馃 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( 113 endpoint: string, 114 email?: string, 115 ipAddress?: string, 116): void { 117 // Clear account-based rate limits 118 if (email) { 119 db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [ 120 `${endpoint}:account:${email.toLowerCase()}`, 121 ]); 122 } 123 124 // Clear IP-based rate limits 125 if (ipAddress) { 126 db.run("DELETE FROM rate_limit_attempts WHERE key = ?", [ 127 `${endpoint}:ip:${ipAddress}`, 128 ]); 129 } 130} 131 132export function cleanupOldAttempts(olderThanSeconds = 86400) { 133 // Clean up attempts older than specified time (default: 24 hours) 134 const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds; 135 db.run("DELETE FROM rate_limit_attempts WHERE timestamp < ?", [cutoff]); 136} 137 138// Run cleanup on module load and periodically 139cleanupOldAttempts(); 140setInterval( 141 () => { 142 cleanupOldAttempts(); 143 }, 144 60 * 60 * 1000, 145); // Every hour