From 87e52860a7134a5243cb636eb3cc3eb502740b14 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 24 Sep 2026 00:21:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=8F=91=E8=A1=8C=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=94=B9=E4=B8=BA=E5=B9=B3=E5=8F=B0=E5=90=8C=E6=BA=90?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审核通过时由 api-server 按 gameId 派生 /games/{gameId}/ 相对路径写入公开投影,删除 AppConfig 的发行入口模板字段与读取逻辑 - 删除 deploy/env 两份示例中的 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE - 三份 nginx 模板内联同源发行入口 location,把 /games// 与子资源转发到发行网关并在边缘清空 Cookie - SPA allowlist 补齐 components、design-system、games、games/detail、games/mine、games/play、games/publish - 前端 normalizeGameEntryUrl 支持相对路径与同源发行路径,按当前 origin 解析并补尾斜杠,继续兼容历史绝对 URL - 删除退役的独立来源模板 deploy/nginx/genarrative-release-origin.conf、门禁脚本 scripts/check-release-origin-config.mjs 与其 npm 脚本 - 游戏分发 e2e 脚本改为在公开投影上断言 entryUrl 等于 /games/{gameId}/ - 同步平台主规范、运维主规范、nginx README 与共享决策记录 --- deploy/container/api-server.env.example | 5 +- deploy/container/nginx.conf | 18 +- deploy/env/api-server.env.example | 7 +- deploy/nginx/README.md | 13 +- deploy/nginx/genarrative-dev-http.conf | 18 +- deploy/nginx/genarrative-release-origin.conf | 81 ------ deploy/nginx/genarrative.conf | 18 +- .../shared-memory/decision-log.md | 11 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 22 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 18 +- package.json | 1 - .../shared/src/contracts/gameDistribution.ts | 4 + scripts/check-game-distribution-media-e2e.mjs | 11 +- scripts/check-release-origin-config.mjs | 272 ------------------ server-rs/crates/api-server/src/config.rs | 7 - .../src/modules/game_distribution.rs | 174 +---------- .../shared-contracts/src/game_distribution.rs | 1 + .../gameDistributionGuards.test.ts | 46 +++ .../gameDistributionGuards.ts | 34 ++- 19 files changed, 187 insertions(+), 574 deletions(-) delete mode 100644 deploy/nginx/genarrative-release-origin.conf delete mode 100644 scripts/check-release-origin-config.mjs create mode 100644 src/components/game-distribution/gameDistributionGuards.test.ts diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example index 8e3d0ec1f..2ce6a6ddb 100644 --- a/deploy/container/api-server.env.example +++ b/deploy/container/api-server.env.example @@ -72,6 +72,5 @@ GENARRATIVE_LLM_MODEL=gpt-5.4-mini WECHAT_MINIPROGRAM_MESSAGE_TOKEN= WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= -# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如 -# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过直接失败。 -GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE= +# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生, +# 不需要部署侧配置发行域名或通配证书。 diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 6d3284ce0..0b8d4c958 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -136,12 +136,28 @@ http { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { try_files $uri /index.html =404; } # END GENARRATIVE MAIN SPA ROUTES diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index f73b16233..02202a8ec 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -179,10 +179,9 @@ GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID= GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET= GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev -# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如 -# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过 -# 直接失败;非生产未配置时回落 http://127.0.0.1:/api/game-distribution/releases/{gameId}/。 -GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE= +# 游戏发行入口固定为平台同源路径 /games/{gameId}/:审核通过时由 api-server 自己派生, +# 不需要部署侧配置发行域名或通配证书;边缘由 nginx 的 +# genarrative-game-distribution-path.conf 把该路径映射到发行网关。 # SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。 GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index a34464db9..c931fc687 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -100,10 +100,11 @@ curl -sSI -H 'Accept-Encoding: br' \ - br 可用时返回 `Content-Encoding: br`。 - 响应头应包含 `Vary: Accept-Encoding`。 -## 游戏发行来源(每游戏独立 origin) +## 游戏发行来源(平台同源路径) -- `deploy/nginx/genarrative-release-origin.conf` 为已公开游戏提供每游戏独立来源:`https://.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。 -- 该来源只把子域根路径映射到 `…/releases//index.html`、其余路径映射到 `…/releases//<原路径>`;平台 API、后台、SPA 与上传接口都不在这个来源上暴露,命中即 404。 -- 发行来源不使用 Cookie:带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 -- 审核通过时 `api-server` 按部署模板(`GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=https://{gameId}.games.example.com/`)与 gameId 派生 `entryUrl`,即该子域根地址;换版本或下架只改变后端公开投影,边缘不需要改配置。 -- 门禁:`npm run check:release-origin-config` 会逐条校验模板约束、交叉检查发行网关仍在设置上述响应头,并在本机存在 `nginx` 与 `openssl` 时用自签通配证书渲染一份临时配置执行 `nginx -t`。 +- 现役发行入口是平台同源路径 `https://<平台域名>/games//`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games//` 与 `/games//` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。 +- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t` 报 `pcre2_compile() failed: missing closing parenthesis`。 +- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 +- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 +- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 640ef088c..49b76e619 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -179,6 +179,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { error_page 503 /maintenance.html; @@ -190,7 +206,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative-release-origin.conf b/deploy/nginx/genarrative-release-origin.conf deleted file mode 100644 index 223f83dab..000000000 --- a/deploy/nginx/genarrative-release-origin.conf +++ /dev/null @@ -1,81 +0,0 @@ -# 游戏发行来源(每游戏独立 origin) -# -# 部署前替换: -# 1) `games.example.com` 为真实发行域,并为 `*.games.example.com` 配置通配 DNS -# 与通配 TLS 证书; -# 2) `ssl_certificate` / `ssl_certificate_key` 指向该通配证书; -# 3) upstream 端口与 api-server 实际监听一致。 -# -# 设计约定: -# - 每个已公开游戏使用自己的子域:`https://.games.example.com/`; -# - 该来源只把请求映射到发行网关 -# `/api/game-distribution/releases//…`,平台 API、后台、SPA 与上传 -# 接口都不在这个来源上暴露; -# - 发行来源从不使用 Cookie:带 Cookie 的请求直接 403,转发前也会清空 Cookie; -# - `X-Content-Type-Options` / CORP / 无凭据 CORS / HTML CSP / 内容类型白名单由 -# api-server 发行网关设置,这里不覆盖,避免两层策略漂移; -# - 公开版本切换与下架由后端 `publication_revision` CAS 决定,边缘只做按主机映射。 - -upstream genarrative_release_api { - server 127.0.0.1:8082; - keepalive 32; -} - -server { - listen 80; - server_name ~^(?[a-z0-9_]+)\.games\.example\.com$; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl http2; - server_name ~^(?[a-z0-9_]+)\.games\.example\.com$; - - ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/games.example.com/privkey.pem; - - access_log /var/log/nginx/genarrative-release.access.log; - error_log /var/log/nginx/genarrative-release.error.log warn; - - # 发行文件是公开静态资源,从不携带平台 Cookie。带上 Cookie 的请求说明它落在 - # 平台会话来源上,直接拒绝,避免发行内容被主站同源脚本读取。 - if ($http_cookie) { - return 403; - } - - # 子域根路径直接服务该游戏的 index.html,游戏内其余资源按相对路径原样交给 - # 发行网关;审核通过时 api-server 按发行入口模板派生的 entryUrl 就是 - # https://.games.example.com/。 - location = / { - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Request-Id $request_id; - proxy_set_header Cookie ""; - proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html; - proxy_read_timeout 60s; - proxy_send_timeout 60s; - } - - location / { - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Request-Id $request_id; - proxy_set_header Cookie ""; - proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri; - proxy_read_timeout 60s; - proxy_send_timeout 60s; - } -} diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index b7d1c433a..981a7c932 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -199,6 +199,22 @@ server { return 404; } + # 平台同源路径发行入口:/games// 与 /games// 映射到 + # api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里, + # 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。 + location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$" { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_pass http://genarrative_api/api/game-distribution/releases/$game_id$game_path; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + # BEGIN GENARRATIVE MAIN SPA ROUTES location = / { error_page 503 /maintenance.html; @@ -210,7 +226,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { + location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6ca080a60..5838a77f4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9318,3 +9318,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:`entryUrl` 仍是公开投影字段,只是改由服务端写入;审核请求摘要不再包含它,表结构与版本回读不变;模板变更只影响之后新通过审核的版本,历史版本已冻结的 `entry_url` 不改写。 - 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.test.ts,pages/AdminGameDistributionReviewPage.tsx,pages/AdminGameDistributionReviewPage.test.tsx}`、`scripts/check-game-distribution-media-e2e.mjs`、`deploy/{nginx,env,container}`、平台与运维主规范、发行里程碑实施计划。 - 验证:`cargo check -p api-server`、`cargo test -p api-server game_distribution`(31 passed)、admin-web 定向 Vitest(19 passed)与 `apps/admin-web` typecheck、`npm run check:release-origin-config`、`npm run check:doc-index`、`npm run check:encoding`、`git diff --check` 全部通过;真实栈端到端(真实 OSS + SpacetimeDB + 审核通过)未在本轮复跑。 + +## 2026-09-24 游戏发行入口改为平台同源路径:取消发行域名与部署模板变量 + +- 背景:每游戏独立来源要求 `*.games.<域名>` 通配 DNS 与通配 TLS,一直未在任何环境落地,dev / release 审核通过直接报「发行来源未配置」;同时线上 SPA 白名单缺少 `games` 系列路由,`/games`、`/games/detail`、`/games/play` 在真实域名上全部 404。运行隔离实际由 iframe `sandbox="allow-scripts"` 的不透明来源承担,不需要独立 origin 兜底。 +- 决策(唯一口径):发行入口固定为平台同源路径 `/games/{gameId}/`。审核通过时 `api-server` 按 gameId 派生该相对路径写入公开投影,不再读取 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`;`AppConfig` 字段与两份部署 env 示例一并删除。dev / release / 预览环境口径一致,不再需要发行域名、通配 DNS 或通配 TLS。 +- 决策(边缘):`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf` 三份模板内联同一条同源发行入口 location,把 `/games//` 与 `/games//` 转发到发行网关,转发前清空 `Cookie`;正则整体必须加双引号,否则 `{32}` 会被 nginx 当块定界符。SPA allowlist 补齐 `components`、`design-system`、`games`、`games/detail`、`games/mine`、`games/play`、`games/publish`。 +- 决策(客户端):`normalizeGameEntryUrl` 接受相对路径与同源发行路径,按当前 origin 解析成绝对地址后交给 iframe;无尾斜杠会归一化补齐。同源非发行路径继续拒绝,非当前源的绝对 https 继续兼容历史数据。 +- 决策(退役):删除 `deploy/nginx/genarrative-release-origin.conf`、`scripts/check-release-origin-config.mjs` 与 `npm run check:release-origin-config`;独立来源不再作为上线门禁。 +- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。 +- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。 +- 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index e4b697fbd..0414a6d16 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -647,24 +647,22 @@ Nginx 负责站点和反向代理 Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 ``` -### 游戏发行来源(发行域名、每游戏 origin 与缓存窗口) +### 游戏发行来源(平台同源路径与缓存窗口) -已公开游戏运行在**独立来源**上,与主站来源隔离;这是发行网关(`api-server`)之外唯一需要的边缘配置。 +已公开游戏通过**平台同源路径** `/games//` 提供,边缘只做前缀映射,不再需要独立发行域名、通配 DNS 或通配 TLS。 -- 配置工件:`deploy/nginx/genarrative-release-origin.conf`。上线前替换域名、通配证书路径与 upstream 端口,并把文件安装到 Nginx 站点目录。 -- 前置资源:`*.games.<域名>` 通配 DNS 指向同一入口,以及覆盖该通配名的 TLS 证书(certbot DNS-01 或等价流程)。 -- 路由约定:`https://.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases//…`;平台 API、后台、SPA 与上传接口在这个来源上一律 404,命中即证明边缘多代理了命名空间。 -- 会话隔离:发行来源从不使用 Cookie。带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`;发行网关自身同样对带 Cookie 的请求返回 403。 -- 响应头与缓存:`X-Content-Type-Options`、CORP(`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以“停止继续分发”为准。 -- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl` 由 `api-server` 按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(必须含 `{gameId}` 占位符)与 gameId 派生,生产应配置 `https://{gameId}.games.<域名>/`;派生结果仍按 HTTPS、无凭据、无 query/fragment 校验,模板缺失或派生结果非法时审核通过直接失败。非生产环境未配置模板时回落到本地回环发行网关地址(`http://127.0.0.1:/api/game-distribution/releases/{gameId}/`)用于联调。 -- 门禁与本地联调: +- 路由约定:`https://<平台域名>/games//` 是该游戏的入口,`/games//` 映射到发行网关 `/api/game-distribution/releases//`。同源发行入口 location 写在 `deploy/nginx/genarrative.conf`(生产 HTTPS)、`deploy/nginx/genarrative-dev-http.conf`(开发 HTTP)与 `deploy/container/nginx.conf`(容器)里。正则必须整体加双引号:`location ~ "^/games/(?game_[0-9a-f]{32})(?/.*)?$"`,否则 nginx 会把 `{32}` 当块定界符并在 `nginx -t` 报 missing closing parenthesis。 +- 会话与隔离:边缘在转发前 `proxy_set_header Cookie ""`,发行网关自身也对带 `Cookie` 的请求返回 `403`;游戏文档跑在 iframe `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载整套游戏代码。 +- 响应头与缓存:`X-Content-Type-Options`、CORP(`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以"停止继续分发"为准。 +- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl` 由 `api-server` 按 gameId 派生**同源路径** `/games/{gameId}/` 写入公开投影;dev / release / 预览环境口径完全一致,入口不再由部署侧配置,历史数据里的绝对 URL 继续兼容。 +- 门禁: ```bash -# 模板约束 + 发行网关响应头策略交叉检查;本机有 nginx/openssl 时还会渲染一份临时配置跑 nginx -t -npm run check:release-origin-config +# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由) +npm run check:nginx-spa-routes ``` -本地想在真实边缘语义下复验时,可以把模板渲染到 `~/data/tmp`(替换 upstream 为本地 api-server 端口、证书换成自签通配证书、监听端口换成高位端口),用 `nginx -c <渲染文件>` 起一个临时实例,再用 `curl -H 'Host: .games.example.com'` 验证:根路径 200 `text/html`、`/assets/*` 200、带 Cookie 403、平台 API 路径 404、未知 gameId 404、http 301 到 https。 +本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games//` 验证:入口文档 200 `text/html`、`/games//assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。 #### 游戏分发可观测事件 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index daa54b892..99e4c004a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -89,8 +89,8 @@ 5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。灰度默认关闭:未配置该键、或 `enabled=false` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。 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。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 -9. 发行入口不由管理员填写:部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`),审核通过时 `api-server` 按模板与 gameId 派生每游戏独立来源地址,再按绝对 HTTPS、无凭据、无 query/fragment 校验后写入公开投影;模板缺 `{gameId}`、生产未配置模板或派生结果非法时审核通过直接失败,不偷偷回落到主站或内网地址。非生产环境未配置模板时回落到 `http://127.0.0.1:/api/game-distribution/releases/{gameId}/`,口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。 +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。带平台 `Cookie` 的请求一律 `403`;边缘在转发到发行网关前清空 `Cookie`,游戏文档又运行在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie 与 storage。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 +9. 发行入口既不由管理员填写,也不需要部署侧配置:审核通过时 `api-server` 按 gameId 派生**平台同源路径** `/games/{gameId}/` 写入公开投影,dev / release / 预览环境口径完全一致,不再需要发行域名、通配 DNS 或通配证书。gameId 必须是服务端生成的稳定标识(只允许 `[A-Za-z0-9_-]`),派生失败时审核通过直接失败,不回落主站其它路径、内网地址或任意外部地址。客户端读取该字段时按当前 origin 解析成绝对地址再交给 iframe;历史数据里的绝对 URL(非当前源的 https)继续兼容,新写入只用相对路径。路径到发行网关的映射由边缘 nginx 的同源发行入口 location 完成。 ### 身份、状态、审核与更新 @@ -129,17 +129,17 @@ | `POST /versions/{versionId}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 | | `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 | | `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 | -| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按部署模板与 gameId 派生该游戏发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 | +| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按 gameId 派生平台同源发行路径 `/games/{gameId}/` 并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 | | `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;后台游戏审核页提供带原因输入与二次确认的入口;此行是完整后台路径 | 除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。 领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。 -### 发行域名、沙箱与网络能力 +### 发行路径、沙箱与网络能力 -- 发行域名必须与平台使用不同的可注册站点(不同 eTLD+1),不能仅使用 `*.genarrative.world` 的兄弟子域;域名需在部署前确定。每个游戏有独立 HTTPS origin,例如 `https://g-.<发行站点>`,不同游戏不能共用 origin;版本固定在 `/releases//index.html`。 -- 主站仅接受服务端配置允许的 HTTPS 发行 host 与发行版本路径,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:` 和 `srcdoc`。发行站点不设置平台 Cookie、不接收平台 Bearer、不挂载主站 API;请求和日志也不得携带平台认证数据。 +- 发行入口是平台同源路径 `https://<平台域名>/games//`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。 +- 主站只接受服务端派生的同源发行路径(形态固定为 `/games//`),拒绝任意 URL、重定向目标、用户输入 URL、`javascript:` 和 `srcdoc`。发行路径在边缘清空 `Cookie`,网关自身也对带 `Cookie` 的请求返回 `403`;发行响应不接收平台 Bearer,请求与日志都不得携带平台认证数据。 - iframe 首版仅使用 `sandbox="allow-scripts"`,全屏通过明确的 iframe 能力授权与用户手势开放。禁止 `allow-same-origin`、顶层导航、弹窗、表单提交、下载、模态对话框、相机、麦克风、剪贴板和地理位置;不提供持久 localStorage/IndexedDB 存档保证,不启用 Service Worker。 - 网关对所有发行 HTML 强制 CSP:默认拒绝;脚本只允许本游戏 origin 和必要内联脚本,不开放 `unsafe-eval`;样式允许本游戏 origin 与内联样式;图片/字体/音频只允许本游戏静态源及必要 `data:`/`blob:`;`connect-src` 仅为当前游戏静态 origin,`worker-src`、`frame-src`、`object-src`、`form-action` 为 `none`,`base-uri 'none'`,`frame-ancestors` 仅主站明确 origin。不能由包内 meta 放宽响应头策略。 - 首版允许加载同游戏发行包内 JSON/二进制素材,禁止外部 API、远程分析、广告、第三方 SDK 网络依赖及任意外站 fetch/WebSocket。静态网关不代理任意外部地址。为兼容 opaque sandbox 下的 ES modules,发行静态资源提供不带 credentials 的 CORS;此能力只对获准发行文件生效,不能扩到主站或私有存储。 @@ -163,16 +163,16 @@ | 幂等与恢复 | 双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 | | 审核与并发 | 待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 | | 真正可玩 | 桌面及手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出和可用全屏;不以 iframe load 代替 | -| 隔离与撤销 | 真实不同站点和每游戏 origin 下,主站 Cookie/storage/DOM 不可访问,外部网络阻断,旧 URL 在缓存窗口后不能取得新资源 | +| 隔离与撤销 | 真实生产构建下同源 iframe 内主站 Cookie/storage/DOM 不可访问、外部网络被 CSP 阻断、离开页面后游戏代码不再运行,旧 URL 在缓存窗口后不能取得新资源 | | 页面与视觉 | 当前 warm token 下的目录、详情、发布、加载/空/失败/待审态,桌面和移动视口无操作遮挡,键盘可达 | | 工程门禁 | 定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引及 diff 检查 | -所有运行时证据须注明实际环境和结论;缺少发行域名、登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。 +所有运行时证据须注明实际环境和结论;缺少登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。 ### 待评审决策 1. 是否采纳人工审核及资料随发行版本审核、更新期间旧版保持公开的首版策略;审核负责人、处理时限与申诉/解除封禁口径需确定。 2. 是否采用 `/games` 为网页根入口,以及“游戏 / 创作 / 项目 / 我的”和移动“游戏 / 我的”的导航提案。 3. 是否接受首版离线静态包、无 Wasm/外网/持久存档的范围,以及建议包额度、7 天失败包保留和 60 秒缓存撤销窗口;公开/撤销版本及审核记录保留周期待定。 -4. 不同可注册站点发行域名、DNS/TLS/CDN、存储区域及运营责任尚待选定。不同站点和每游戏 origin 是上线门禁,不能退化成主站同源目录。 +4. 是否接受平台同源路径发行(`/games//` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域与运营责任仍需确定。 5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。 diff --git a/package.json b/package.json index 0098a0e6b..87d45e193 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs", "check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs", "check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs", - "check:release-origin-config": "node scripts/check-release-origin-config.mjs", "check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs", "check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs", "check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs", diff --git a/packages/shared/src/contracts/gameDistribution.ts b/packages/shared/src/contracts/gameDistribution.ts index 22b3e4a31..2d2391b48 100644 --- a/packages/shared/src/contracts/gameDistribution.ts +++ b/packages/shared/src/contracts/gameDistribution.ts @@ -61,6 +61,10 @@ export type GameDistributionGameVisibility = export type GameDistributionVersionSummary = { id: string; version: string; + /** + * 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 + * 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。 + */ entryUrl: string; sha256: string; publishedAt: string; diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 94b12b058..55a88bf78 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -534,12 +534,6 @@ async function main() { approved.status === 200, `status=${approved.status} ${approved.text.slice(0, 250)}`, ); - check( - '审核通过后发行入口由服务端派生', - approved.data?.version?.entryUrl === - `${API}/api/game-distribution/releases/${gameId}/`, - String(approved.data?.version?.entryUrl), - ); // 8. 公开目录:封面/截图对象键生效 const catalogAfter = await api('/api/game-distribution/games'); @@ -547,6 +541,11 @@ async function main() { (game) => game.id === gameId, ); check('公开目录返回该游戏', Boolean(publishedGame)); + check( + '审核通过后发行入口由服务端派生为平台同源路径', + publishedGame?.currentVersion?.entryUrl === `/games/${gameId}/`, + String(publishedGame?.currentVersion?.entryUrl), + ); check( '公开投影带封面对象键', publishedGame?.coverObjectKey === cover.objectKey, diff --git a/scripts/check-release-origin-config.mjs b/scripts/check-release-origin-config.mjs deleted file mode 100644 index 7a3164ae6..000000000 --- a/scripts/check-release-origin-config.mjs +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env node -/** - * 游戏发行来源配置门禁。 - * - * 逐条校验 `deploy/nginx/genarrative-release-origin.conf`: - * 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀); - * 2) 只暴露发行网关,不代理平台 API / 后台 / SPA; - * 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空); - * 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。 - * 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行 - * `nginx -t`,把语法与指令上下文一起验证掉。 - */ -import { execFileSync } from 'node:child_process'; -import { - existsSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(scriptDir, '..'); -const templatePath = join( - repoRoot, - 'deploy/nginx/genarrative-release-origin.conf', -); -const gatewayPath = join( - repoRoot, - 'server-rs/crates/api-server/src/modules/game_distribution.rs', -); - -const failures = []; -const notes = []; - -function fail(message) { - failures.push(message); -} - -function normalize(source) { - return source.replace(/\s+/gu, ' '); -} - -function requireSnippet(source, snippet, message) { - if (!normalize(source).includes(normalize(snippet))) { - fail(message); - } -} - -function main() { - if (!existsSync(templatePath)) { - fail(`缺少发行来源模板:${templatePath}`); - return; - } - const template = readFileSync(templatePath, 'utf8'); - - requireSnippet( - template, - 'server_name ~^(?[a-z0-9_]+)\\.games\\.example\\.com$;', - '发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)', - ); - requireSnippet( - template, - 'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;', - '发行来源必须使用通配 TLS 证书', - ); - requireSnippet( - template, - 'if ($http_cookie) { return 403; }', - '发行来源必须拒绝携带平台 Cookie 的请求', - ); - requireSnippet( - template, - 'proxy_set_header Cookie "";', - '发行来源转发前必须清空 Cookie', - ); - requireSnippet( - template, - 'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;', - '发行来源必须按 game_id 映射到发行网关前缀', - ); - requireSnippet( - template, - 'location /.well-known/acme-challenge/', - '发行来源必须保留 ACME challenge 路径', - ); - - requireSnippet( - template, - 'location = / {', - '发行来源必须显式把子域根路径映射为该游戏的 index.html', - ); - requireSnippet( - template, - 'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;', - '子域根路径必须映射到该游戏的 index.html', - ); - const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length; - if (proxyPassCount !== 2) { - fail( - `发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount} 条`, - ); - } - const cookieStripCount = ( - template.match(/proxy_set_header Cookie "";/gu) ?? [] - ).length; - if (cookieStripCount !== 2) { - fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount} 处`); - } - const gatewayPrefixCount = ( - template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? [] - ).length; - if (gatewayPrefixCount !== 2) { - fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount} 处`); - } - for (const forbidden of [ - '/api/auth', - '/api/profile', - '/admin/api', - '/api/game-distribution/games', - '/api/game-distribution/versions', - ]) { - if (template.includes(forbidden)) { - fail(`发行来源不得代理平台命名空间:${forbidden}`); - } - } - - if (!existsSync(gatewayPath)) { - fail(`缺少发行网关源码:${gatewayPath}`); - } else { - const gateway = readFileSync(gatewayPath, 'utf8'); - for (const [snippet, message] of [ - [ - 'header::X_CONTENT_TYPE_OPTIONS', - '发行网关必须继续设置 X-Content-Type-Options', - ], - [ - 'HeaderName::from_static("cross-origin-resource-policy")', - '发行网关必须继续设置 CORP', - ], - [ - 'HeaderValue::from_static("cross-origin")', - 'CORP 必须是 cross-origin(opaque sandbox 才能加载自有脚本)', - ], - [ - 'header::ACCESS_CONTROL_ALLOW_ORIGIN', - '发行网关必须继续设置无凭据 CORS', - ], - ['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'], - ['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'], - ]) { - if (!gateway.includes(snippet)) { - fail(message); - } - } - } - - validateWithNginx(template); - - if (failures.length > 0) { - console.error('[check:release-origin-config] FAILED'); - for (const message of failures) { - console.error(`- ${message}`); - } - process.exit(1); - } - for (const note of notes) { - console.log(`[check:release-origin-config] ${note}`); - } - console.log( - '[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)', - ); -} - -function binaryExists(binary) { - try { - execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -function validateWithNginx(template) { - if (!binaryExists('nginx')) { - notes.push('未找到 nginx,跳过渲染后的 nginx -t'); - return; - } - const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-')); - try { - const certPath = join(workDir, 'wildcard.crt'); - const keyPath = join(workDir, 'wildcard.key'); - if (binaryExists('openssl')) { - execFileSync( - 'openssl', - [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-nodes', - '-days', - '1', - '-subj', - '/CN=games.example.com', - '-addext', - 'subjectAltName=DNS:*.games.example.com,DNS:games.example.com', - '-keyout', - keyPath, - '-out', - certPath, - ], - { stdio: 'ignore' }, - ); - } else { - notes.push('未找到 openssl,跳过渲染后的 nginx -t'); - return; - } - const rendered = template - .replace( - '/etc/letsencrypt/live/games.example.com/fullchain.pem', - certPath, - ) - .replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath) - .replace( - /\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu, - join(workDir, '$1'), - ) - // 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。 - .replace('listen 80;', 'listen 18080;') - .replace('listen 443 ssl http2;', 'listen 18443 ssl http2;'); - const renderedPath = join(workDir, 'release-origin.conf'); - writeFileSync(renderedPath, rendered); - const wrapperPath = join(workDir, 'nginx.conf'); - writeFileSync( - wrapperPath, - [ - `pid ${join(workDir, 'nginx.pid')};`, - `error_log ${join(workDir, 'error.log')} warn;`, - 'events { worker_connections 64; }', - 'http {', - ' access_log off;', - ' client_body_temp_path ' + join(workDir, 'client-body') + ';', - ' proxy_temp_path ' + join(workDir, 'proxy') + ';', - ' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';', - ' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';', - ' scgi_temp_path ' + join(workDir, 'scgi') + ';', - ` include ${renderedPath};`, - '}', - '', - ].join('\n'), - ); - try { - execFileSync('nginx', ['-t', '-c', wrapperPath], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - notes.push('渲染后的发行来源配置通过 nginx -t'); - } catch (error) { - const stderr = error.stderr ? String(error.stderr) : ''; - fail( - `渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`, - ); - } - } finally { - rmSync(workDir, { recursive: true, force: true }); - } -} - -main(); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f878f111b..1b71b2cda 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -94,9 +94,6 @@ pub struct AppConfig { pub client_download_channel: String, /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 pub project_snapshot_channel: String, - /// 游戏发行入口模板:审核通过时按 `{gameId}` 占位符展开成每游戏独立来源地址。 - /// 生产必须显式配置;非生产缺省回落到本地发行网关回环地址,便于免 TLS 验证游玩。 - pub game_distribution_release_entry_template: Option, pub log_filter: String, pub otel_enabled: bool, pub admin_username: Option, @@ -401,7 +398,6 @@ impl Default for AppConfig { image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), project_snapshot_channel: "dev".to_string(), - game_distribution_release_entry_template: None, log_filter: "info,tower_http=info".to_string(), otel_enabled: false, admin_username: None, @@ -730,9 +726,6 @@ impl AppConfig { if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") { config.project_snapshot_channel = channel.trim().to_string(); } - // 发行入口模板由部署侧提供;显式空值视为未配置,不能悄悄回落到本地回环。 - config.game_distribution_release_entry_template = - read_first_non_empty_env(&["GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE"]); if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"]) { diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 6bc5c39a4..ee797f8df 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -47,7 +47,6 @@ use crate::{ admin::{AuthenticatedAdmin, require_admin_auth}, api_response::json_success_body, auth::{AuthenticatedAccessToken, require_bearer_auth}, - config::AppConfig, http_error::AppError, platform_errors::{map_llm_error, map_oss_error}, request_context::RequestContext, @@ -1552,7 +1551,10 @@ async fn admin_get_version( )) } -/// 审核通过时按部署模板与 gameId 派生发行入口。 +/// 审核通过时派生的发行入口:平台同源路径 `/games/{gameId}/`。 +/// +/// 存相对路径而不是绝对 URL,部署侧就不需要提供发行域名;dev / release / 预览环境 +/// 口径一致,由客户端按当前 origin 解析成绝对地址后再交给 iframe。 async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result { let version = state .spacetime_client() @@ -1560,73 +1562,19 @@ async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result< .await .map_err(map_spacetime_error)? .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - build_release_entry_url(&state.config, &version.game_id) + build_release_entry_url(&version.game_id) } -/// 本地联调缺省模板:直接指向本进程的发行网关,免 TLS 即可验证内嵌游玩。 -fn default_local_release_entry_template(bind_port: u16) -> String { - format!("http://127.0.0.1:{bind_port}/api/game-distribution/releases/{{gameId}}/") -} - -/// 按部署模板生成该游戏的发行入口。 -/// -/// 模板必须显式包含 `{gameId}`,否则所有游戏会共用同一个来源;生产环境没有模板时 -/// 直接失败,不能悄悄回落到本地回环地址。 -fn build_release_entry_url(config: &AppConfig, game_id: &str) -> Result { - let template = match config.game_distribution_release_entry_template.as_deref() { - Some(template) => template.trim().to_string(), - None if config.is_production() => { - return Err(internal( - "发行来源未配置:请设置 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE", - )); - } - None => default_local_release_entry_template(config.bind_port), - }; - if !template.contains("{gameId}") { - return Err(internal( - "发行入口模板必须包含 {gameId} 占位符,避免多个游戏共用同一个来源", - )); - } +/// 发行入口固定走平台同源路径,游戏标识必须能安全落在路径段里。 +fn build_release_entry_url(game_id: &str) -> Result { if game_id.is_empty() || !game_id.chars().all(|character| { character.is_ascii_alphanumeric() || character == '-' || character == '_' }) { - return Err(internal("游戏标识不适用于发行子域")); + return Err(internal("游戏标识不适用于发行路径")); } - let entry_url = template.replace("{gameId}", game_id); - validate_release_entry_url(&entry_url, !config.is_production())?; - Ok(entry_url) -} - -/// 校验派生出的发行入口。 -/// -/// 生产环境只接受绝对 HTTPS 地址;非生产环境额外允许 http 回环地址,口径与前端 -/// `normalizeGameEntryUrl` 一致,便于本地把发行网关跑在 127.0.0.1 上验证内嵌游玩。 -/// 任何环境都拒绝凭据、query 和 fragment。 -fn validate_release_entry_url(value: &str, allow_loopback_http: bool) -> Result<(), AppError> { - let parsed = - url::Url::parse(value.trim()).map_err(|_| bad_request("发行入口必须是有效 URL"))?; - let host = parsed.host_str(); - let scheme_allowed = parsed.scheme() == "https" - || (allow_loopback_http - && parsed.scheme() == "http" - && matches!( - host, - Some("127.0.0.1") | Some("localhost") | Some("[::1]") | Some("::1") - )); - if !scheme_allowed - || host.is_none() - || parsed.username() != "" - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - { - return Err(bad_request( - "发行入口必须是无凭据、无查询参数的 HTTPS URL;仅非生产环境允许回环 http", - )); - } - Ok(()) + Ok(format!("/games/{game_id}/")) } async fn admin_suspend_game( @@ -3031,117 +2979,23 @@ mod tests { } #[test] - fn release_entry_url_is_derived_from_template_and_game_id() { - let config = crate::config::AppConfig { - game_distribution_release_entry_template: Some( - "https://{gameId}.games.example.test/".to_string(), - ), - ..crate::config::AppConfig::default() - }; + fn release_entry_url_is_same_origin_path_with_game_id() { assert_eq!( - build_release_entry_url(&config, "game_1").expect("派生发行入口"), - "https://game_1.games.example.test/" + build_release_entry_url("game_1").expect("派生发行入口"), + "/games/game_1/" ); } #[test] - fn release_entry_template_must_contain_game_id() { - let config = crate::config::AppConfig { - game_distribution_release_entry_template: Some( - "https://games.example.test/".to_string(), - ), - ..crate::config::AppConfig::default() - }; - assert!(build_release_entry_url(&config, "game_1").is_err()); - } - - #[test] - fn production_release_entry_requires_configured_template() { - let config = crate::config::AppConfig { - environment: "production".to_string(), - ..crate::config::AppConfig::default() - }; - assert!(build_release_entry_url(&config, "game_1").is_err()); - } - - #[test] - fn non_production_release_entry_falls_back_to_loopback_gateway() { - let config = crate::config::AppConfig { - bind_port: 12401, - ..crate::config::AppConfig::default() - }; - assert_eq!( - build_release_entry_url(&config, "game_1").expect("本地发行入口"), - "http://127.0.0.1:12401/api/game-distribution/releases/game_1/" - ); - } - - #[test] - fn release_entry_rejects_game_id_that_is_not_host_safe() { - let config = crate::config::AppConfig { - game_distribution_release_entry_template: Some( - "https://{gameId}.games.example.test/".to_string(), - ), - ..crate::config::AppConfig::default() - }; + fn release_entry_rejects_game_id_that_is_not_path_safe() { for invalid in ["", "../escape", "game/1", "game 1"] { assert!( - build_release_entry_url(&config, invalid).is_err(), + build_release_entry_url(invalid).is_err(), "未拒绝的游戏标识:{invalid}" ); } } - #[test] - fn release_entry_url_requires_credential_free_https() { - validate_release_entry_url( - "https://games.example.test/releases/game_1/index.html", - false, - ) - .expect("发行入口"); - for invalid in [ - "/releases/game_1/index.html", - "http://games.example.test/releases/game_1/index.html", - "http://127.0.0.1:10001/releases/game_1/index.html", - "https://user:pass@games.example.test/index.html", - "https://games.example.test/index.html?token=1", - "https://games.example.test/index.html#x", - ] { - assert_eq!( - validate_release_entry_url(invalid, false) - .expect_err("生产环境非法发行入口应被拒绝") - .status_code(), - StatusCode::BAD_REQUEST, - "未拒绝的发行入口:{invalid}" - ); - } - } - - #[test] - fn non_production_release_entry_allows_loopback_http_only() { - for allowed in [ - "http://127.0.0.1:10001/api/game-distribution/releases/game_1/index.html", - "http://localhost:10001/api/game-distribution/releases/game_1/index.html", - "https://games.example.test/releases/game_1/index.html", - ] { - validate_release_entry_url(allowed, true).expect("非生产环境应接受回环 http"); - } - for invalid in [ - "http://games.example.test/releases/game_1/index.html", - "http://192.168.1.10:10001/index.html", - "http://127.0.0.1:10001/index.html?token=1", - "http://user:pass@127.0.0.1:10001/index.html", - ] { - assert_eq!( - validate_release_entry_url(invalid, true) - .expect_err("非生产环境也不能放宽回环之外的地址") - .status_code(), - StatusCode::BAD_REQUEST, - "未拒绝的发行入口:{invalid}" - ); - } - } - #[test] fn release_response_allows_opaque_sandbox_asset_loads() { // 发行文档在 allow-scripts 沙箱里是 opaque origin;CORP same-origin 会让游戏 diff --git a/server-rs/crates/shared-contracts/src/game_distribution.rs b/server-rs/crates/shared-contracts/src/game_distribution.rs index 47fee1ffb..4eb118b9e 100644 --- a/server-rs/crates/shared-contracts/src/game_distribution.rs +++ b/server-rs/crates/shared-contracts/src/game_distribution.rs @@ -89,6 +89,7 @@ pub struct GameDistributionAuthor { pub struct GameDistributionVersionSummary { pub id: String, pub version: String, + /// 发行入口:平台同源路径 `/games//`,客户端按当前 origin 解析后再交给 iframe。 pub entry_url: String, pub sha256: String, pub published_at: String, diff --git a/src/components/game-distribution/gameDistributionGuards.test.ts b/src/components/game-distribution/gameDistributionGuards.test.ts new file mode 100644 index 000000000..e306937bd --- /dev/null +++ b/src/components/game-distribution/gameDistributionGuards.test.ts @@ -0,0 +1,46 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it } from 'vitest'; + +import { normalizeGameEntryUrl } from './gameDistributionGuards'; + +const GAME_ID = `game_${'a'.repeat(32)}`; +const GAME_ENTRY_PATH = `/games/${GAME_ID}/`; + +describe('normalizeGameEntryUrl', () => { + it('resolves a release gateway relative path against the current origin', () => { + expect(normalizeGameEntryUrl(GAME_ENTRY_PATH)).toBe( + new URL(GAME_ENTRY_PATH, window.location.origin).href, + ); + }); + + it('adds the trailing slash required for relative game assets', () => { + expect(normalizeGameEntryUrl(`/games/${GAME_ID}`)).toBe( + new URL(GAME_ENTRY_PATH, window.location.origin).href, + ); + }); + + it('normalizes a same-origin absolute release gateway URL', () => { + expect( + normalizeGameEntryUrl(`${window.location.origin}/games/${GAME_ID}`), + ).toBe(new URL(GAME_ENTRY_PATH, window.location.origin).href); + }); + + it.each([ + `/games/${GAME_ID}/assets/x.js`, + '/games/detail', + '/creation', + '/', + 'javascript:alert(1)', + 'https://user:pass@x/y', + 'http://evil.test/x', + ])('rejects an invalid or unsafe entry URL: %s', (entryUrl) => { + expect(normalizeGameEntryUrl(entryUrl)).toBeNull(); + }); + + it('passes through an absolute HTTPS URL on another origin', () => { + const entryUrl = 'https://play.example.test/releases/version-1/index.html'; + + expect(normalizeGameEntryUrl(entryUrl)).toBe(entryUrl); + }); +}); diff --git a/src/components/game-distribution/gameDistributionGuards.ts b/src/components/game-distribution/gameDistributionGuards.ts index bb11a1059..656b6baa3 100644 --- a/src/components/game-distribution/gameDistributionGuards.ts +++ b/src/components/game-distribution/gameDistributionGuards.ts @@ -2,6 +2,12 @@ import { useEffect, useState } from 'react'; const MAX_GAME_ID_LENGTH = 128; const MAX_ENTRY_URL_LENGTH = 4096; +const GAME_ENTRY_PATH_PATTERN = /^\/games\/(game_[0-9a-f]{32})\/?$/; + +function normalizeGameEntryPath(pathname: string) { + const match = GAME_ENTRY_PATH_PATTERN.exec(pathname); + return match ? `/games/${match[1]}/` : null; +} function containsControlCharacter(value: string) { for (const character of value) { @@ -45,13 +51,18 @@ export function normalizeGameEntryUrl(value: string | null | undefined) { return null; } + const currentOrigin = + typeof window === 'undefined' ? null : window.location.origin; + if (normalized.startsWith('/')) { + if (!currentOrigin) { + return null; + } + const normalizedPath = normalizeGameEntryPath(normalized); + return normalizedPath ? new URL(normalizedPath, currentOrigin).href : null; + } + try { - const url = new URL( - normalized, - typeof window === 'undefined' - ? 'http://localhost' - : window.location.origin, - ); + const url = new URL(normalized); const isLocalDevelopmentHttp = url.protocol === 'http:' && (url.hostname === 'localhost' || @@ -60,13 +71,16 @@ export function normalizeGameEntryUrl(value: string | null | undefined) { if ( (url.protocol !== 'https:' && !isLocalDevelopmentHttp) || url.username || - url.password || - (typeof window !== 'undefined' && - url.origin === window.location.origin && - !import.meta.env.DEV) + url.password ) { return null; } + if (currentOrigin && url.origin === currentOrigin) { + const normalizedPath = normalizeGameEntryPath(url.pathname); + return normalizedPath + ? new URL(normalizedPath, currentOrigin).href + : null; + } return url.href; } catch { return null;