← back to Stayclaim
src/lib/rate-limit.ts
62 lines
import { headers } from 'next/headers';
import { pool } from '@/lib/db';
/**
* Postgres-backed token bucket. Fail-OPEN by design: if PG is unavailable,
* the request is allowed through rather than 503 the public archive.
*
* Disabled unless RATE_LIMIT_ENABLED=1 — lets the code ship before the
* migration runs on Kamatera (otherwise every request would hit a missing
* SQL function and flood the error log).
*
* IP source: x-forwarded-for (leftmost = client) → x-real-ip → 127.0.0.1.
* nginx is the only proxy and it strips client-supplied XFF before forwarding.
*/
const ENABLED = process.env.RATE_LIMIT_ENABLED === '1';
export type RateLimitScope = 'search' | 'submit' | 'promote' | 'la-records' | 'stars';
export interface RateLimitOpts {
limit: number;
windowSeconds: number;
}
export interface RateLimitResult {
ok: boolean;
retryAfter?: number;
}
const DEFAULTS: Record<RateLimitScope, RateLimitOpts> = {
search: { limit: 60, windowSeconds: 60 },
submit: { limit: 10, windowSeconds: 600 },
promote: { limit: 5, windowSeconds: 600 },
'la-records': { limit: 30, windowSeconds: 60 },
stars: { limit: 60, windowSeconds: 60 },
};
export async function rateLimit(scope: RateLimitScope, opts?: Partial<RateLimitOpts>): Promise<RateLimitResult> {
if (!ENABLED) return { ok: true };
const cfg = { ...DEFAULTS[scope], ...opts };
const h = await headers();
const xff = h.get('x-forwarded-for') || '';
const ip = xff.split(',')[0]?.trim() || h.get('x-real-ip')?.trim() || '127.0.0.1';
if (ip.length > 64) return { ok: true };
try {
const r = await pool.query<{ count: number }>(
`SELECT rate_limit_hit($1::inet, $2, $3)::int AS count`,
[ip, scope, cfg.windowSeconds],
);
const count = r.rows[0]?.count ?? 0;
if (count > cfg.limit) {
return { ok: false, retryAfter: cfg.windowSeconds };
}
return { ok: true };
} catch (err) {
console.error('[rate-limit] db error, failing open', { scope, err });
return { ok: true };
}
}