1
14import { Inject, Injectable } from "@nestjs/common";
15import { RedisService } from "../../common/redis.service.js";
16import { logger } from "../../common/logger.js";
17
18export const FORM_SUBMIT_LIMIT = 5;
19export const FORM_RL_WINDOW_MS = 60_000;
20
21export interface RateCheck {
22 allowed: boolean;
23 remaining: number;
24
25 retryAfterSeconds: number;
26}
27
28@Injectable()
29export class FormRateLimiter {
30 constructor(@Inject(RedisService) private readonly redis: RedisService) {}
31
32 private lastRedisWarn = 0;
33
34
40 async check(slug: string, ip: string): Promise<RateCheck> {
41 const key = `form:rl:${slug}:${ip}`;
42 let count: number;
43 try {
44 const now = Date.now();
45 const pipe = this.redis.getRedis().pipeline();
46 pipe.zremrangebyscore(key, 0, now - FORM_RL_WINDOW_MS);
47 pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`);
48 pipe.zcard(key);
49 pipe.pexpire(key, FORM_RL_WINDOW_MS);
50 const results = await pipe.exec();
51 const card = results?.[2]?.[1];
52 count = typeof card === "number" ? card : 1;
53 } catch (err) {
54 if (Date.now() - this.lastRedisWarn > 60_000) {
55 this.lastRedisWarn = Date.now();
56 logger.warn(`form rate limiter degraded (redis unavailable): ${(err as Error).message}`);
57 }
58 return { allowed: true, remaining: FORM_SUBMIT_LIMIT, retryAfterSeconds: 0 };
59 }
60 const remaining = Math.max(0, FORM_SUBMIT_LIMIT - count);
61 const retryAfterSeconds = Math.ceil(FORM_RL_WINDOW_MS / 1000);
62 return {
63 allowed: count <= FORM_SUBMIT_LIMIT,
64 remaining,
65 retryAfterSeconds: count <= FORM_SUBMIT_LIMIT ? 0 : retryAfterSeconds
66 };
67 }
68}
69