Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
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<string, Counter>()
|
|
|
|
// 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:<userId>`) 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))
|
|
}
|