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:
@@ -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>;
|
||||
Reference in New Issue
Block a user