import Redis from 'ioredis'; import { RateLimiterRedis } from 'rate-limiter-flexible'; const LIMITER_CONFIG = { auth: { limit: 5, durationSec: 15 * 60, prefix: 'deckhearth:auth' }, search: { limit: 60, durationSec: 60, prefix: 'deckhearth:search' }, upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' }, generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' }, import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' }, scan: { limit: 5, durationSec: 60, prefix: 'deckhearth:scan' }, }; let cached = null; function init() { const url = process.env.REDIS_URL; if (url) { const storeClient = new Redis(url, { maxRetriesPerRequest: 1, enableOfflineQueue: false, }); const instances = new Map(); for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) { instances.set( name, new RateLimiterRedis({ storeClient, keyPrefix: cfg.prefix, points: cfg.limit, duration: cfg.durationSec, }) ); } return { mode: 'live', instances }; } if (process.env.NODE_ENV === 'production') { throw new Error( '[rate-limit] REDIS_URL is not configured. Set REDIS_URL to the homelab Redis URL (CT 102) before serving traffic.' ); } console.warn('[rate-limit] REDIS_URL not set — rate limiting disabled (dev/test only)'); return { mode: 'noop' }; } function extractIpIdentifier(req) { const xff = req.headers?.['x-forwarded-for']; const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim(); return firstHop || req.socket?.remoteAddress || 'anonymous'; } function extractUserIdentifier(userId) { if ( userId === null || userId === undefined || userId === '' || (typeof userId === 'number' && Number.isNaN(userId)) ) { throw new Error( '[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.' ); } return `user:${userId}`; } async function check(className, identifier) { if (!cached) { cached = init(); } if (cached.mode === 'noop') { return { allowed: true, remaining: Infinity, reset: 0 }; } const limiter = cached.instances.get(className); if (!limiter) { throw new Error(`[rate-limit] Unknown limiter class: ${className}`); } try { const result = await limiter.consume(identifier); return { allowed: true, remaining: result.remainingPoints, reset: Date.now() + result.msBeforeNext, }; } catch (rej) { if (rej && typeof rej.msBeforeNext === 'number') { return { allowed: false, remaining: 0, reset: Date.now() + rej.msBeforeNext, }; } console.error('[rate-limit]', rej); return { allowed: true, remaining: Infinity, reset: 0 }; } } export async function checkAuthRateLimit(req) { return check('auth', extractIpIdentifier(req)); } export async function checkSearchRateLimit(req) { return check('search', extractIpIdentifier(req)); } export async function checkUploadRateLimit(req, userId) { return check('upload', extractUserIdentifier(userId)); } export async function checkGenerateRateLimit(req, userId) { return check('generate', extractUserIdentifier(userId)); } export async function checkImportRateLimit(req, userId) { return check('import', extractUserIdentifier(userId)); } export async function checkScanRateLimit(req, userId) { return check('scan', extractUserIdentifier(userId)); }