| |
| |
| |
| |
| |
| |
|
|
| interface RateLimitConfig { |
| limit: number; |
| windowMs: number; |
| } |
|
|
| class RateLimiter { |
| private requests = new Map<string, number[]>(); |
| private lastCleanup = Date.now(); |
| private readonly CLEANUP_INTERVAL = 60 * 1000; |
| private readonly MAX_KEYS = 10000; |
|
|
| |
| |
| |
| |
| |
| |
| check(identifier: string, config: RateLimitConfig): boolean { |
| const now = Date.now(); |
|
|
| |
| if (now - this.lastCleanup > this.CLEANUP_INTERVAL) { |
| this.cleanup(config.windowMs); |
| } |
|
|
| |
| const timestamps = this.requests.get(identifier) || []; |
|
|
| |
| const recentRequests = timestamps.filter(t => now - t < config.windowMs); |
|
|
| |
| if (recentRequests.length >= config.limit) { |
| return false; |
| } |
|
|
| |
| recentRequests.push(now); |
| this.requests.set(identifier, recentRequests); |
|
|
| |
| if (this.requests.size > this.MAX_KEYS) { |
| const firstKey = this.requests.keys().next().value; |
| if (firstKey !== undefined) { |
| this.requests.delete(firstKey); |
| } |
| } |
|
|
| return true; |
| } |
|
|
| |
| |
| |
| getCount(identifier: string, windowMs: number): number { |
| const now = Date.now(); |
| const timestamps = this.requests.get(identifier) || []; |
| return timestamps.filter(t => now - t < windowMs).length; |
| } |
|
|
| |
| |
| |
| getResetTime(identifier: string, config: RateLimitConfig): number { |
| const timestamps = this.requests.get(identifier) || []; |
| if (timestamps.length === 0) return 0; |
|
|
| const now = Date.now(); |
| const recentRequests = timestamps.filter(t => now - t < config.windowMs); |
|
|
| if (recentRequests.length === 0) return 0; |
|
|
| const oldestRequest = Math.min(...recentRequests); |
| const resetTime = oldestRequest + config.windowMs - now; |
|
|
| return Math.ceil(resetTime / 1000); |
| } |
|
|
| |
| |
| |
| private cleanup(windowMs: number): void { |
| const now = Date.now(); |
|
|
| for (const [identifier, timestamps] of this.requests.entries()) { |
| const recent = timestamps.filter(t => now - t < windowMs); |
|
|
| if (recent.length === 0) { |
| |
| this.requests.delete(identifier); |
| } else { |
| |
| this.requests.set(identifier, recent); |
| } |
| } |
|
|
| this.lastCleanup = now; |
| } |
|
|
| |
| |
| |
| clear(): void { |
| this.requests.clear(); |
| } |
|
|
| |
| |
| |
| getStats(): { totalIdentifiers: number; totalRequests: number } { |
| let totalRequests = 0; |
| for (const timestamps of this.requests.values()) { |
| totalRequests += timestamps.length; |
| } |
|
|
| return { |
| totalIdentifiers: this.requests.size, |
| totalRequests, |
| }; |
| } |
| } |
|
|
| |
| export const pageviewRateLimiter = new RateLimiter(); |
| export const interactionRateLimiter = new RateLimiter(); |
|
|
| |
| export const RATE_LIMIT_CONFIG = { |
| pageview: { |
| limit: 100, |
| windowMs: 10 * 60 * 1000 |
| }, |
| interaction: { |
| limit: 500, |
| windowMs: 10 * 60 * 1000 |
| }, |
| strict: { |
| limit: 10, |
| windowMs: 60 * 1000 |
| } |
| } as const; |
|
|
| |
| |
| |
| export function getIdentifier(request: Request): string { |
| |
| const forwardedFor = request.headers.get('x-forwarded-for'); |
| const realIp = request.headers.get('x-real-ip'); |
| const cfConnectingIp = request.headers.get('cf-connecting-ip'); |
|
|
| if (forwardedFor) { |
| |
| return forwardedFor.split(',')[0].trim(); |
| } |
|
|
| if (cfConnectingIp) { |
| return cfConnectingIp; |
| } |
|
|
| if (realIp) { |
| return realIp; |
| } |
|
|
| |
| return 'unknown'; |
| } |
|
|