游戏发行入口改为平台同源路径
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Backend tests (push) Successful in 5m12s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m26s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Frontend tests (push) Successful in 2m20s
Project CI / Repository checks (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 1m21s
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Backend tests (push) Successful in 5m12s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m26s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Frontend tests (push) Successful in 2m20s
Project CI / Repository checks (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 1m21s
- 审核通过时由 api-server 按 gameId 派生 /games/{gameId}/ 相对路径写入公开投影,删除 AppConfig 的发行入口模板字段与读取逻辑
- 删除 deploy/env 两份示例中的 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE
- 三份 nginx 模板内联同源发行入口 location,把 /games/<gameId>/ 与子资源转发到发行网关并在边缘清空 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 与共享决策记录
This commit is contained in:
@@ -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 自己派生,
|
||||
# 不需要部署侧配置发行域名或通配证书。
|
||||
|
||||
@@ -136,12 +136,28 @@ http {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
|
||||
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
|
||||
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
|
||||
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
|
||||
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
|
||||
|
||||
Vendored
+3
-4
@@ -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_PORT>/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
|
||||
|
||||
@@ -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://<gameId>.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。
|
||||
- 该来源只把子域根路径映射到 `…/releases/<gameId>/index.html`、其余路径映射到 `…/releases/<gameId>/<原路径>`;平台 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/<gameId>/`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games/<gameId>/` 与 `/games/<gameId>/<asset>` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。
|
||||
- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`。不加引号时 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/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
@@ -179,6 +179,22 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
|
||||
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
|
||||
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
|
||||
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
|
||||
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) {
|
||||
|
||||
@@ -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://<gameId>.games.example.com/`;
|
||||
# - 该来源只把请求映射到发行网关
|
||||
# `/api/game-distribution/releases/<gameId>/…`,平台 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 ~^(?<game_id>[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 ~^(?<game_id>[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://<gameId>.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;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,22 @@ server {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 平台同源路径发行入口:/games/<gameId>/ 与 /games/<gameId>/<asset> 映射到
|
||||
# api-server 发行网关。游戏文档跑在 iframe sandbox="allow-scripts" 的不透明来源里,
|
||||
# 离开页面即随 iframe 卸载,因此不再要求独立发行域名与通配证书。
|
||||
location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$" {
|
||||
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) {
|
||||
|
||||
@@ -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/<gameId>/` 与 `/games/<gameId>/<asset>` 转发到发行网关,转发前清空 `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。
|
||||
|
||||
@@ -647,24 +647,22 @@ Nginx 负责站点和反向代理
|
||||
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
```
|
||||
|
||||
### 游戏发行来源(发行域名、每游戏 origin 与缓存窗口)
|
||||
### 游戏发行来源(平台同源路径与缓存窗口)
|
||||
|
||||
已公开游戏运行在**独立来源**上,与主站来源隔离;这是发行网关(`api-server`)之外唯一需要的边缘配置。
|
||||
已公开游戏通过**平台同源路径** `/games/<gameId>/` 提供,边缘只做前缀映射,不再需要独立发行域名、通配 DNS 或通配 TLS。
|
||||
|
||||
- 配置工件:`deploy/nginx/genarrative-release-origin.conf`。上线前替换域名、通配证书路径与 upstream 端口,并把文件安装到 Nginx 站点目录。
|
||||
- 前置资源:`*.games.<域名>` 通配 DNS 指向同一入口,以及覆盖该通配名的 TLS 证书(certbot DNS-01 或等价流程)。
|
||||
- 路由约定:`https://<gameId>.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases/<gameId>/…`;平台 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端口>/api/game-distribution/releases/{gameId}/`)用于联调。
|
||||
- 门禁与本地联调:
|
||||
- 路由约定:`https://<平台域名>/games/<gameId>/` 是该游戏的入口,`/games/<gameId>/<asset>` 映射到发行网关 `/api/game-distribution/releases/<gameId>/<asset>`。同源发行入口 location 写在 `deploy/nginx/genarrative.conf`(生产 HTTPS)、`deploy/nginx/genarrative-dev-http.conf`(开发 HTTP)与 `deploy/container/nginx.conf`(容器)里。正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`,否则 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: <gameId>.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/<gameId>/` 验证:入口文档 200 `text/html`、`/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。
|
||||
|
||||
#### 游戏分发可观测事件
|
||||
|
||||
|
||||
@@ -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端口>/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-<gameId>.<发行站点>`,不同游戏不能共用 origin;版本固定在 `/releases/<versionId>/index.html`。
|
||||
- 主站仅接受服务端配置允许的 HTTPS 发行 host 与发行版本路径,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:` 和 `srcdoc`。发行站点不设置平台 Cookie、不接收平台 Bearer、不挂载主站 API;请求和日志也不得携带平台认证数据。
|
||||
- 发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。
|
||||
- 主站只接受服务端派生的同源发行路径(形态固定为 `/games/<gameId>/`),拒绝任意 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/<gameId>/` + `sandbox="allow-scripts"` 的不透明来源隔离)替代独立发行域名:选择该方案后独立域名、通配 DNS/TLS 与 CDN 不再是上线门禁,存储区域与运营责任仍需确定。
|
||||
5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -61,6 +61,10 @@ export type GameDistributionGameVisibility =
|
||||
export type GameDistributionVersionSummary = {
|
||||
id: string;
|
||||
version: string;
|
||||
/**
|
||||
* 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||||
* 兼容历史数据的绝对 URL(非当前源的 https 地址),新写入只用相对路径。
|
||||
*/
|
||||
entryUrl: string;
|
||||
sha256: string;
|
||||
publishedAt: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ~^(?<game_id>[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();
|
||||
@@ -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<String>,
|
||||
pub log_filter: String,
|
||||
pub otel_enabled: bool,
|
||||
pub admin_username: Option<String>,
|
||||
@@ -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"])
|
||||
{
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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 会让游戏
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct GameDistributionAuthor {
|
||||
pub struct GameDistributionVersionSummary {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||||
pub entry_url: String,
|
||||
pub sha256: String,
|
||||
pub published_at: String,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user