修复登录服务器切换与本地资源请求
- 允许 release 客户端安全访问 custom HTTPS 与本机 loopback HTTP - 修复 Tauri WebView 请求传输与无效 URLPattern - 按服务器 origin 隔离本机 External Editor 凭据 - 保留登录请求底层错误并补充路由与凭据隔离测试
This commit is contained in:
@@ -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;否则会出现登录走新服务器、平台资源生成仍请求旧服务器的漂移。
|
||||
|
||||
Reference in New Issue
Block a user