/** * Tiny in-process sliding-window rate limiter. * * Per Node process — fine for a single Next.js standalone server, which is * the only deployment shape we ship. If the app ever runs behind multiple * replicas, swap the Map for a shared Redis-backed bucket; the public API * here stays the same. * * No deps: a Map of `key -> sorted timestamps[]`. Each call drops * timestamps older than the window, then either records the new request * and returns ok, or returns blocked with retry-after seconds. * * Memory: O(active-keys * limit). At a 5-per-15min ceiling and even * thousands of distinct IPs, this is trivially small. */ interface Window { /** Max events allowed per `windowMs` per `key`. */ limit: number; /** Window length in ms. */ windowMs: number; } const buckets = new Map(); export function rateLimit( key: string, { limit, windowMs }: Window, ): { ok: true } | { ok: false; retryAfterSec: number } { const now = Date.now(); const cutoff = now - windowMs; const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff); if (hits.length >= limit) { const oldest = hits[0]; const retryAfterSec = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)); buckets.set(key, hits); return { ok: false, retryAfterSec }; } hits.push(now); buckets.set(key, hits); return { ok: true }; } /** * Best-effort client IP extraction. In a StartOS deployment the Next.js * server sits behind a single proxy hop, so the leftmost * `x-forwarded-for` entry is the originating client. If headers are * absent (direct access in dev), fall back to the literal "unknown" key * so the limiter still applies as a global rate cap. */ export function clientIpFromHeaders(headers: Headers): string { const xff = headers.get('x-forwarded-for'); if (xff) { const first = xff.split(',')[0]?.trim(); if (first) return first; } const real = headers.get('x-real-ip'); if (real) return real; return 'unknown'; }