Files
pagodera/lib/rate-limiter.ts
2025-11-11 03:08:36 -03:00

189 lines
4.3 KiB
TypeScript

/**
* Rate Limiter para ambiente serverless
* Utiliza Map em memória com TTL (Time To Live)
* Adequado para serverless de curta duração
*/
interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}
interface RateLimitEntry {
count: number;
resetAt: number;
}
// Cache em memória de requisições por IP
const requestCache = new Map<string, RateLimitEntry>();
// Configuração de rate limits por endpoint
const RATE_LIMITS: Record<string, RateLimitConfig> = {
"/api/donations/create": {
maxRequests: 5,
windowMs: 10 * 60 * 1000, // 10 minutos
},
"/api/donations/status": {
maxRequests: 30,
windowMs: 60 * 1000, // 1 minuto
},
"/api/donations/verify": {
maxRequests: 20,
windowMs: 60 * 1000, // 1 minuto
},
"/api/webhooks/abacatepay": {
maxRequests: 100,
windowMs: 60 * 1000, // 1 minuto
},
"/api/auth/register": {
maxRequests: 5,
windowMs: 10 * 60 * 1000, // 10 minutos
},
"/api/auth/login": {
maxRequests: 10,
windowMs: 60 * 1000, // 1 minuto
},
"/api/auth/refresh": {
maxRequests: 20,
windowMs: 60 * 1000, // 1 minuto
},
"/api/auth/me": {
maxRequests: 30,
windowMs: 60 * 1000, // 1 minuto
},
};
// Limpar cache expirado a cada 5 minutos
setInterval(
() => {
const now = Date.now();
let cleaned = 0;
for (const [key, entry] of requestCache.entries()) {
if (now > entry.resetAt) {
requestCache.delete(key);
cleaned++;
}
}
if (cleaned > 0) {
console.log(`🧹 Rate limiter: ${cleaned} entradas expiradas removidas`);
}
},
5 * 60 * 1000,
);
/**
* Verifica se uma requisição excede o rate limit
* @param ip - Endereço IP do cliente
* @param path - Caminho da requisição (ex: /api/donations/create)
* @returns Objeto com informações sobre o rate limit
*/
export function checkRateLimit(
ip: string,
path: string,
): {
allowed: boolean;
remaining: number;
resetAt: number;
limit: number;
} {
const config = RATE_LIMITS[path];
// Se não há config para este path, permite tudo
if (!config) {
return {
allowed: true,
remaining: Infinity,
resetAt: Date.now() + 60000,
limit: Infinity,
};
}
const key = `${ip}:${path}`;
const now = Date.now();
const entry = requestCache.get(key);
// Se não existe entrada ou expirou, criar nova
if (!entry || now > entry.resetAt) {
const newEntry: RateLimitEntry = {
count: 1,
resetAt: now + config.windowMs,
};
requestCache.set(key, newEntry);
return {
allowed: true,
remaining: config.maxRequests - 1,
resetAt: newEntry.resetAt,
limit: config.maxRequests,
};
}
// Se existe e não expirou, incrementar contador
entry.count++;
if (entry.count > config.maxRequests) {
return {
allowed: false,
remaining: 0,
resetAt: entry.resetAt,
limit: config.maxRequests,
};
}
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetAt: entry.resetAt,
limit: config.maxRequests,
};
}
/**
* Extrai o IP real de uma requisição
* Considera headers de proxies/CDN (x-forwarded-for, x-real-ip)
*/
export function getClientIP(request: Request): string {
// Tentar x-forwarded-for primeiro (pode ter múltiplos IPs)
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
// Pegar apenas o primeiro IP (cliente original)
return forwardedFor.split(",")[0].trim();
}
// Tentar x-real-ip
const realIP = request.headers.get("x-real-ip");
if (realIP) {
return realIP.trim();
}
// Fallback: usar conexão remota
return request.headers.get("cf-connecting-ip") || "unknown";
}
/**
* Middleware helper para criar resposta de rate limit
*/
export function createRateLimitResponse(resetAt: number): Response {
const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);
return new Response(
JSON.stringify({
error: "Rate limit excedido",
message: "Muitas requisições. Tente novamente mais tarde.",
retryAfter,
}),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": retryAfter.toString(),
"X-RateLimit-Limit": "1",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": new Date(resetAt).toISOString(),
},
},
);
}