mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
Create shared rateLimitConfig.ts with tiers (strict, moderate, lenient, generous, per-user variants) and update edge functions to import centralized rate limiters. Replace inline rate limiter usage with new config, preserving backward compatibility. Add documentation guide for rate limiting usage.
225 lines
6.8 KiB
TypeScript
225 lines
6.8 KiB
TypeScript
/**
|
|
* Shared Rate Limiting Middleware for Edge Functions
|
|
* Prevents abuse and DoS attacks with in-memory rate limiting
|
|
*/
|
|
|
|
export interface RateLimitConfig {
|
|
windowMs: number; // Time window in milliseconds
|
|
maxRequests: number; // Max requests per window
|
|
maxMapSize?: number; // Max IPs to track (default: 10000)
|
|
keyGenerator?: (req: Request) => string; // Custom key generator
|
|
trustProxy?: boolean; // Trust X-Forwarded-For header
|
|
}
|
|
|
|
export interface RateLimitResult {
|
|
allowed: boolean;
|
|
retryAfter?: number;
|
|
remaining?: number;
|
|
}
|
|
|
|
class RateLimiter {
|
|
private rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
private config: Required<RateLimitConfig>;
|
|
private cleanupInterval: number;
|
|
|
|
constructor(config: RateLimitConfig) {
|
|
this.config = {
|
|
maxMapSize: 10000,
|
|
keyGenerator: (req: Request) => this.getClientIP(req),
|
|
trustProxy: true,
|
|
...config
|
|
};
|
|
|
|
// Setup periodic cleanup
|
|
this.cleanupInterval = setInterval(
|
|
() => this.cleanupExpiredEntries(),
|
|
Math.min(this.config.windowMs / 2, 30000)
|
|
);
|
|
}
|
|
|
|
private getClientIP(req: Request): string {
|
|
if (this.config.trustProxy) {
|
|
const forwarded = req.headers.get('x-forwarded-for');
|
|
if (forwarded) return forwarded.split(',')[0].trim();
|
|
|
|
const realIP = req.headers.get('x-real-ip');
|
|
if (realIP) return realIP;
|
|
}
|
|
|
|
// Fallback for testing
|
|
return '0.0.0.0';
|
|
}
|
|
|
|
private cleanupExpiredEntries(): void {
|
|
try {
|
|
const now = Date.now();
|
|
let deletedCount = 0;
|
|
|
|
for (const [key, data] of this.rateLimitMap.entries()) {
|
|
if (now > data.resetAt) {
|
|
this.rateLimitMap.delete(key);
|
|
deletedCount++;
|
|
}
|
|
}
|
|
|
|
// Cleanup runs silently unless there are issues
|
|
} catch (error) {
|
|
// Emergency: Clear oldest 30% if cleanup fails
|
|
if (this.rateLimitMap.size > this.config.maxMapSize) {
|
|
const toClear = Math.floor(this.rateLimitMap.size * 0.3);
|
|
const keys = Array.from(this.rateLimitMap.keys()).slice(0, toClear);
|
|
keys.forEach(key => this.rateLimitMap.delete(key));
|
|
}
|
|
}
|
|
}
|
|
|
|
check(req: Request): RateLimitResult {
|
|
const key = this.config.keyGenerator(req);
|
|
const now = Date.now();
|
|
const existing = this.rateLimitMap.get(key);
|
|
|
|
// Check existing entry
|
|
if (existing && now <= existing.resetAt) {
|
|
if (existing.count >= this.config.maxRequests) {
|
|
const retryAfter = Math.ceil((existing.resetAt - now) / 1000);
|
|
return {
|
|
allowed: false,
|
|
retryAfter,
|
|
remaining: 0
|
|
};
|
|
}
|
|
existing.count++;
|
|
return {
|
|
allowed: true,
|
|
remaining: this.config.maxRequests - existing.count
|
|
};
|
|
}
|
|
|
|
// Handle capacity
|
|
if (!existing && this.rateLimitMap.size >= this.config.maxMapSize) {
|
|
this.cleanupExpiredEntries();
|
|
|
|
// LRU eviction if still at capacity
|
|
if (this.rateLimitMap.size >= this.config.maxMapSize) {
|
|
const toDelete = Math.floor(this.config.maxMapSize * 0.3);
|
|
const sortedEntries = Array.from(this.rateLimitMap.entries())
|
|
.sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
|
|
for (let i = 0; i < toDelete && i < sortedEntries.length; i++) {
|
|
this.rateLimitMap.delete(sortedEntries[i][0]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create new entry
|
|
this.rateLimitMap.set(key, {
|
|
count: 1,
|
|
resetAt: now + this.config.windowMs
|
|
});
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining: this.config.maxRequests - 1
|
|
};
|
|
}
|
|
|
|
destroy(): void {
|
|
clearInterval(this.cleanupInterval);
|
|
this.rateLimitMap.clear();
|
|
}
|
|
}
|
|
|
|
// Import centralized rate limit configurations
|
|
import {
|
|
RATE_LIMIT_STRICT,
|
|
RATE_LIMIT_MODERATE,
|
|
RATE_LIMIT_STANDARD,
|
|
RATE_LIMIT_LENIENT,
|
|
RATE_LIMIT_GENEROUS,
|
|
RATE_LIMIT_PER_USER_STRICT,
|
|
RATE_LIMIT_PER_USER_MODERATE,
|
|
RATE_LIMIT_PER_USER_STANDARD,
|
|
RATE_LIMIT_PER_USER_LENIENT,
|
|
} from './rateLimitConfig.ts';
|
|
|
|
// Export factory function for creating custom rate limiters
|
|
export function createRateLimiter(config: RateLimitConfig): RateLimiter {
|
|
return new RateLimiter(config);
|
|
}
|
|
|
|
/**
|
|
* Pre-configured rate limiters using centralized tier definitions
|
|
*
|
|
* These are singleton instances that should be imported and used by edge functions.
|
|
* See rateLimitConfig.ts for detailed documentation on when to use each tier.
|
|
*/
|
|
export const rateLimiters = {
|
|
// Strict: 5 requests/minute - For expensive operations
|
|
strict: createRateLimiter(RATE_LIMIT_STRICT),
|
|
|
|
// Moderate: 10 requests/minute - For moderation and submissions
|
|
moderate: createRateLimiter(RATE_LIMIT_MODERATE),
|
|
|
|
// Standard: 20 requests/minute - For typical operations (DEPRECATED: use 'moderate' for 10/min or 'standard' for 20/min)
|
|
standard: createRateLimiter(RATE_LIMIT_MODERATE), // Keeping for backward compatibility
|
|
|
|
// Lenient: 30 requests/minute - For lightweight reads
|
|
lenient: createRateLimiter(RATE_LIMIT_LENIENT),
|
|
|
|
// Generous: 60 requests/minute - For high-frequency operations
|
|
generous: createRateLimiter(RATE_LIMIT_GENEROUS),
|
|
|
|
// Per-user rate limiters (key by user ID instead of IP)
|
|
perUserStrict: createRateLimiter(RATE_LIMIT_PER_USER_STRICT),
|
|
perUserModerate: createRateLimiter(RATE_LIMIT_PER_USER_MODERATE),
|
|
perUserStandard: createRateLimiter(RATE_LIMIT_PER_USER_STANDARD),
|
|
perUserLenient: createRateLimiter(RATE_LIMIT_PER_USER_LENIENT),
|
|
|
|
// Legacy per-user factory function (DEPRECATED: use perUserStrict, perUserModerate, etc.)
|
|
perUser: (maxRequests: number = 20) => createRateLimiter({
|
|
...RATE_LIMIT_PER_USER_STANDARD,
|
|
maxRequests,
|
|
}),
|
|
};
|
|
|
|
// Middleware helper
|
|
export function withRateLimit(
|
|
handler: (req: Request) => Promise<Response>,
|
|
limiter: RateLimiter,
|
|
corsHeaders: Record<string, string> = {}
|
|
): (req: Request) => Promise<Response> {
|
|
return async (req: Request) => {
|
|
const result = limiter.check(req);
|
|
|
|
if (!result.allowed) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Rate limit exceeded',
|
|
message: 'Too many requests. Please try again later.',
|
|
retryAfter: result.retryAfter
|
|
}),
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Retry-After': String(result.retryAfter || 60),
|
|
'X-RateLimit-Limit': String(limiter['config'].maxRequests),
|
|
'X-RateLimit-Remaining': String(result.remaining || 0),
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const response = await handler(req);
|
|
|
|
// Add rate limit headers to successful responses
|
|
if (result.remaining !== undefined) {
|
|
response.headers.set('X-RateLimit-Limit', String(limiter['config'].maxRequests));
|
|
response.headers.set('X-RateLimit-Remaining', String(result.remaining));
|
|
}
|
|
|
|
return response;
|
|
};
|
|
}
|