fix: 修复在线更新暂存链路并增加全局 API 限流备底

- 新增 server/rate-limit.ts:进程内固定窗口限流器,无数据库写入
- server/app.ts 注册全局 preHandler,仅作用于 /api/*,超限返回 429 与 Retry-After
- 提取 isApiPath 统一 onSend、preHandler 与 404 的路径判断
- 更新任务冲突判定改用 ACTIVE_UPDATE_CONFLICT_SQL,staged/download 产物不再阻塞新任务
- cancelUpdateJob 调用补上 await,避免结果恒为 pending Promise
- server/cli/update.ts 增加特权工作区所有权校验与暂存路径重建逻辑
- 新增 tests/rate-limit.test.ts 与 tests/update-apply-staging.test.ts
This commit is contained in:
Qiufeng
2026-09-17 13:12:20 +08:00
parent 5afcd98ebd
commit ae8966baf6
12 changed files with 1145 additions and 71 deletions
+80
View File
@@ -0,0 +1,80 @@
/**
* In-memory, per-key request limiter used as a coarse anti-flood backstop for
* the whole HTTP API.
*
* The semantics are a fixed window per key: the first request of a window
* starts the clock, every later request in the same window increments the
* counter, and an expired window is reset on the next request. This mirrors
* the `login_attempts` window logic already used for login lockouts
* (`server/app.ts`), but it never touches the database: a rate limit decision
* must stay cheap enough to run on every request.
*
* Precise controls (per-IP login lockout, dangerous-operation re-auth) remain
* in place on top of this limiter; it only stops a client from issuing an
* abusive number of requests across all endpoints.
*/
export type RateLimiterOptions = {
/** Maximum number of requests allowed per key inside one window. */
limit: number;
/** Window length in milliseconds. */
windowMs: number;
/** Injectable clock so tests can advance time without waiting. */
now?: () => number;
};
export type RateLimitDecision = {
allowed: boolean;
/** Seconds the caller should wait before retrying; 0 when allowed. */
retryAfterSeconds: number;
};
type Bucket = {
count: number;
windowStart: number;
};
/** Run a full sweep every N checks instead of on every call. */
const SWEEP_INTERVAL_CHECKS = 1000;
export function createRateLimiter(options: RateLimiterOptions) {
const { limit, windowMs } = options;
if (!Number.isInteger(limit) || limit < 1) throw new Error("rate limit 必须是大于等于 1 的整数");
if (!Number.isInteger(windowMs) || windowMs < 1) throw new Error("rate limit 窗口必须是大于等于 1 的整数毫秒数");
const now = options.now ?? Date.now;
const buckets = new Map<string, Bucket>();
let checksSinceSweep = 0;
return {
check(key: string): RateLimitDecision {
const current = now();
let bucket = buckets.get(key);
// A key that is unknown or whose window has already elapsed starts a
// fresh window. This also recycles the single key being hit, so an
// idle client never leaves a stale counter behind.
if (!bucket || current - bucket.windowStart >= windowMs) {
bucket = { count: 0, windowStart: current };
buckets.set(key, bucket);
}
// Bounds long-running memory growth: keys that stopped sending traffic
// are dropped by an amortized periodic sweep rather than on every call.
if (++checksSinceSweep >= SWEEP_INTERVAL_CHECKS) {
checksSinceSweep = 0;
for (const [candidateKey, candidate] of buckets) {
if (current - candidate.windowStart >= windowMs) buckets.delete(candidateKey);
}
}
if (bucket.count >= limit) {
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((bucket.windowStart + windowMs - current) / 1000)) };
}
bucket.count += 1;
return { allowed: true, retryAfterSeconds: 0 };
},
/** Number of tracked keys; used to observe lazy cleanup. */
size(): number {
return buckets.size;
},
};
}
export type RateLimiter = ReturnType<typeof createRateLimiter>;