import config from "../config.ts"; interface Bucket { count: number; resetAt: number; } export type RateLimitKind = | "login" | "passkey" | "git-auth" | "comment" | "reaction" | "upload" | "register" | "repo-create" | "issue-create" | "patch-create" | "label-write" | "release-write" | "file-edit"; const buckets = new Map(); function sweep() { const now = Date.now(); for (const [key, bucket] of buckets) { if (now > bucket.resetAt) buckets.delete(key); } } // Periodic sweep so memory doesn't grow unboundedly when traffic is low // and the request-driven sweep never reaches its threshold. setInterval(sweep, 60 * 1000).unref(); let sweepCounter = 0; function maybeSweep() { if (++sweepCounter < 1000) return; sweepCounter = 0; sweep(); } export function checkRateLimit( ip: string | null, kind: RateLimitKind, maxRequests: number, windowMs: number, ): boolean { if (config.RATE_LIMIT_DISABLED || !ip) return true; const key = `${ip}|${kind}`; const now = Date.now(); const bucket = buckets.get(key); if (!bucket || now > bucket.resetAt) { buckets.set(key, { count: 1, resetAt: now + windowMs }); maybeSweep(); return true; } if (bucket.count >= maxRequests) return false; bucket.count++; return true; } export function getClientIp( request: Request, server: Bun.Server | null, ): string | null { if (config.TRUSTED_PROXY) { return ( request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null ); } return server?.requestIP(request)?.address ?? null; } /** * Rate-limit a mutating handler. Returns null if the request is allowed, * or a 429 Response if it isn't. The bucket is keyed on the user id when * available (so a single attacker can't bypass by rotating source IPs) * and on the IP when not. */ export function rateLimit( request: Request, server: Bun.Server | null, userId: number | null, kind: RateLimitKind, maxRequests: number, windowMs: number, ): Response | null { const key = userId !== null ? `u${userId}` : getClientIp(request, server); if (checkRateLimit(key, kind, maxRequests, windowMs)) return null; return new Response("Too many requests. Please slow down.", { status: 429, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); }