← 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 };
  }
}