实现 Pingora 发行网关路由并关闭两处路由漂移待办
Project CI / AI game creator shell Rust crates (push) Failing after 1m17s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Failing after 1m17s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 新增 RouteDecision::ReleaseGateway:/games/game_<32 位小写十六进制 id>/… 重写上游路径到 /api/game-distribution/releases/<gameId><asset>、清空 Cookie、不进 SPA fallback、不套接流保护分组、不受维护闸拦截,与 nginx 同名 location 同口径(只认小写 32 位十六进制) - 路由矩阵新增 games_release_gateway 用例(含 upstreamPath 期望值)与对应断言;parity 门禁支持 release_gateway 并把 games_spa_fallback、games_release_gateway 列入必查清单 - check:pingora-gateway-smoke 新增三条运行时断言:重写到发行网关且上游拿不到 Cookie、/games/detail 仍走 SPA、/games/game/index.html 仍是真实 404 - 文档:Pingora 试点文档补「平台同源发行入口」语义;pitfalls 记录 SPA allowlist 三处真相源与「发行入口不是 SPA」;按 docs/project-memory/README.md 删除两份已关闭待办,关闭记录写入开放事项索引第五节 - 验证:网关 39 passed、check:pingora-route-parity OK(23 路由)、check:nginx-spa-routes OK(12 路由 / 3 模板)、check:pingora-gateway-smoke 通过、cargo fmt --all --check、check:production-ops、encoding / doc-index / diff 检查全绿;变异验证:矩阵 upstreamPath 写错或拿掉 /games/detail 都会立刻变红
This commit is contained in:
@@ -849,6 +849,12 @@ enum RouteDecision {
|
||||
target: ProxyTarget,
|
||||
body_limit: Option<u64>,
|
||||
},
|
||||
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>(/<asset>)?`。
|
||||
/// 与 Nginx 的同名 location 同口径:走 api 上游,但在代理阶段把路径重写成
|
||||
/// `/api/game-distribution/releases/<gameId><asset 路径>` 并清空 Cookie。
|
||||
ReleaseGateway {
|
||||
upstream_path: String,
|
||||
},
|
||||
Local(LocalResponse),
|
||||
}
|
||||
|
||||
@@ -879,6 +885,8 @@ impl RouteDecision {
|
||||
fn proxy_target(&self) -> Option<ProxyTarget> {
|
||||
match self {
|
||||
RouteDecision::Proxy { target, .. } => Some(*target),
|
||||
// 发行入口同样代理到 api 上游,只有路径与请求头在代理阶段被重写。
|
||||
RouteDecision::ReleaseGateway { .. } => Some(ProxyTarget::Api),
|
||||
RouteDecision::Local(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -886,6 +894,8 @@ impl RouteDecision {
|
||||
fn body_limit(&self) -> Option<u64> {
|
||||
match self {
|
||||
RouteDecision::Proxy { body_limit, .. } => *body_limit,
|
||||
// 发行入口只服务静态资源读取,Nginx 侧也没有请求体上限指令。
|
||||
RouteDecision::ReleaseGateway { .. } => None,
|
||||
RouteDecision::Local(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -1182,6 +1192,7 @@ impl ProxyHttp for GenarrativeGateway {
|
||||
|
||||
match &ctx.route {
|
||||
RouteDecision::Proxy { .. } => Ok(false),
|
||||
RouteDecision::ReleaseGateway { .. } => Ok(false),
|
||||
RouteDecision::Local(LocalResponse::RedirectPermanent { location }) => {
|
||||
respond_redirect(session, location).await?;
|
||||
Ok(true)
|
||||
@@ -1296,6 +1307,14 @@ impl ProxyHttp for GenarrativeGateway {
|
||||
upstream_request.insert_header("X-Forwarded-For", forwarded_for.as_str())?;
|
||||
}
|
||||
|
||||
// 中文注释:平台同源发行入口按 Nginx 的 proxy_pass 口径重写路径——换成
|
||||
// /api/game-distribution/releases/<gameId><asset 路径>,原来的 query 不再拼接;
|
||||
// 同时清空 Cookie,发行内容不读账号凭证。
|
||||
if let RouteDecision::ReleaseGateway { upstream_path } = &ctx.route {
|
||||
upstream_request.set_raw_path(upstream_path.as_bytes())?;
|
||||
upstream_request.remove_header("cookie");
|
||||
}
|
||||
|
||||
// 中文注释:SpacetimeDB 订阅走 WebSocket Upgrade;普通 API 连接头保持干净,贴近当前 Nginx 模板。
|
||||
if ctx.route.proxy_target() == Some(ProxyTarget::Spacetime) && is_upgrade_request(session) {
|
||||
upstream_request.insert_header("Connection", "Upgrade")?;
|
||||
@@ -1623,6 +1642,31 @@ fn is_generic_api_proxy_path(path: &str) -> bool {
|
||||
path == "/api" || path.starts_with("/api/")
|
||||
}
|
||||
|
||||
/// 平台同源发行入口:`/games/game_<32 位小写十六进制 id>` 可选跟一段 `/…` 资源路径。
|
||||
///
|
||||
/// 与 Nginx 的 `location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"` 同口径:
|
||||
/// 只认小写十六进制、固定 32 位,不做大小写放宽;命中后上游路径是
|
||||
/// `/api/game-distribution/releases/<gameId><asset 路径>`,其余返回 `None` 交给后面的 SPA / 静态分支。
|
||||
fn release_gateway_upstream_path(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("/games/")?;
|
||||
let (game_id, asset_path) = match rest.find('/') {
|
||||
Some(index) => (&rest[..index], &rest[index..]),
|
||||
None => (rest, ""),
|
||||
};
|
||||
let hex = game_id.strip_prefix("game_")?;
|
||||
if hex.len() != 32
|
||||
|| !hex
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"/api/game-distribution/releases/{game_id}{asset_path}"
|
||||
))
|
||||
}
|
||||
|
||||
fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<ProtectionClass> {
|
||||
match route {
|
||||
RouteDecision::Proxy {
|
||||
@@ -1641,6 +1685,8 @@ fn protection_class_for_route(route: &RouteDecision, path: &str) -> Option<Prote
|
||||
target: ProxyTarget::Gitea,
|
||||
..
|
||||
} => None,
|
||||
// 发行入口在 Nginx 侧没有任何 limit_conn / limit_req 指令,保持同口径。
|
||||
RouteDecision::ReleaseGateway { .. } => None,
|
||||
RouteDecision::Local(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -1868,6 +1914,10 @@ fn classify_path(path: &str) -> RouteDecision {
|
||||
return RouteDecision::Local(LocalResponse::NotFound);
|
||||
}
|
||||
|
||||
if let Some(upstream_path) = release_gateway_upstream_path(path) {
|
||||
return RouteDecision::ReleaseGateway { upstream_path };
|
||||
}
|
||||
|
||||
if path.starts_with("/admin/assets/") || path.starts_with("/assets/") {
|
||||
return RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
@@ -3065,6 +3115,7 @@ mod tests {
|
||||
mode: Option<String>,
|
||||
location: Option<String>,
|
||||
protection_class: Option<String>,
|
||||
upstream_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -3108,6 +3159,15 @@ mod tests {
|
||||
case.sample_path
|
||||
);
|
||||
}
|
||||
("release_gateway", RouteDecision::ReleaseGateway { upstream_path }) => {
|
||||
assert_eq!(
|
||||
Some(upstream_path.as_str()),
|
||||
case.expect.upstream_path.as_deref(),
|
||||
"route parity release gateway upstream path mismatch: {} {}",
|
||||
case.id,
|
||||
case.sample_path
|
||||
);
|
||||
}
|
||||
(
|
||||
"redirect_permanent",
|
||||
RouteDecision::Local(LocalResponse::RedirectPermanent { location }),
|
||||
@@ -3872,6 +3932,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_platform_same_origin_release_paths() {
|
||||
let game_id = "game_0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(
|
||||
release_gateway_upstream_path(&format!("/games/{game_id}/index.html")),
|
||||
Some(format!(
|
||||
"/api/game-distribution/releases/{game_id}/index.html"
|
||||
))
|
||||
);
|
||||
// 不带资源路径(尾斜杠缺失)时与 Nginx 一样仍然命中,只是上游没有 asset 段。
|
||||
assert_eq!(
|
||||
release_gateway_upstream_path(&format!("/games/{game_id}")),
|
||||
Some(format!("/api/game-distribution/releases/{game_id}"))
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_path(&format!("/games/{game_id}/assets/app.js")),
|
||||
RouteDecision::ReleaseGateway { .. }
|
||||
));
|
||||
|
||||
// 大小写、位数、字符集、以及「像但不是」的路径都不许被吞进发行网关。
|
||||
for path in [
|
||||
"/games/detail",
|
||||
"/games/mine",
|
||||
"/games/game_0123456789ABCDEF0123456789ABCDEF/index.html",
|
||||
"/games/game_0123456789abcdef0123456789abcde/index.html",
|
||||
"/games/game_0123456789abcdef0123456789abcdef0/index.html",
|
||||
"/games/game_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz/index.html",
|
||||
"/games/game/index.html",
|
||||
] {
|
||||
assert_eq!(release_gateway_upstream_path(path), None, "path: {path}");
|
||||
}
|
||||
// 这些路径仍然按 SPA / 精确静态分类,不受发行入口影响。
|
||||
assert_eq!(
|
||||
classify_path("/games/detail"),
|
||||
RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::SpaFallback,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
classify_path("/games/game/index.html"),
|
||||
RouteDecision::Local(LocalResponse::Static {
|
||||
root: StaticRoot::Web,
|
||||
mode: StaticMode::Exact,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_rejects_when_concurrency_is_exhausted() {
|
||||
let config = ProtectionConfig {
|
||||
|
||||
Reference in New Issue
Block a user