1import { Inject, CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
2import type { Request, Response } from "express";
3import { loadEnv } from "../../config/env.js";
4import { ApiError } from "../errors.js";
5import { logger } from "../logger.js";
6import { RedisService } from "../redis.service.js";
7import type { AuthContext } from "../types.js";
8
9
25const WINDOW_MS = 60_000;
26
27@Injectable()
28export class RateLimitGuard implements CanActivate {
29 private lastRedisWarn = 0;
30
31 constructor(@Inject(RedisService) private readonly redis: RedisService) {}
32
33 async canActivate(context: ExecutionContext): Promise<boolean> {
34 const req = context.switchToHttp().getRequest<Request>();
35 const res = context.switchToHttp().getResponse<Response>();
36 const auth: AuthContext | undefined = req.auth;
37
38
39
40
41
42 if (req.path.startsWith("/internal/")) return true;
43
44 const cls = req.path.startsWith("/api/v1/auth/") ? "auth" : "api";
45
46
47
48
49
50
51 const envLimit = cls === "auth" ? loadEnv().RATE_LIMIT_AUTH : loadEnv().RATE_LIMIT_API;
52 const keyLimit = auth?.kind === "api" ? auth.rateLimit : undefined;
53 const limit =
54 auth?.kind === "api" ? keyLimit ?? loadEnv().RATE_LIMIT_API_KEY : envLimit;
55 const subject = auth?.apiKeyId ?? auth?.userId ?? req.ip ?? "unknown";
56 const key = `rl:${cls}:${subject}`;
57
58 let count: number;
59 try {
60 const now = Date.now();
61 const pipe = this.redis.getRedis().pipeline();
62 pipe.zremrangebyscore(key, 0, now - WINDOW_MS);
63 pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`);
64 pipe.zcard(key);
65 pipe.pexpire(key, WINDOW_MS);
66 const results = await pipe.exec();
67 const cardResult = results?.[2]?.[1];
68 count = typeof cardResult === "number" ? cardResult : 1;
69 } catch (err) {
70 if (Date.now() - this.lastRedisWarn > 60_000) {
71 this.lastRedisWarn = Date.now();
72 logger.warn(`rate limiter degraded (redis unavailable): ${(err as Error).message}`);
73 }
74
75
76 res.setHeader("X-RateLimit-Limit", String(limit));
77 res.setHeader("X-RateLimit-Remaining", "0");
78 res.setHeader("X-RateLimit-Reset", String(Math.ceil((Date.now() + WINDOW_MS) / 1000)));
79 return true;
80 }
81
82 const remaining = Math.max(0, limit - count);
83 const reset = Math.ceil((Date.now() + WINDOW_MS) / 1000);
84 res.setHeader("X-RateLimit-Limit", String(limit));
85 res.setHeader("X-RateLimit-Remaining", String(remaining));
86 res.setHeader("X-RateLimit-Reset", String(reset));
87
88 if (count > limit) {
89 const retryAfter = Math.ceil(WINDOW_MS / 1000);
90 res.setHeader("Retry-After", String(retryAfter));
91 throw new ApiError("RATE_LIMITED", 429, "Too many requests.", [
92 { retryAfterSeconds: retryAfter }
93 ]);
94 }
95 return true;
96 }
97}
98