import { headers } from "next/headers"; import { redis } from "@/lib/redis"; /** * Fixed-window rate limiter with optional Redis backend. Falls back to in-process * Map when Redis is unavailable or unconfigured — fine for single-server deployments. * * Periodic cleanup runs every 5 minutes to keep the in-process map bounded. */ type Bucket = { count: number; resetAt: number }; const buckets = new Map(); export interface RateLimitResult { ok: boolean; /** Seconds until the window resets (0 when allowed). */ retryAfter: number; } const CLEANUP_INTERVAL_MS = 300_000; const MAX_BUCKETS = 10_000; let lastCleanup = Date.now(); function cleanup(): void { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; lastCleanup = now; if (buckets.size <= MAX_BUCKETS) { for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); } else { for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); if (buckets.size > MAX_BUCKETS) { const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt); const toRemove = Math.floor(sorted.length * 0.2); for (let i = 0; i < toRemove; i++) // eslint-disable-next-line security/detect-object-injection -- numeric array index buckets.delete(sorted[i][0]); } } } export async function rateLimit( key: string, limit: number, windowMs: number, ): Promise { const now = Date.now(); if (redis) { try { const windowKey = `ratelimit:${key}`; const current = await redis.incr(windowKey); if (current === 1) await redis.pexpire(windowKey, windowMs); const ttl = current === 1 ? windowMs : Math.max(0, await redis.pttl(windowKey)); if (current > limit) { return { ok: false, retryAfter: Math.ceil(ttl / 1000) }; } return { ok: true, retryAfter: 0 }; } catch { // Redis unavailable — fall through to in-memory } } cleanup(); const bucket = buckets.get(key); if (!bucket || now >= bucket.resetAt) { buckets.set(key, { count: 1, resetAt: now + windowMs }); return { ok: true, retryAfter: 0 }; } if (bucket.count >= limit) { return { ok: false, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) }; } bucket.count += 1; return { ok: true, retryAfter: 0 }; } /** Best-effort client IP from the proxy headers our edge proxy forwards. */ export async function clientIp(): Promise { try { const h = await headers(); return ( h.get("x-real-client-ip") ?? h.get("cf-connecting-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0" ); } catch { return "0.0.0.0"; } }