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
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 cleanupStaleEntries();
73
74 // Skip rate limiting completely in development for localhost
75 if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
76 return { limited: false };
77 }
78
79 const now = Date.now();
80 const { points, durationMs } = RATE_LIMITS[endpointType];
81 const requests = ipRequests.get(ip) || [];
82 const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
83
84 log.debug(
85 `Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs / 1000}s`
86 );
87
88 if (validRequests.length >= points) {
89 const oldestRequest = validRequests[0];
90 const retryAfter = durationMs - (now - oldestRequest.timestamp);
91 return { limited: true, retryAfter };
92 }
93
94 validRequests.push({ timestamp: now });
95 ipRequests.set(ip, validRequests);
96
97 return { limited: false };
98}
99
100export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
101 const url = new URL(event.request.url);
102 const isPublic = url.searchParams.get('public') === 'true';
103
104 // Skip rate limiting for non-API routes
105 if (!url.pathname.startsWith('/api')) {
106 return null;
107 }
108
109 const ip = event.getClientAddress();
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}