import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import type { Request, Response } from "express"; import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js"; import { RateLimiterService } from "./rate-limiter.service.js"; /** * Enforces every `@RateLimit(...)` rule declared on a handler (public * routes only — see auth.controller.ts / public-signature.controller.ts / * public-verify.controller.ts). Handlers without the decorator are left * alone (returns true immediately), so this guard is safe to attach at * controller level without affecting authenticated routes. * * All rules on a handler must pass; the first one that doesn't wins the * `Retry-After` value and gets logged. A hit here is a signal worth * operator attention (repeated 429s from one source usually means brute * force or scraping, not an accident), hence `warn`, not `debug`. */ @Injectable() export class RateLimitGuard implements CanActivate { private readonly logger = new Logger(RateLimitGuard.name); constructor( private readonly reflector: Reflector, private readonly limiter: RateLimiterService, ) {} canActivate(context: ExecutionContext): boolean { const rules = this.reflector.get(RATE_LIMIT_KEY, context.getHandler()); if (!rules || rules.length === 0) return true; const request = context.switchToHttp().getRequest(); for (const rule of rules) { const keyValue = this.resolveKeyValue(rule, request); const bucketKey = `${rule.name}:${keyValue}`; const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs); if (!verdict.allowed) { this.logger.warn( `rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`, ); const response = context.switchToHttp().getResponse(); response.set("Retry-After", String(verdict.retryAfterSeconds)); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, message: "Muitas requisições. Tente novamente mais tarde.", retryAfterSeconds: verdict.retryAfterSeconds, }, HttpStatus.TOO_MANY_REQUESTS, ); } } return true; } private resolveKeyValue(rule: RateLimitRule, request: Request): string { switch (rule.keyBy) { case "ip": return request.ip ?? "unknown"; case "token": return this.stringParam(request.params.token); case "verificationId": return this.stringParam(request.params.verificationId); } } /** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */ private stringParam(value: string | string[] | undefined): string { if (Array.isArray(value)) return value[0] ?? "unknown"; return value ?? "unknown"; } }