增加登录服务器选择

- 登录页支持 release、dev 和 custom 服务器

- 持久化服务器选择并统一登录与平台会话请求地址

- 校验自定义服务器 origin 与 HTTPS 安全边界

- 补充前端、AppSurface 和 Rust 平台会话测试
This commit is contained in:
kdletters
2026-08-18 13:46:03 +08:00
parent bdf7ed288a
commit 1e9d74fa4a
8 changed files with 396 additions and 35 deletions
@@ -126,13 +126,12 @@ fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
let host = parsed
.host_str()
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world");
let loopback = host == "localhost"
|| host == "127.0.0.1"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") {
if parsed.scheme() == "http" && !(cfg!(debug_assertions) && loopback) {
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
@@ -342,6 +341,20 @@ mod tests {
);
}
#[test]
fn custom_server_selection_accepts_https_origins_and_rejects_plain_http() {
assert_eq!(
normalize_platform_api_base_url("https://staging.example.com/"),
Ok("https://staging.example.com".to_string())
);
assert_eq!(
normalize_platform_api_base_url("http://127.0.0.1:8080/"),
Ok("http://127.0.0.1:8080".to_string())
);
assert!(normalize_platform_api_base_url("http://staging.example.com").is_err());
assert!(normalize_platform_api_base_url("https://staging.example.com/api").is_err());
}
#[test]
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
let expected = PlatformSessionSnapshot {
@@ -20,6 +20,15 @@ import {
normalizeAuthPhoneInput,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import {
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
type ClientServerPreset,
type ClientServerSelection,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import {
beginPlatformSessionTransition,
clearCommittedPlatformSession,
@@ -95,6 +104,46 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
const selectedServerAddress =
serverSelection.preset === 'release'
? AGC_RELEASE_API_BASE_URL
: serverSelection.preset === 'dev'
? AGC_DEVELOPMENT_API_BASE_URL
: customServerUrl || '请输入自定义服务器地址';
function applyServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return true;
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
return false;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => {
let disposed = false;
@@ -229,6 +278,9 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
if (!applyServerSelection()) {
return;
}
const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) {
setLoginStatus('请输入手机号');
@@ -265,6 +317,9 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码');
return;
}
if (!applyServerSelection()) {
return;
}
setLoginBusy(true);
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
@@ -324,6 +379,51 @@ export function AuthenticatedClient({
<h1> GameAgent</h1>
<p></p>
</div>
<label>
<select
aria-label="服务器"
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
<small className="client-auth-server-address">
{selectedServerAddress}
</small>
</label>
{serverSelection.preset === 'custom' ? (
<label>
<input
aria-label="自定义服务器地址"
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
applyServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
);
}
}
}}
/>
</label>
) : null}
<div className="client-auth-tabs" role="group" aria-label="登录方式">
<button
type="button"
@@ -1,11 +1,123 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
return import.meta.env.DEV ? 'dev' : 'release';
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
export function getClientServerBaseUrl(
selection: ClientServerSelection = getClientServerSelection(),
) {
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(selection.customBaseUrl);
}
type ClientHttpContext = {
isDevelopment: boolean;
isTauri: boolean;
pageProtocol: string;
mode?: string;
serverBaseUrl?: string;
};
type ClientHttpTarget = {
@@ -18,6 +130,7 @@ function currentClientHttpContext(): ClientHttpContext {
isDevelopment: import.meta.env.DEV,
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
mode: import.meta.env.MODE,
};
}
@@ -25,23 +138,26 @@ export function resolveClientHttpTarget(
url: string,
context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget {
if (context.isDevelopment) {
// Existing unit fixtures omit mode; retain the Vite-relative transport for
// them while real development/release clients use the selected server.
if (
!context.serverBaseUrl &&
(context.mode === 'test' || (!context.mode && context.isDevelopment))
) {
return { transport: 'web', url };
}
const serverBaseUrl =
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
const target = new URL(url, `${serverBaseUrl}/`);
if (target.origin !== serverBaseUrl) {
throw new Error('请求目标不在当前选择的服务器范围内');
}
const isHttpPage =
context.pageProtocol === 'http:' || context.pageProtocol === 'https:';
if (!context.isTauri && isHttpPage) {
return { transport: 'web', url };
}
if (!context.isTauri) {
return { transport: 'web', url };
}
const target = new URL(url, `${AGC_DEVELOPMENT_API_BASE_URL}/`);
if (target.origin !== AGC_DEVELOPMENT_API_BASE_URL) {
throw new Error('AGC release API 请求目标不在允许的开发服务器范围内');
if (!context.isTauri || isHttpPage) {
return { transport: 'web', url: target.toString() };
}
return { transport: 'tauri-http', url: target.toString() };
}
@@ -5,6 +5,7 @@ import {
getStoredAuthAccessToken,
refreshClientAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -62,23 +63,7 @@ async function installCommittedPlatformSession(
}
async function resolvePlatformApiBaseUrl() {
if (!import.meta.env.DEV || import.meta.env.MODE === 'test') {
return 'https://dev.genarrative.world';
}
const response = await fetch('/__agc_dev_server.json', {
cache: 'no-store',
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error('无法读取本地陶泥儿 API 服务地址');
}
const marker = (await response.json()) as { apiTarget?: unknown };
const apiBaseUrl =
typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : '';
if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) {
throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内');
}
return apiBaseUrl;
return getClientServerBaseUrl();
}
async function commitPlatformSession(
+27 -1
View File
@@ -86,6 +86,28 @@ textarea {
font: inherit;
}
.client-auth-panel select {
height: 38px;
min-width: 0;
padding: 0 11px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #111827;
font: inherit;
}
.client-auth-panel select:focus {
border-color: #111827;
outline: 2px solid rgb(17 24 39 / 10%);
}
.client-auth-server-address {
color: #6b7280;
font-size: 12px;
font-weight: 500;
}
.client-auth-panel input:focus {
border-color: #111827;
outline: 2px solid rgb(17 24 39 / 10%);
@@ -5728,7 +5750,11 @@ iframe.preview-frame {
min-width: max-content;
margin: 0;
padding: 14px;
font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace;
font:
12px/1.55 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
white-space: pre;
}
@@ -1,5 +1,6 @@
import { afterEach } from 'vitest';
import { resetClientServerSelectionForTests } from '../../src/services/clientHttp';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
@@ -23,6 +24,7 @@ import {
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionStateForTests();
resetClientServerSelectionForTests();
delete window.__TAURI__;
});
@@ -265,6 +267,39 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull();
});
it('shows release, dev, and custom server choices on the login screen', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
throw new Error(`unexpected fetch ${String(input)}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }, 'ready'),
),
);
await screen.findByRole('main', { name: '登录' });
const server = screen.getByRole('combobox', { name: '服务器' });
expect(server).not.toBeNull();
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
fireEvent.change(server, { target: { value: 'custom' } });
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'https://staging.example.com' },
});
expect(
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
).toBe('https://staging.example.com');
});
it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
@@ -1,11 +1,19 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import {
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
resetClientServerSelectionForTests,
resolveClientHttpTarget,
setClientServerSelection,
} from '../src/services/clientHttp';
describe('AGC client HTTP transport', () => {
afterEach(() => resetClientServerSelectionForTests());
it('keeps local development requests on the Vite API proxy', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
@@ -22,10 +30,12 @@ describe('AGC client HTTP transport', () => {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`,
});
});
@@ -35,6 +45,7 @@ describe('AGC client HTTP transport', () => {
isDevelopment: false,
isTauri: false,
pageProtocol: 'https:',
mode: 'test',
}),
).toEqual({ transport: 'web', url: '/api/auth/me' });
});
@@ -45,7 +56,77 @@ describe('AGC client HTTP transport', () => {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toThrow('不在允许的开发服务器范围');
).toThrow('当前选择的服务器范围');
});
it('persists release, dev, and custom server selection', () => {
const release = setClientServerSelection({
preset: 'release',
customBaseUrl: '',
});
expect(release).toEqual({
preset: 'release',
customBaseUrl: '',
});
expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL);
const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL);
const custom = setClientServerSelection({
preset: 'custom',
customBaseUrl: 'https://staging.example.com/',
});
expect(custom).toEqual({
preset: 'custom',
customBaseUrl: 'https://staging.example.com',
});
expect(getClientServerSelection().preset).toBe('dev');
expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com');
});
it('accepts HTTPS custom servers and loopback HTTP only', () => {
expect(normalizeClientServerBaseUrl('https://example.com/')).toBe(
'https://example.com',
);
expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe(
'http://127.0.0.1:8080',
);
expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow(
'必须使用 HTTPS',
);
expect(() =>
normalizeClientServerBaseUrl('https://example.com/api'),
).toThrow('纯 HTTP(S)');
});
it('routes selected custom servers for both web and Tauri clients', () => {
const serverBaseUrl = 'https://staging.example.com';
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: false,
pageProtocol: 'http:',
mode: 'development',
serverBaseUrl,
}),
).toEqual({
transport: 'web',
url: `${serverBaseUrl}/api/auth/me`,
});
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl,
}),
).toEqual({
transport: 'tauri-http',
url: `${serverBaseUrl}/api/auth/me`,
});
});
});
@@ -14249,3 +14249,8 @@
- 普通客户端必须展示上述安全摘要与建议,不能把可解释的资源恢复失败降级成“执行失败,请稍后重试”。平台账号失效提示重新登录;资源身份冲突、多个同源图集或透明图集明确要求先在资源画布核对,而非盲目重复生成或扣费。
- 已有同一画布、身份可信且可解码的规范图与背景图时,历史核心图集的只读恢复只是可选增强:未找到该图集不得阻断直连 Codex 生成、浏览器试玩或版本登记,也不得触发重复付费生成;最终源码仍须实际引用至少一个已登记的平台图片。
- 透明后处理失败但平台已保留源图时,只有同一画布身份的只读恢复成功后才清理对应 `agentId/runId` 生成账本;恢复失败、身份不唯一或结果未知继续保留 `accepted` / `operationId` 供对账,禁止因清理过早而重复扣费。
## 2026-08-18 AGC 登录服务器选择
- AGC 登录页提供 `release``https://www.genarrative.world`)、`dev``https://dev.genarrative.world`)和 `custom` 三种服务器选择;选择持久化在客户端本地存储,登录、验证码、刷新和原生平台会话安装统一使用当前选择。
- custom 只接受纯 HTTPS origin;开发环境允许 `localhost` / loopback 的 HTTP,禁止把路径、查询参数、凭据或非本机明文 HTTP 地址作为服务器地址。