import { headers } from "next/headers"; /** * Tiny in-process fixed-window rate limiter for abuse-prone server actions * (register, password reset, login). It's per-node (not shared across * instances) — fine for a single-server retro hotel; swap for Redis if you * ever scale out. Keys are typically `${action}:${ip}`. * * Periodic cleanup runs every 5 minutes to keep the 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; } let lastCleanup = Date.now(); const CLEANUP_INTERVAL_MS = 300_000; // 5 min const MAX_BUCKETS = 10_000; function cleanup(): void { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; lastCleanup = now; if (buckets.size <= MAX_BUCKETS) { // Quick eviction of completely expired entries for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k); } else { // Aggressive: clear all expired, then delete oldest 20% if still too large 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++) buckets.delete(sorted[i][0]); } } } export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { const now = Date.now(); 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"; } }