- 新增 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
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
/**
|
|
* 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>;
|