diff --git a/src/config/limits.ts b/src/config/limits.ts index c8a8aa2..ae26aa1 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -32,18 +32,12 @@ // Login lock duration in minutes. // 登录锁定时长(分钟)。 loginLockoutMinutes: 2, - // Write API request budget per minute. - // 写操作 API 每分钟请求配额。 - apiWriteRequestsPerMinute: 120, - // /api/sync read request budget per minute. - // /api/sync 读请求每分钟配额。 - syncReadRequestsPerMinute: 1000, - // /api/devices/knowndevice probe budget per IP per minute. - // /api/devices/knowndevice 每 IP 每分钟探测配额。 - knownDeviceRequestsPerMinute: 10, - // Public Send access budget per IP per minute. - // 公共 Send 访问接口每 IP 每分钟配额。 - publicSendRequestsPerMinute: 60, + // Authenticated API request budget per user per minute (all reads & writes combined). + // 认证 API 每用户每分钟请求配额(读写合计)。 + apiRequestsPerMinute: 200, + // Public (unauthenticated) request budget per IP per minute. + // 公开(未认证)接口每 IP 每分钟请求配额。 + publicRequestsPerMinute: 60, // Fixed window size for API rate limiting in seconds. // API 限流固定窗口大小(秒)。 apiWindowSeconds: 60, @@ -53,15 +47,9 @@ // Minimum interval between login-attempt cleanup runs. // 登录尝试表清理的最小间隔。 loginIpCleanupIntervalMs: 10 * 60 * 1000, - // Minimum interval between API-window cleanup runs. - // API 窗口计数清理的最小间隔。 - apiWindowCleanupIntervalMs: 5 * 60 * 1000, // Retention window for login IP records. // 登录 IP 记录保留时长。 loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000, - // Number of historical API windows to keep. - // 保留的历史 API 窗口数量。 - apiWindowRetentionWindows: 120, }, cleanup: { // Minimum interval between refresh-token cleanup runs. diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index a2ae71a..13d6ce9 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -267,10 +267,10 @@ export async function handleToken(request: Request, env: Env): Promise return jsonResponse(response); } else if (grantType === 'send_access') { - const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`); + const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (!sendAccessLimit.allowed) { return identityErrorResponse( - `Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`, + `Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`, 'TooManyRequests', 429 ); diff --git a/src/router.ts b/src/router.ts index fcf385f..8a3241c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -215,13 +215,13 @@ export async function handleRequest(request: Request, env: Env): Promise { + async function enforcePublicRateLimit(): Promise { const rateLimit = new RateLimitService(env.DB); - const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`); + const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute); if (check.allowed) return null; return new Response(JSON.stringify({ error: 'Too many requests', - error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`, + error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`, }), { status: 429, headers: { @@ -289,7 +289,7 @@ export async function handleRequest(request: Request, env: Env): Promise { - if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) { - return; - } - - const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS); - await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run(); - RateLimitService.lastApiWindowCleanupAt = Date.now(); - } - private async ensureLoginIpTable(): Promise { if (RateLimitService.loginIpTableReady) return; @@ -162,8 +137,9 @@ export class RateLimitService { await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run(); } - // Atomically consume one budget unit for the current fixed window. - // Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. + // Cache API-backed fixed-window rate limiter. + // Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL. + // Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour). private async consumeFixedWindowBudget( identifier: string, maxRequests: number, @@ -172,77 +148,41 @@ export class RateLimitService { const nowSec = Math.floor(Date.now() / 1000); const windowStart = nowSec - (nowSec % windowSeconds); const windowEnd = windowStart + windowSeconds; - await this.maybeCleanupApiWindows(windowStart, windowSeconds); + const ttl = Math.max(1, windowEnd - nowSec); - const writeResult = await this.db - .prepare( - 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + - 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' + - 'WHERE api_rate_limits.count < ?' - ) - .bind(identifier, windowStart, maxRequests) - .run(); + const cache = await caches.open('rate-limit'); + const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`); - // No changed row means conflict happened and WHERE prevented increment: - // current count is already at/above configured limit. - if ((writeResult.meta.changes ?? 0) === 0) { - return { - allowed: false, - remaining: 0, - retryAfterSeconds: windowEnd - nowSec, - }; + const cached = await cache.match(cacheKey); + let count = 0; + if (cached) { + count = parseInt(await cached.text(), 10) || 0; } - const row = await this.db - .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') - .bind(identifier, windowStart) - .first<{ count: number }>(); - - if (!row) { - return { - allowed: true, - remaining: 0, - }; + if (count >= maxRequests) { + return { allowed: false, remaining: 0, retryAfterSeconds: ttl }; } - const remaining = Math.max(0, maxRequests - row.count); - return { allowed: true, remaining }; + count++; + await cache.put( + cacheKey, + new Response(String(count), { + headers: { 'Cache-Control': `public, max-age=${ttl}` }, + }) + ); + + return { allowed: true, remaining: Math.max(0, maxRequests - count) }; } - // Write budget for POST/PUT/DELETE/PATCH requests. - async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { - return this.consumeFixedWindowBudget( - identifier, - CONFIG.API_WRITE_REQUESTS_PER_MINUTE, - CONFIG.API_WINDOW_SECONDS - ); - } - - // Read budget for GET /api/sync. - async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { - return this.consumeFixedWindowBudget( - identifier, - CONFIG.SYNC_READ_REQUESTS_PER_MINUTE, - CONFIG.API_WINDOW_SECONDS - ); - } - - // Probe budget for GET /api/devices/knowndevice. - async consumeKnownDeviceProbeBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { - return this.consumeFixedWindowBudget( - identifier, - CONFIG.KNOWN_DEVICE_REQUESTS_PER_MINUTE, - CONFIG.API_WINDOW_SECONDS - ); - } - - // Budget for unauthenticated public Send access endpoints. - async consumePublicSendAccessBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { - return this.consumeFixedWindowBudget( - identifier, - CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE, - CONFIG.API_WINDOW_SECONDS - ); + // General-purpose fixed-window budget. + // Callers supply an identifier (must be unique per rate-limit category) and the + // per-window maximum. This single method replaces all previous specialised + // budget helpers (write / sync / knownDevice / publicSend). + async consumeBudget( + identifier: string, + maxRequests: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS); } }