// Server-only, in-memory sliding-window limiter — deliberately no Redis: // this app runs as a single Coolify container, so a plain in-process Map // is sufficient and needs zero new infra. Counters reset on redeploy/ // restart (acceptable — an attacker gets a few extra free attempts right // after a deploy, not a meaningful window) and won't share state if this // ever scales to multiple instances; revisit with a shared store then. // // Complements, doesn't replace, Payload's own built-in per-account login // lockout (Customers collection, maxLoginAttempts: 5 / lockTime: 10min, // Payload defaults) — that stops brute-forcing one account, this stops an // IP hammering registration or spraying attempts across many accounts. const attempts = new Map(); // Prevents unbounded growth from IPs that hit a route once and never // return — without this, `attempts` would grow forever on a low-traffic // site that nonetheless gets scanned/crawled periodically. const MAX_TRACKED_KEYS = 10_000; export function checkRateLimit(key: string, { limit, windowMs }: { limit: number; windowMs: number }): boolean { const now = Date.now(); const windowStart = now - windowMs; const timestamps = (attempts.get(key) ?? []).filter((t) => t > windowStart); if (timestamps.length >= limit) { attempts.set(key, timestamps); return false; } timestamps.push(now); if (attempts.size >= MAX_TRACKED_KEYS && !attempts.has(key)) { attempts.clear(); } attempts.set(key, timestamps); return true; } // Caddy sits in front of this app and sets X-Forwarded-For — falls back to // a constant key (effectively a single shared bucket) if that's ever // missing, e.g. local dev, rather than disabling rate limiting outright. export function getClientIp(request: Request): string { const forwarded = request.headers.get("x-forwarded-for"); return forwarded?.split(",")[0]?.trim() || "unknown"; }