diff --git a/app/backend/src/lib/rate-limit.ts b/app/backend/src/lib/rate-limit.ts new file mode 100644 index 0000000..d10c87e --- /dev/null +++ b/app/backend/src/lib/rate-limit.ts @@ -0,0 +1,43 @@ +import { redis } from '../redis.js'; + +export interface RateLimitResult { + limited: boolean; + retryAfter: number; +} + +export async function checkRateLimit( + ip: string, + action: string, + max: number, + windowSeconds: number, +): Promise { + const key = `rl:${action}:${ip}`; + const hits = await redis.incr(key); + if (hits === 1) await redis.expire(key, windowSeconds); + if (hits > max) { + const ttl = await redis.ttl(key); + return { limited: true, retryAfter: Math.max(ttl, 1) }; + } + return { limited: false, retryAfter: 0 }; +} + +export async function checkRateLimitBySubject( + subject: string, + action: string, + max: number, + windowSeconds: number, +): Promise { + const key = `rl:${action}:${subject}`; + const hits = await redis.incr(key); + if (hits === 1) await redis.expire(key, windowSeconds); + if (hits > max) { + const ttl = await redis.ttl(key); + return { limited: true, retryAfter: Math.max(ttl, 1) }; + } + return { limited: false, retryAfter: 0 }; +} + +export function rateLimitResponse(reply: import('fastify').FastifyReply, retryAfter: number) { + reply.header('Retry-After', String(retryAfter)); + return reply.code(429).send({ error: 'rate_limited', retryAfter }); +}