修复发行资源路径与审核试玩 Cookie
Project CI / Backend tests (pull_request) Failing after 29s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m48s
Project CI / Frontend tests (pull_request) Failing after 1m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m18s
Project CI / Repository checks (pull_request) Failing after 42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 4m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 6m37s
Project CI / Backend tests (pull_request) Failing after 29s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m48s
Project CI / Frontend tests (pull_request) Failing after 1m11s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m18s
Project CI / Repository checks (pull_request) Failing after 42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 4m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 6m37s
归一化发行包根绝对资源引用并兼容已有发行包,修复正式运行时素材请求跑到本地 Vite 的问题。 审核预览仅拒绝真实平台 refresh Cookie,保留版本绑定 Token 和私有缓存隔离。
This commit is contained in:
@@ -544,7 +544,9 @@ pub(crate) fn read_local_project_export_package_at(
|
||||
if content.len() as u64 != expected_size {
|
||||
return Err(format!("发行包条目长度不一致:{path}"));
|
||||
}
|
||||
let content = normalize_release_asset_references(&path, content);
|
||||
entries.push((path, content));
|
||||
|
||||
}
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if entries.is_empty() {
|
||||
@@ -590,6 +592,46 @@ pub(crate) fn read_local_project_export_package_at(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_release_asset_references(path: &str, content: Vec<u8>) -> Vec<u8> {
|
||||
let is_text = matches!(
|
||||
path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(),
|
||||
"css" | "html" | "htm" | "js" | "json" | "mjs"
|
||||
);
|
||||
if !is_text {
|
||||
return content;
|
||||
}
|
||||
let mut source = match String::from_utf8(content) {
|
||||
Ok(source) => source,
|
||||
Err(error) => return error.into_bytes(),
|
||||
};
|
||||
for root in ["assets", "game", "ui"] {
|
||||
source = source.replace(&format!("\"/{root}/"), &format!("\"{root}/"));
|
||||
source = source.replace(&format!("'/{root}/"), &format!("'{root}/"));
|
||||
source = source.replace(&format!("`/{root}/"), &format!("`{root}/"));
|
||||
source = source.replace(&format!("url(/{root}/"), &format!("url({root}/"));
|
||||
}
|
||||
source.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_release_asset_references;
|
||||
|
||||
#[test]
|
||||
fn normalize_release_asset_references_removes_root_prefix_only_in_text() {
|
||||
let source = br#"<img src=\"/assets/hero.png\"><script>fetch('/ui/config.json')</script><style>url(/game/icon.svg)</style>"#;
|
||||
let normalized = normalize_release_asset_references("index.html", source.to_vec());
|
||||
let normalized = String::from_utf8(normalized).expect("normalized text");
|
||||
assert!(normalized.contains("src=\"assets/hero.png\""));
|
||||
assert!(normalized.contains("fetch('ui/config.json')"));
|
||||
assert!(normalized.contains("url(game/icon.svg)"));
|
||||
assert_eq!(
|
||||
normalize_release_asset_references("image.png", b"/assets/image.png".to_vec()),
|
||||
b"/assets/image.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
|
||||
let seed = unix_millis();
|
||||
for suffix in 0..1000 {
|
||||
|
||||
@@ -83,13 +83,13 @@
|
||||
### 真实发行包与资料合同
|
||||
|
||||
1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
|
||||
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。AGC 发布归一化阶段只把包内已知根路径 `/assets/`、`/game/`、`/ui/` 转成相对引用,不修改项目源码;其它外部绝对地址仍由客户端/服务器拒绝,静态校验不能代替运行时 CSP 阻断。
|
||||
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
|
||||
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
|
||||
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install` 和 `npm run build`,将 `dist` 归一化为根 `index.html` ZIP;为兼容 AGC 上传素材的运行 URL,会补入项目根 `assets/**` 中 dist 未包含的文件,同路径以 dist 构建产物为准,不修改项目源码。
|
||||
6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
|
||||
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP,并在游戏脚本前注入隔离的运行期 `localStorage` / `sessionStorage` 兼容层,避免游戏直接读取 opaque origin 原生 storage 时抛 `SecurityError`。兼容层只在当前运行实例内存中有效,不读取平台 Cookie、主站 DOM 或账号数据。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP,并在游戏脚本前注入隔离的运行期 `localStorage` / `sessionStorage` 兼容层,避免游戏直接读取 opaque origin 原生 storage 时抛 `SecurityError`。兼容层只在当前运行实例内存中有效,不读取平台 Cookie、主站 DOM 或账号数据。公开发行与审核预览只拒绝真实平台 refresh Cookie;审核预览 Token 绑定单个版本且短期有效。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
|
||||
9. 发行入口既不由管理员填写,也不需要部署侧配置:审核通过时 `api-server` 按 gameId 派生**平台同源路径** `/games/{gameId}/` 写入公开投影,dev / release / 预览环境口径完全一致,不再需要发行域名、通配 DNS 或通配证书。gameId 必须是服务端生成的稳定标识(只允许 `[A-Za-z0-9_-]`),派生失败时审核通过直接失败,不回落主站其它路径、内网地址或任意外部地址。客户端读取该字段时按当前 origin 解析成绝对地址再交给 iframe;历史数据里的绝对 URL(非当前源的 https)继续兼容,新写入只用相对路径。路径到发行网关的映射由边缘 nginx 的同源发行入口 location 完成。
|
||||
|
||||
### 身份、状态、审核与更新
|
||||
|
||||
@@ -22,6 +22,7 @@ use module_game_distribution::{
|
||||
normalize_review_moderation_reason, release_asset_content_type, validate_release_zip,
|
||||
validate_review_list_status,
|
||||
};
|
||||
use platform_auth::read_refresh_session_token;
|
||||
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmMessage, LlmRunRequest};
|
||||
use platform_oss::{
|
||||
OssAppendInternalObjectRequest, OssDeleteObjectRequest, OssGetObjectRequest,
|
||||
@@ -878,10 +879,31 @@ fn release_package_asset_response(
|
||||
}
|
||||
Err(_) => return Err(AppError::from_status(StatusCode::NOT_FOUND)),
|
||||
};
|
||||
let content = normalize_release_asset_references(content, content_type);
|
||||
let content = inject_release_storage_bootstrap(content, content_type);
|
||||
Ok(release_asset_response_with_cache(content, content_type, cache_control))
|
||||
}
|
||||
|
||||
fn normalize_release_asset_references(content: Vec<u8>, content_type: &str) -> Vec<u8> {
|
||||
if !(content_type.starts_with("text/html")
|
||||
|| content_type.starts_with("text/css")
|
||||
|| content_type.contains("javascript"))
|
||||
{
|
||||
return content;
|
||||
}
|
||||
let mut source = match String::from_utf8(content) {
|
||||
Ok(source) => source,
|
||||
Err(error) => return error.into_bytes(),
|
||||
};
|
||||
for root in ["assets", "game", "ui"] {
|
||||
source = source.replace(&format!("\"/{root}/"), &format!("\"{root}/"));
|
||||
source = source.replace(&format!("'/{root}/"), &format!("'{root}/"));
|
||||
source = source.replace(&format!("`/{root}/"), &format!("`{root}/"));
|
||||
source = source.replace(&format!("url(/{root}/"), &format!("url({root}/"));
|
||||
}
|
||||
source.into_bytes()
|
||||
}
|
||||
|
||||
fn inject_release_storage_bootstrap(content: Vec<u8>, content_type: &str) -> Vec<u8> {
|
||||
if !content_type.starts_with("text/html") {
|
||||
return content;
|
||||
@@ -2423,9 +2445,16 @@ async fn serve_admin_version_preview_asset_inner(
|
||||
preview_token: String,
|
||||
asset_path: String,
|
||||
) -> Result<Response, AppError> {
|
||||
if headers.contains_key(header::COOKIE) {
|
||||
if headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|cookie_header| {
|
||||
read_refresh_session_token(cookie_header, state.refresh_cookie_config())
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::from_status(StatusCode::FORBIDDEN)
|
||||
.with_message("审核试玩资源不能携带平台 Cookie"));
|
||||
.with_message("审核试玩资源不能携带平台会话 Cookie"));
|
||||
}
|
||||
let session = state
|
||||
.get_game_distribution_preview_session(&preview_token)
|
||||
@@ -4001,6 +4030,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: Some(
|
||||
r#"[{"assetId":"asset_1","objectKey":"generated/shot-1.png"}]"#.to_string(),
|
||||
@@ -4063,6 +4093,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
cover_object_key: Some("generated/game-cover.png".to_string()),
|
||||
screenshots_json: None,
|
||||
};
|
||||
@@ -4128,6 +4159,7 @@ mod tests {
|
||||
play_count: 0,
|
||||
created_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-09-20T00:00:00Z".to_string(),
|
||||
local_project_id: None,
|
||||
cover_object_key: None,
|
||||
screenshots_json: None,
|
||||
};
|
||||
@@ -4582,6 +4614,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn release_normalizes_legacy_root_asset_references() {
|
||||
let source = br#"<script>fetch('/assets/hero.png')</script><style>url(/ui/icon.svg)</style>"#;
|
||||
let normalized = normalize_release_asset_references(
|
||||
source.to_vec(),
|
||||
"text/javascript; charset=utf-8",
|
||||
);
|
||||
let normalized = String::from_utf8(normalized).expect("normalized source");
|
||||
assert!(normalized.contains("fetch('assets/hero.png')"));
|
||||
assert!(normalized.contains("url(ui/icon.svg)"));
|
||||
assert_eq!(
|
||||
normalize_release_asset_references(vec![1, 2, 3], "image/png"),
|
||||
vec![1, 2, 3]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn release_response_sets_nosniff_and_scopes_csp_to_html() {
|
||||
let html = release_asset_response_with_cache(
|
||||
|
||||
Reference in New Issue
Block a user