rate-limiter.ts RawHistoryFolder
1import type { RequestEvent } from '@sveltejs/kit';
2import { createLogger } from '$lib/utils/logger.server';
3
4const log = createLogger('rate-limiter');
5
6const isDev = import.meta.env.DEV;
7
8// Rate limit configuration by endpoint type
9const RATE_LIMITS = {
10 // Authentication endpoints (login, register)
11 auth: {
12 points: isDev ? 1000 : 20,
13 durationMs: 5 * 60 * 1000
14 },
15
16 // Public endpoints (public chat)
17 public: {
18 points: isDev ? 1000 : 30,
19 durationMs: 60 * 1000
20 },
21
22 // Protected endpoints (authenticated API calls)
23 protected: {
24 points: isDev ? 1000 : 100,
25 durationMs: 60 * 1000
26 }
27} as const;
28
29// In-memory store for rate limiting
30const ipRequests = new Map<string, Array<{ timestamp: number }>>();
31
32// Compute max duration across all rate limits for cleanup
33const MAX_RATE_LIMIT_DURATION = Math.max(
34 RATE_LIMITS.auth.durationMs,
35 RATE_LIMITS.public.durationMs,
36 RATE_LIMITS.protected.durationMs
37);
38
39// Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope)
40let lastCleanup = 0;
41
42function cleanupStaleEntries() {
43 const now = Date.now();
44 // Run cleanup at most once per max duration window
45 if (now - lastCleanup < MAX_RATE_LIMIT_DURATION) return;
46 lastCleanup = now;
47
48 for (const [ip, timestamps] of ipRequests.entries()) {
49 const filtered = timestamps.filter((t) => now - t.timestamp < MAX_RATE_LIMIT_DURATION);
50 if (filtered.length === 0) {
51 ipRequests.delete(ip);
52 } else {
53 ipRequests.set(ip, filtered);
54 }
55 }
56}
57
58function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS {
59 if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
60 return 'auth';
61 }
62 if (isPublic) {
63 return 'public';
64 }
65 return 'protected';
66}
67
68function isRateLimited(
69 ip: string,
70 endpointType: keyof typeof RATE_LIMITS
71): { limited: boolean; retryAfter?: number } {
72 // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility)
73 cleanupStaleEntries();
74
75 // Skip rate limiting completely in development for localhost
76 if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
77 return { limited: false };
78 }
79
80 const now = Date.now();
81 const { points, durationMs } = RATE_LIMITS[endpointType];
82 const requests = ipRequests.get(ip) || [];
83 const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
84
85 log.debug(
86 `Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs / 1000}s`
87 );
88
89 if (validRequests.length >= points) {
90 const oldestRequest = validRequests[0];
91 const retryAfter = durationMs - (now - oldestRequest.timestamp);
92 return { limited: true, retryAfter };
93 }
94
95 validRequests.push({ timestamp: now });
96 ipRequests.set(ip, validRequests);
97
98 return { limited: false };
99}
100
101export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
102 const ip = event.getClientAddress();
103 const url = new URL(event.request.url);
104 const isPublic = url.searchParams.get('public') === 'true';
105
106 // Skip rate limiting for non-API routes
107 if (!url.pathname.startsWith('/api')) {
108 return null;
109 }
110
111 const endpointType = getEndpointType(url.pathname, isPublic);
112 const { limited, retryAfter } = isRateLimited(ip, endpointType);
113
114 if (limited) {
115 // Construct a user-friendly error message
116 let errorMessage = 'Too many requests. ';
117 if (endpointType === 'auth') {
118 errorMessage += 'Please wait before attempting to log in again.';
119 } else {
120 errorMessage += 'Please try again later.';
121 }
122
123 // Build response body - only include debug info in development
124 const responseBody: Record<string, unknown> = {
125 error: errorMessage,
126 retryAfter: Math.ceil((retryAfter || 0) / 1000)
127 };
128
129 // Only include potentially sensitive info in dev mode
130 if (isDev) {
131 responseBody.endpointType = endpointType;
132 responseBody.debug = {
133 hasUser: Boolean(event.locals.user),
134 hasSession: Boolean(event.locals.session),
135 endpointType,
136 rateLimitPoints: RATE_LIMITS[endpointType].points
137 };
138 }
139
140 // Build headers - only include debug headers in development
141 const headers: Record<string, string> = {
142 'Content-Type': 'application/json',
143 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
144 };
145
146 if (isDev) {
147 headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
148 headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
149 headers['X-Debug-Endpoint-Type'] = endpointType;
150 headers['X-Debug-Rate-Limit-Points'] = RATE_LIMITS[endpointType].points.toString();
151 }
152
153 return new Response(JSON.stringify(responseBody), {
154 status: 429,
155 headers
156 });
157 }
158
159 return null;
160}