import { NextResponse } from "next/server" // ============================================================================ // Fixed-window rate limiting for endpoints Better Auth does not cover. // // Better Auth throttles its own /api/auth/* endpoints (see lib/auth.ts). Nothing // else was limited: the public v1 API, uploads, and the unauthenticated tenant // portal maintenance submission were all unbounded. This closes that gap. // // SCOPE / LIMITATION: counters live in the process, so the limit is enforced // PER INSTANCE. On a single container (the current Dokploy deployment) that is // the real limit; if the app is ever scaled horizontally, an attacker's // effective ceiling multiplies by the replica count. That is still a bounded, // large improvement over "no limit at all", and the swap to a shared store // (Postgres or Redis) only has to replace `hit()` below. Do NOT let the absence // of a shared store be a reason to ship no limit. // ============================================================================ type Counter = { count: number; resetAt: number } const buckets = new Map() // Drop expired counters so a long-lived process doesn't accumulate a key per // distinct IP forever. Runs opportunistically on write, not on a timer. let lastSweep = 0 function sweep(now: number): void { if (now - lastSweep < 60_000) return lastSweep = now for (const [key, c] of buckets) { if (c.resetAt <= now) buckets.delete(key) } } export type RateLimitResult = { ok: boolean /** Requests remaining in the current window. */ remaining: number /** Seconds until the window resets — sent as Retry-After on a 429. */ retryAfter: number limit: number } /** * Record a hit against `key` and report whether it is within `limit` per * `windowSeconds`. Callers pass a namespaced key (e.g. `v1:`) so * different endpoints never share a bucket. */ export function hit(key: string, limit: number, windowSeconds: number): RateLimitResult { const now = Date.now() sweep(now) const existing = buckets.get(key) if (!existing || existing.resetAt <= now) { const resetAt = now + windowSeconds * 1000 buckets.set(key, { count: 1, resetAt }) return { ok: true, remaining: limit - 1, retryAfter: windowSeconds, limit } } existing.count++ const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) return { ok: existing.count <= limit, remaining: Math.max(0, limit - existing.count), retryAfter, limit, } } /** * The caller's IP, taken from the proxy headers Traefik/Dokploy set. Falls back * to a constant so a missing header degrades to one shared bucket (fail closed * on volume) rather than to no limit at all. */ export function clientIp(request: Request): string { const fwd = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() return fwd || request.headers.get("x-real-ip") || "unknown" } /** Standard 429 with rate-limit headers, or null when the request is allowed. */ export function rateLimitResponse(result: RateLimitResult): NextResponse | null { if (result.ok) return null return NextResponse.json( { error: "Too many requests. Please slow down and try again shortly." }, { status: 429, headers: { "Retry-After": String(result.retryAfter), "X-RateLimit-Limit": String(result.limit), "X-RateLimit-Remaining": "0", }, } ) } /** * Convenience wrapper: hit the bucket and return a ready 429 if over the limit. * * const limited = enforceRateLimit(`upload:${ownerId}`, 30, 60) * if (limited) return limited */ export function enforceRateLimit( key: string, limit: number, windowSeconds: number ): NextResponse | null { return rateLimitResponse(hit(key, limit, windowSeconds)) }