v0.1.4 2490b00

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, 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} 

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close