馃 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