chore: sync in-progress work across marketing, admin, API and tests
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8f90347659
commit
1d02598786
@@ -0,0 +1,107 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user