馃 distributed transcription service thistle.dunkirk.sh
1import db from "../db/schema"; 2 3export interface RateLimitResult { 4 allowed: boolean; 5 retryAfter?: number; 6} 7 8export interface RateLimitConfig { 9 ip?: { max: number; windowSeconds: number }; 10 account?: { max: number; windowSeconds: number; email: string }; 11} 12 13export function checkRateLimit( 14 key: string, 15 maxAttempts: number, 16 windowSeconds: number, 17): RateLimitResult { 18 const now = Math.floor(Date.now() / 1000); 19 const windowStart = now - windowSeconds; 20 21 // Count attempts in rolling window 22 const count = db 23 .query<{ count: number }, [string, number]>( 24 "SELECT COUNT(*) as count FROM rate_limit_attempts WHERE key = ? AND timestamp > ?", 25 ) 26 .get(key, windowStart); 27 28 const attemptCount = count?.count ?? 0; 29 30 if (attemptCount >= maxAttempts) { 31 // Find oldest attempt in window to calculate retry time 32 const oldest = db 33 .query<{ timestamp: number }, [string, number]>( 34 "SELECT timestamp FROM rate_limit_attempts WHERE key = ? AND timestamp > ? ORDER BY timestamp ASC LIMIT 1", 35 ) 36 .get(key, windowStart); 37 38 const retryAfter = oldest 39 ? oldest.timestamp + windowSeconds - now 40 : windowSeconds; 41 42 return { 43 allowed: false, 44 retryAfter: Math.max(retryAfter, 1), 45 }; 46 } 47 48 // Record this attempt 49 db.run("INSERT INTO rate_limit_attempts (key, timestamp) VALUES (?, ?)", [ 50 key, 51 now, 52 ]); 53 54 return { allowed: true }; 55} 56 57export function enforceRateLimit( 58 req: Request, 59 endpoint: string, 60 config: RateLimitConfig, 61): Response | null { 62 const ipAddress = 63 req.headers.get("x-forwarded-for") ?? 64 req.headers.get("x-real-ip") ?? 65 "unknown"; 66 67 // Check IP-based rate limit 68 if (config.ip) { 69 const ipLimit = checkRateLimit( 70 `${endpoint}:ip:${ipAddress}`, 71 config.ip.max, 72 config.ip.windowSeconds, 73 ); 74 75 if (!ipLimit.allowed) { 76 return Response.json( 77 { 78 error: `Too many requests. Try again in ${ipLimit.retryAfter} seconds.`, 79 }, 80 { 81 status: 429, 82 headers: { "Retry-After": String(ipLimit.retryAfter) }, 83 }, 84 ); 85 } 86 } 87 88 // Check account-based rate limit 89 if (config.account) { 90 const accountLimit = checkRateLimit( 91 `${endpoint}:account:${config.account.email.toLowerCase()}`, 92 config.account.max, 93 config.account.windowSeconds, 94 ); 95 96 if (!accountLimit.allowed) { 97 return Response.json( 98 { 99 error: `Too many attempts for this account. Try again in ${accountLimit.retryAfter} seconds.`, 100 }, 101 { 102 status: 429, 103 headers: { "Retry-After": String(accountLimit.retryAfter) }, 104 }, 105 ); 106 } 107 } 108 109 return null; // Allowed 110} 111 112export function clearRateLimit(endpoint: string, email?: string, ipAddress?: string): void { 113 // Clear account-based rate limits 114 if (email) { 115 db.run( 116 "DELETE FROM rate_limit_attempts WHERE key = ?", 117 [`${endpoint}:account:${email.toLowerCase()}`] 118 ); 119 } 120 121 // Clear IP-based rate limits 122 if (ipAddress) { 123 db.run( 124 "DELETE FROM rate_limit_attempts WHERE key = ?", 125 [`${endpoint}:ip:${ipAddress}`] 126 ); 127 } 128} 129 130export function cleanupOldAttempts(olderThanSeconds = 86400) { 131 // Clean up attempts older than specified time (default: 24 hours) 132 const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds; 133 db.run("DELETE FROM rate_limit_attempts WHERE timestamp < ?", [cutoff]); 134} 135 136// Run cleanup on module load and periodically 137cleanupOldAttempts(); 138setInterval( 139 () => { 140 cleanupOldAttempts(); 141 }, 142 60 * 60 * 1000, 143); // Every hour