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, rooms)
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
28 // SSE endpoints - separate limits for authenticated and unauthenticated users
29 sse: {
30 authenticated: {
31 points: isDev ? 1000 : 500,
32 durationMs: 60 * 1000
33 },
34 unauthenticated: {
35 points: 0,
36 durationMs: 60 * 1000
37 }
38 }
39} as const;
40
41// In-memory store for rate limiting
42const ipRequests = new Map<string, Array<{ timestamp: number }>>();
43
44// Cleanup old entries every 5 minutes
45setInterval(() => {
46 const now = Date.now();
47 for (const [ip, timestamps] of ipRequests.entries()) {
48 // Remove entries older than the maximum duration we care about (5 minutes)
49 const filtered = timestamps.filter(t => now - t.timestamp < 5 * 60 * 1000);
50 if (filtered.length === 0) {
51 ipRequests.delete(ip);
52 } else {
53 ipRequests.set(ip, filtered);
54 }
55 }
56}, 5 * 60 * 1000);
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 (pathname.startsWith('/api/sse')) {
63 return 'sse';
64 }
65 if (isPublic) {
66 return 'public';
67 }
68 return 'protected';
69}
70
71function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
72 // Skip rate limiting completely in development for localhost
73 if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) {
74 return { limited: false };
75 }
76
77 const now = Date.now();
78
79 // Handle SSE endpoints differently based on authentication status
80 if (endpointType === 'sse') {
81 const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated;
82 const { points, durationMs } = config;
83
84 // Get or initialize request history for this IP
85 const requests = ipRequests.get(ip) || [];
86
87 // Clean old requests outside the window
88 const validRequests = requests.filter(req => now - req.timestamp < durationMs);
89
90 // Check if we're over the limit
91 if (validRequests.length >= points) {
92 log.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`);
93 const oldestRequest = validRequests[0];
94 const retryAfter = durationMs - (now - oldestRequest.timestamp);
95 return { limited: true, retryAfter };
96 }
97
98 // Add this request
99 validRequests.push({ timestamp: now });
100 ipRequests.set(ip, validRequests);
101
102 return { limited: false };
103 }
104
105 // Handle other endpoints
106 const { points, durationMs } = RATE_LIMITS[endpointType];
107 const requests = ipRequests.get(ip) || [];
108 const validRequests = requests.filter(req => now - req.timestamp < durationMs);
109
110 log.debug(`Rate limit check for ${ip} on ${endpointType}: ${validRequests.length}/${points} requests in the last ${durationMs/1000}s`);
111
112 if (validRequests.length >= points) {
113 const oldestRequest = validRequests[0];
114 const retryAfter = durationMs - (now - oldestRequest.timestamp);
115 return { limited: true, retryAfter };
116 }
117
118 validRequests.push({ timestamp: now });
119 ipRequests.set(ip, validRequests);
120
121 return { limited: false };
122}
123
124export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
125 const ip = event.getClientAddress();
126 const url = new URL(event.request.url);
127 const isPublic = url.searchParams.get('public') === 'true';
128 const isAuthenticated = Boolean(event.locals.user);
129
130 // Skip rate limiting for non-API routes
131 if (!url.pathname.startsWith('/api')) {
132 return null;
133 }
134
135 const endpointType = getEndpointType(url.pathname, isPublic);
136 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
137
138 if (limited) {
139 // Create debug headers
140 const debugHeaders = {
141 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
142 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
143 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
144 'X-Debug-Endpoint-Type': endpointType,
145 'X-Debug-Rate-Limit-Points': isAuthenticated
146 ? RATE_LIMITS.sse.authenticated.points.toString()
147 : RATE_LIMITS.sse.unauthenticated.points.toString(),
148 };
149
150 // Construct a user-friendly error message
151 let errorMessage = 'Too many requests. ';
152 if (endpointType === 'sse') {
153 errorMessage += isAuthenticated
154 ? 'Please wait before attempting to reconnect to the chat.'
155 : 'Please wait before attempting to view the public chat again.';
156 } else if (endpointType === 'auth') {
157 errorMessage += 'Please wait before attempting to log in again.';
158 } else {
159 errorMessage += 'Please try again later.';
160 }
161
162 return new Response(JSON.stringify({
163 error: errorMessage,
164 retryAfter: Math.ceil((retryAfter || 0) / 1000),
165 endpointType,
166 isAuthenticated,
167 debug: {
168 isAuthenticated,
169 hasUser: Boolean(event.locals.user),
170 hasSession: Boolean(event.locals.session),
171 endpointType,
172 rateLimitPoints: isAuthenticated
173 ? RATE_LIMITS.sse.authenticated.points
174 : RATE_LIMITS.sse.unauthenticated.points
175 }
176 }), {
177 status: 429,
178 headers: {
179 'Content-Type': 'application/json',
180 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),
181 ...debugHeaders
182 }
183 });
184 }
185
186 return null;
187}