forked from EduCraft/curriculum-project-hub
6990082247
三处引用旧工程名/旧资源路径的地方一并更新,它们必须同时改 —— 少改一处 就是静默故障,而不是构建期报错: 1. 部署脚本(deploy_platform.sh / deploy_fleet_release.sh):npm ci 的 prefix、rsync 排除项、构建产物存在性检查从 database-admin 换成 filelib-web。最后一项是真门禁:static.ts 缺产物时只 warn 不注册路由, 漏改会让 /app 与 /database 静默 404 —— 恰是 database-admin 长期处于 禁用状态的原因。 2. silo 限流豁免:资源路径随 appDir 改名而变(/database/_app/* 已不存在, 现为 /_filelib/*);/app/* 此前不在豁免列表,它现在也是 SPA 外壳, 客户端路由无法预先枚举。 注:/database/* 是整体豁免,filelib 的 JSON API 也绕过限流预算。这是 迁移前就有的行为,原样保留,但覆盖面因多了 /app/* 而变宽。 3. 回归测试:把 registerStaticSpa 与 registerDatabaseSpa 挂到同一个 Fastify 实例,断言 ready() 不因重复路由抛错 —— appDir 若用回默认的 _app,这里会红(ADR-0029 的承重约束)。另断言 /app 与 /database/dashboard/users 返回同一份字节(SPA 回退不读请求)、body 含 /_filelib/。构建产物缺失时不 skip 而是直接失败:那说明该先跑 filelib-web 的 build,不是测试不适用。
59 lines
2.4 KiB
TypeScript
59 lines
2.4 KiB
TypeScript
/**
|
|
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
|
*
|
|
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
|
* Static SPA assets and the admin HTML shells are exempt so a single page load
|
|
* (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
|
*/
|
|
|
|
/** Paths that must not consume the silo HTTP request-rate budget. */
|
|
export function isSiloHttpRateLimitExempt(url: string): boolean {
|
|
const path = (url.split("?", 1)[0] ?? url) || "/";
|
|
|
|
if (path === "/api/healthz") return true;
|
|
|
|
// admin-web SvelteKit build output at the root, and top-level static files
|
|
// (see admin/static.ts).
|
|
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
|
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
|
|
|
// filelib-web SvelteKit build output. appDir 改名为 `_filelib` 以避开根 /_app/*
|
|
// (见 database/static.ts);同一份产物服务 /app 与 /database 两个前缀。
|
|
if (path === "/_filelib" || path.startsWith("/_filelib/")) return true;
|
|
|
|
// SPA index shells for client-side routes (not APIs). /admin/*、/database/* 与
|
|
// /app/* 整体豁免 —— 客户端路由无法预先枚举;这也覆盖了每次加载一次的
|
|
// /database/config bootstrap。
|
|
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
|
if (path === "/database" || path.startsWith("/database/")) return true;
|
|
if (path === "/app" || path.startsWith("/app/")) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
export class SiloFixedWindowRateLimiter {
|
|
private windowStartedAt: number;
|
|
private used = 0;
|
|
|
|
constructor(readonly limit: number, readonly windowMs: number, now = Date.now()) {
|
|
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("rate limit must be a positive integer");
|
|
if (!Number.isSafeInteger(windowMs) || windowMs <= 0) throw new Error("rate window must be a positive integer");
|
|
this.windowStartedAt = now;
|
|
}
|
|
|
|
consume(now = Date.now()): { readonly allowed: true } | { readonly allowed: false; readonly retryAfterSeconds: number } {
|
|
if (now >= this.windowStartedAt + this.windowMs) {
|
|
this.windowStartedAt = now;
|
|
this.used = 0;
|
|
}
|
|
if (this.used >= this.limit) {
|
|
return {
|
|
allowed: false,
|
|
retryAfterSeconds: Math.max(1, Math.ceil((this.windowStartedAt + this.windowMs - now) / 1000)),
|
|
};
|
|
}
|
|
this.used += 1;
|
|
return { allowed: true };
|
|
}
|
|
}
|