修复登录服务器切换与本地资源请求

- 允许 release 客户端安全访问 custom HTTPS 与本机 loopback HTTP

- 修复 Tauri WebView 请求传输与无效 URLPattern

- 按服务器 origin 隔离本机 External Editor 凭据

- 保留登录请求底层错误并补充路由与凭据隔离测试
This commit is contained in:
kdletters
2026-08-18 18:32:49 +08:00
parent 1e9d74fa4a
commit 9070c0760b
9 changed files with 92 additions and 43 deletions
@@ -11,7 +11,13 @@
"core:resources:allow-close",
{
"identifier": "http:default",
"allow": [{ "url": "https://dev.genarrative.world/api/*" }]
"allow": [
{ "url": "https://dev.genarrative.world/api/*" },
{ "url": "https://www.genarrative.world/api/*" },
{ "url": "https://*/api/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
]
},
"opener:default"
]
@@ -1,7 +1,8 @@
use super::*;
use std::future::Future;
use sha2::{Digest as _, Sha256};
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME: &str = "external-editor-api.json";
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024;
const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)";
const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str =
@@ -46,16 +47,22 @@ pub(crate) async fn with_external_editor_api_credentials<T>(
.await
}
fn private_external_editor_api_key_path() -> Result<PathBuf, String> {
fn private_external_editor_api_key_directory() -> Result<PathBuf, String> {
let home = std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?;
Ok(home
.join(".config")
.join("genarrative")
.join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME))
Ok(home.join(".config").join("genarrative"))
}
fn private_external_editor_api_key_path_for_base_url(api_base_url: &str) -> Result<PathBuf, String> {
let api_base_url = normalize_external_editor_api_base_url(api_base_url)?;
let fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes()));
Ok(private_external_editor_api_key_directory()?.join(format!(
"{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX}{}.json",
&fingerprint[..16]
)))
}
pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result<String, String> {
@@ -74,13 +81,12 @@ pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result<Stri
let host = parsed
.host_str()
.ok_or_else(|| "陶泥儿 External Editor 地址缺少 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" && !loopback {
return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
@@ -227,8 +233,12 @@ fn write_private_external_editor_api_credentials_at(
base_url: Some(credentials.api_base_url.clone()),
})
.map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?;
let file_name = path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少文件名".to_string())?;
let temporary = parent.join(format!(
".{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME}.tmp.{}.{}",
".{file_name}.tmp.{}.{}",
std::process::id(),
unix_millis(),
));
@@ -326,7 +336,11 @@ async fn create_private_external_editor_api_credentials_from_platform_session(
/// copied into the file or passed to the later isolated Runtime.
pub(crate) async fn ensure_private_external_editor_api_credentials(
) -> Result<ExternalEditorApiCredentials, String> {
let path = private_external_editor_api_key_path()?;
let session = current_platform_session().ok_or_else(|| {
"authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key"
.to_string()
})?;
let path = private_external_editor_api_key_path_for_base_url(&session.api_base_url)?;
if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? {
return Ok(credentials);
}
@@ -1637,7 +1651,15 @@ mod tests {
.expect("trusted dev origin"),
"https://dev.genarrative.world"
);
assert!(normalize_external_editor_api_base_url("https://untrusted.example.test").is_err());
assert_eq!(
normalize_external_editor_api_base_url("http://127.0.0.1:8085/"),
Ok("http://127.0.0.1:8085".to_string())
);
assert!(normalize_external_editor_api_base_url("http://staging.example.com").is_err());
assert_eq!(
normalize_external_editor_api_base_url("https://untrusted.example.test"),
Ok("https://untrusted.example.test".to_string())
);
assert!(
normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err()
);
@@ -1649,6 +1671,26 @@ mod tests {
assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err());
}
#[test]
fn private_external_editor_credentials_are_isolated_by_server_origin() {
let local = private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8082")
.expect("local credential path");
let previous =
private_external_editor_api_key_path_for_base_url("http://127.0.0.1:8085")
.expect("previous local credential path");
let dev = private_external_editor_api_key_path_for_base_url(
"https://dev.genarrative.world",
)
.expect("dev credential path");
assert_ne!(local, previous);
assert_ne!(local, dev);
assert!(local
.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|name| name.starts_with(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)));
}
#[test]
fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() {
assert!(
@@ -1675,7 +1717,7 @@ mod tests {
.path()
.join("config")
.join("genarrative")
.join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME);
.join("external-editor-api-test.json");
prepare_private_external_editor_api_credentials_parent_dir_at(&path)
.expect("prepare private credential directory");
@@ -131,7 +131,7 @@ fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if parsed.scheme() == "http" && !(cfg!(debug_assertions) && loopback) {
if parsed.scheme() == "http" && !loopback {
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
@@ -21,8 +21,6 @@ import {
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import {
AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
type ClientServerPreset,
type ClientServerSelection,
getClientServerSelection,
@@ -111,20 +109,13 @@ export function AuthenticatedClient({
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() {
function persistServerSelection() {
try {
const next = setClientServerSelection({
setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return true;
} catch (error) {
setLoginStatus(error instanceof Error ? error.message : String(error));
@@ -278,7 +269,7 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) {
return;
}
if (!applyServerSelection()) {
if (!persistServerSelection()) {
return;
}
const normalizedPhone = normalizeAuthPhoneInput(phone);
@@ -317,7 +308,7 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码');
return;
}
if (!applyServerSelection()) {
if (!persistServerSelection()) {
return;
}
setLoginBusy(true);
@@ -394,9 +385,6 @@ export function AuthenticatedClient({
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
<small className="client-auth-server-address">
{selectedServerAddress}
</small>
</label>
{serverSelection.preset === 'custom' ? (
<label>
@@ -413,7 +401,7 @@ export function AuthenticatedClient({
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
applyServerSelection();
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
@@ -122,9 +122,13 @@ async function requestAuthJson<T>(
credentials: 'same-origin',
headers,
});
} catch {
} catch (error) {
const detail =
error instanceof Error ? error.message.trim() : String(error).trim();
throw new ClientAuthRequestError(
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
detail
? `无法连接登录服务:${detail}`
: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
{ networkError: true },
);
}
@@ -154,9 +154,7 @@ export function resolveClientHttpTarget(
throw new Error('请求目标不在当前选择的服务器范围内');
}
const isHttpPage =
context.pageProtocol === 'http:' || context.pageProtocol === 'https:';
if (!context.isTauri || isHttpPage) {
if (!context.isTauri) {
return { transport: 'web', url: target.toString() };
}
return { transport: 'tauri-http', url: target.toString() };
@@ -102,12 +102,6 @@ textarea {
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%);
@@ -129,4 +129,19 @@ describe('AGC client HTTP transport', () => {
url: `${serverBaseUrl}/api/auth/me`,
});
});
it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'http:',
mode: 'production',
serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
});
@@ -14253,4 +14253,6 @@
## 2026-08-18 AGC 登录服务器选择
- AGC 登录页提供 `release``https://www.genarrative.world`)、`dev``https://dev.genarrative.world`)和 `custom` 三种服务器选择;选择持久化在客户端本地存储,登录、验证码、刷新和原生平台会话安装统一使用当前选择。
- custom 只接受纯 HTTPS origin开发环境允许 `localhost` / loopback 的 HTTP,禁止把路径、查询参数、凭据或非本机明文 HTTP 地址作为服务器地址。
- custom 只接受纯 HTTPS origin`localhost` / loopback 的 HTTP 也允许用于本机服务,禁止把路径、查询参数、凭据或非本机明文 HTTP 地址作为服务器地址。
- Tauri release 的 HTTP capability scope 必须覆盖 release、dev、custom HTTPS 以及 loopback HTTP,否则前端选择虽能保存,plugin-http 仍会在请求层拒绝登录。
- 直连 Codex 的本机 External Editor API Key 必须按服务器 origin 独立存储。登录服务器切换后禁止复用另一 origin 的历史 Key 或 base URL;否则会出现登录走新服务器、平台资源生成仍请求旧服务器的漂移。