detached 1f4430f

feat: Implement comprehensive rate limiting for chat and SSE endpoints

- Add granular rate limiting for SSE connections with separate limits for authenticated and unauthenticated users
- Enhance rate limiter to provide dynamic retry-after information
- Implement client-side cooldown mechanism for message sending
- Add SSE error handling with connection retry and user-friendly error messages
- Update chat state and UI to display rate limit warnings and connection errors
commit
1f4430fb102b49b385f0fabf350c790c8beeae71
author
Kit · · 2025-02-14 07:58 UTC
parent
88fcae1
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
5 files · +331 -31
M src/lib/api/rate-limiter.ts +59 -16
@@ -11,8 +11,11 @@ const RATE_LIMITS = {
1111 // Protected endpoints (authenticated API calls)
1212 protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute
1313
14- // SSE endpoints
15- sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute
14+ // SSE endpoints - separate limits for authenticated and unauthenticated users
15+ sse: {
16+ authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users
17+ unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users
18+ }
1619 } as const;
1720
1821 // In-memory store for rate limiting
@@ -45,33 +48,57 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
4548 return 'protected';
4649 }
4750
48-function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean {
51+function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
4952 const now = Date.now();
50- const { points, durationMs } = RATE_LIMITS[endpointType];
5153
52- // Get or initialize request history for this IP
53- const requests = ipRequests.get(ip) || [];
54+ // Handle SSE endpoints differently based on authentication status
55+ if (endpointType === 'sse') {
56+ const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated;
57+ const { points, durationMs } = config;
58+
59+ // Get or initialize request history for this IP
60+ const requests = ipRequests.get(ip) || [];
61+
62+ // Clean old requests outside the window
63+ const validRequests = requests.filter(req => now - req.timestamp < durationMs);
64+
65+ // Check if we're over the limit
66+ if (validRequests.length >= points) {
67+ console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`);
68+ const oldestRequest = validRequests[0];
69+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
70+ return { limited: true, retryAfter };
71+ }
72+
73+ // Add this request
74+ validRequests.push({ timestamp: now });
75+ ipRequests.set(ip, validRequests);
76+
77+ return { limited: false };
78+ }
5479
55- // Clean old requests outside the window
80+ // Handle other endpoints
81+ const { points, durationMs } = RATE_LIMITS[endpointType];
82+ const requests = ipRequests.get(ip) || [];
5683 const validRequests = requests.filter(req => now - req.timestamp < durationMs);
5784
58- // Check if we're over the limit
5985 if (validRequests.length >= points) {
60- console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`);
61- return true;
86+ const oldestRequest = validRequests[0];
87+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
88+ return { limited: true, retryAfter };
6289 }
6390
64- // Add this request
6591 validRequests.push({ timestamp: now });
6692 ipRequests.set(ip, validRequests);
6793
68- return false;
94+ return { limited: false };
6995 }
7096
7197 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
7298 const ip = event.getClientAddress();
7399 const url = new URL(event.request.url);
74100 const isPublic = url.searchParams.get('public') === 'true';
101+ const isAuthenticated = Boolean(event.locals.user);
75102
76103 // Skip rate limiting for non-API routes
77104 if (!url.pathname.startsWith('/api')) {
@@ -79,17 +106,33 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
79106 }
80107
81108 const endpointType = getEndpointType(url.pathname, isPublic);
109+ const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
82110
83- if (isRateLimited(ip, endpointType)) {
111+ if (limited) {
84112 console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`);
113+
114+ // Construct a user-friendly error message
115+ let errorMessage = 'Too many requests. ';
116+ if (endpointType === 'sse') {
117+ errorMessage += isAuthenticated
118+ ? 'Please wait before attempting to reconnect to the chat.'
119+ : 'Please wait before attempting to view the public chat again.';
120+ } else if (endpointType === 'auth') {
121+ errorMessage += 'Please wait before attempting to log in again.';
122+ } else {
123+ errorMessage += 'Please try again later.';
124+ }
125+
85126 return new Response(JSON.stringify({
86- error: 'Too many requests',
87- retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000)
127+ error: errorMessage,
128+ retryAfter: Math.ceil((retryAfter || 0) / 1000),
129+ endpointType,
130+ isAuthenticated
88131 }), {
89132 status: 429,
90133 headers: {
91134 'Content-Type': 'application/json',
92- 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString()
135+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
93136 }
94137 });
95138 }
@@ -11,8 +11,11 @@ const RATE_LIMITS = {
11 // Protected endpoints (authenticated API calls)11 // Protected endpoints (authenticated API calls)
12 protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute12 protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute
13 13
14- // SSE endpoints14+ // SSE endpoints - separate limits for authenticated and unauthenticated users
15- sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute15+ sse: {
16+ authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users
17+ unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users
18+ }
16 } as const;19 } as const;
17 20
18 // In-memory store for rate limiting21 // In-memory store for rate limiting
@@ -45,33 +48,57 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
45 return 'protected';48 return 'protected';
46 }49 }
47 50
48-function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean {51+function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } {
49 const now = Date.now();52 const now = Date.now();
50- const { points, durationMs } = RATE_LIMITS[endpointType];
51 53
52- // Get or initialize request history for this IP54+ // Handle SSE endpoints differently based on authentication status
53- const requests = ipRequests.get(ip) || [];55+ if (endpointType === 'sse') {
56+ const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated;
57+ const { points, durationMs } = config;
58+
59+ // Get or initialize request history for this IP
60+ const requests = ipRequests.get(ip) || [];
61+
62+ // Clean old requests outside the window
63+ const validRequests = requests.filter(req => now - req.timestamp < durationMs);
64+
65+ // Check if we're over the limit
66+ if (validRequests.length >= points) {
67+ console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`);
68+ const oldestRequest = validRequests[0];
69+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
70+ return { limited: true, retryAfter };
71+ }
72+
73+ // Add this request
74+ validRequests.push({ timestamp: now });
75+ ipRequests.set(ip, validRequests);
76+
77+ return { limited: false };
78+ }
54 79
55- // Clean old requests outside the window80+ // Handle other endpoints
81+ const { points, durationMs } = RATE_LIMITS[endpointType];
82+ const requests = ipRequests.get(ip) || [];
56 const validRequests = requests.filter(req => now - req.timestamp < durationMs);83 const validRequests = requests.filter(req => now - req.timestamp < durationMs);
57 84
58- // Check if we're over the limit
59 if (validRequests.length >= points) {85 if (validRequests.length >= points) {
60- console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`);86+ const oldestRequest = validRequests[0];
61- return true;87+ const retryAfter = durationMs - (now - oldestRequest.timestamp);
88+ return { limited: true, retryAfter };
62 }89 }
63 90
64- // Add this request
65 validRequests.push({ timestamp: now });91 validRequests.push({ timestamp: now });
66 ipRequests.set(ip, validRequests);92 ipRequests.set(ip, validRequests);
67 93
68- return false;94+ return { limited: false };
69 }95 }
70 96
71 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {97 export async function handleRateLimit(event: RequestEvent): Promise<Response | null> {
72 const ip = event.getClientAddress();98 const ip = event.getClientAddress();
73 const url = new URL(event.request.url);99 const url = new URL(event.request.url);
74 const isPublic = url.searchParams.get('public') === 'true';100 const isPublic = url.searchParams.get('public') === 'true';
101+ const isAuthenticated = Boolean(event.locals.user);
75 102
76 // Skip rate limiting for non-API routes103 // Skip rate limiting for non-API routes
77 if (!url.pathname.startsWith('/api')) {104 if (!url.pathname.startsWith('/api')) {
@@ -79,17 +106,33 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
79 }106 }
80 107
81 const endpointType = getEndpointType(url.pathname, isPublic);108 const endpointType = getEndpointType(url.pathname, isPublic);
109+ const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
82 110
83- if (isRateLimited(ip, endpointType)) {111+ if (limited) {
84 console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`);112 console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`);
113+
114+ // Construct a user-friendly error message
115+ let errorMessage = 'Too many requests. ';
116+ if (endpointType === 'sse') {
117+ errorMessage += isAuthenticated
118+ ? 'Please wait before attempting to reconnect to the chat.'
119+ : 'Please wait before attempting to view the public chat again.';
120+ } else if (endpointType === 'auth') {
121+ errorMessage += 'Please wait before attempting to log in again.';
122+ } else {
123+ errorMessage += 'Please try again later.';
124+ }
125+
85 return new Response(JSON.stringify({126 return new Response(JSON.stringify({
86- error: 'Too many requests',127+ error: errorMessage,
87- retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000)128+ retryAfter: Math.ceil((retryAfter || 0) / 1000),
129+ endpointType,
130+ isAuthenticated
88 }), {131 }), {
89 status: 429,132 status: 429,
90 headers: {133 headers: {
91 'Content-Type': 'application/json',134 'Content-Type': 'application/json',
92- 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString()135+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
93 }136 }
94 });137 });
95 }138 }
M src/lib/components/chat-room.svelte +174 -11
@@ -21,6 +21,37 @@ let currentMessage = $state('');
2121 let showUserList = $state(false);
2222 let isMaximized = $state(false);
2323
24+// Rate limiting state
25+let cooldownEndTime = $state<number | null>(null);
26+let cooldownProgress = $state(0);
27+let cooldownInterval: ReturnType<typeof setInterval> | null = null;
28+let rateLimitWarning = $state<string | null>(null);
29+
30+function updateCooldownProgress() {
31+ if (!cooldownEndTime) return;
32+
33+ const now = Date.now();
34+ if (now >= cooldownEndTime) {
35+ cooldownEndTime = null;
36+ cooldownProgress = 0;
37+ rateLimitWarning = null;
38+ if (cooldownInterval) {
39+ clearInterval(cooldownInterval);
40+ cooldownInterval = null;
41+ }
42+ return;
43+ }
44+
45+ cooldownProgress = ((cooldownEndTime - now) / 1000);
46+}
47+
48+function startCooldownTimer(retryAfter: number) {
49+ cooldownEndTime = Date.now() + retryAfter;
50+ if (cooldownInterval) clearInterval(cooldownInterval);
51+ cooldownInterval = setInterval(updateCooldownProgress, 100);
52+ updateCooldownProgress();
53+}
54+
2455 let { showChatRoom = $bindable() } = $props();
2556
2657 function handleClose() {
@@ -42,13 +73,22 @@ let visibleMessages = $derived((() => {
4273 return result;
4374 })());
4475
45-// Single effect to handle all state updates
76+// Add SSE error state
77+let sseError = $state<string | null>(null);
78+let sseRetryAfter = $state<number | null>(null);
79+
80+// Update the effect to check for SSE errors
4681 $effect(() => {
4782 const user = chatState.getCurrentUser();
4883 if (user?.id !== currentUser?.id) {
4984 currentUser = user;
5085 }
5186
87+ // Check for SSE errors
88+ const { error, retryAfter } = chatState.getSSEError();
89+ sseError = error;
90+ sseRetryAfter = retryAfter;
91+
5292 // Only update messages if we have new ones
5393 const stateMessages = chatState.getMessages();
5494 if (stateMessages.length !== messages.length) {
@@ -132,21 +172,35 @@ onMount(() => {
132172 // Add resize listener
133173 window.addEventListener('resize', handleResize);
134174
135- return () => window.removeEventListener('resize', handleResize);
175+ return () => {
176+ window.removeEventListener('resize', handleResize);
177+ if (cooldownInterval) clearInterval(cooldownInterval);
178+ };
136179 });
137180
138181 // New reactive state to track when a message is being sent
139182 let isSendingMessage = $state(false);
140183
141184 async function handleSubmit() {
142- if (!currentMessage.trim()) return;
185+ if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
186+
143187 isSendingMessage = true;
144188 try {
145- await chatState.sendMessage(currentMessage);
146- currentMessage = '';
189+ const response = await chatState.sendMessage(currentMessage);
190+ if (!response.success && response.isRateLimited && response.retryAfter) {
191+ startCooldownTimer(response.retryAfter);
192+ rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
193+ } else if (!response.success) {
194+ rateLimitWarning = response.error || 'Failed to send message. Please try again.';
195+ setTimeout(() => rateLimitWarning = null, 3000);
196+ } else {
197+ currentMessage = '';
198+ rateLimitWarning = null;
199+ }
147200 } catch (error) {
148201 console.error('Failed to send message:', error);
149- alert('Failed to send message. Please try again.');
202+ rateLimitWarning = 'Failed to send message. Please try again.';
203+ setTimeout(() => rateLimitWarning = null, 3000);
150204 } finally {
151205 isSendingMessage = false;
152206 }
@@ -246,7 +300,12 @@ function handleMaximizeEvent(event: CustomEvent<{
246300 ondragmove={handleDragMove}
247301 >
248302 <div class="title-bar">
249- <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div>
303+ <div class="title-bar-text">
304+ Pdr Aim - {currentUser?.nickname}
305+ {#if sseError}
306+ <span class="connection-error">⚠️ Connection Error</span>
307+ {/if}
308+ </div>
250309 <div class="title-bar-controls">
251310 {#if !isMobile}
252311 <button aria-label="Minimize"></button>
@@ -266,8 +325,29 @@ function handleMaximizeEvent(event: CustomEvent<{
266325 </div>
267326
268327 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
328+ <!-- Add error message display -->
329+ {#if sseError}
330+ <div class="error-banner">
331+ {sseError}
332+ {#if sseRetryAfter}
333+ <br>
334+ <small>Retrying in {Math.ceil(sseRetryAfter)}s...</small>
335+ {/if}
336+ </div>
337+ {/if}
338+
269339 <!-- Chat content area -->
270340 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
341+ <!-- Add rate limit warning -->
342+ {#if rateLimitWarning}
343+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
344+ <span>{rateLimitWarning}</span>
345+ {#if cooldownEndTime}
346+ <small>You can send another message in {cooldownProgress.toFixed(1)}s</small>
347+ {/if}
348+ </div>
349+ {/if}
350+
271351 <div
272352 class="sunken-panel chat-area"
273353 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
@@ -297,16 +377,19 @@ function handleMaximizeEvent(event: CustomEvent<{
297377 bind:value={currentMessage}
298378 style="flex: 1;"
299379 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
300- placeholder="Type a message..."
301- disabled={!currentUser}
380+ placeholder={cooldownEndTime ? `Wait ${cooldownProgress.toFixed(1)}s...` : "Type a message..."}
381+ disabled={!currentUser || Boolean(cooldownEndTime)}
302382 />
303383 <LoadingButton
304384 onclick={handleSubmit}
305- disabled={!currentUser}
385+ disabled={!currentUser || Boolean(cooldownEndTime)}
306386 loading={isSendingMessage}
307- text="Send"
387+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Send"}
308388 />
309389 </div>
390+ {#if cooldownEndTime}
391+ <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
392+ {/if}
310393 </div>
311394
312395 <!-- Online users list -->
@@ -528,4 +611,84 @@ function handleMaximizeEvent(event: CustomEvent<{
528611 margin-right: 0.5rem;
529612 font-style: italic;
530613 }
614+
615+ .cooldown-progress {
616+ position: absolute;
617+ bottom: 0;
618+ left: 0;
619+ height: 2px;
620+ background: #2d31a6;
621+ transition: width 0.1s linear;
622+ }
623+
624+ .input-container {
625+ position: relative;
626+ }
627+
628+ .connection-error {
629+ font-size: 0.75rem;
630+ color: #ff4444;
631+ margin-left: 0.5rem;
632+ }
633+
634+ .error-banner {
635+ position: absolute;
636+ top: 2rem;
637+ left: 0;
638+ right: 0;
639+ background: #ffebee;
640+ color: #c62828;
641+ padding: 0.5rem;
642+ text-align: center;
643+ z-index: 100;
644+ border-bottom: 1px solid #ffcdd2;
645+ font-size: 0.875rem;
646+ }
647+
648+ .error-banner small {
649+ color: #666;
650+ }
651+
652+ .rate-limit-warning {
653+ background: #fff3e0;
654+ color: #e65100;
655+ padding: 0.5rem;
656+ margin-bottom: 0.5rem;
657+ border-radius: 4px;
658+ font-size: 0.875rem;
659+ display: flex;
660+ flex-direction: column;
661+ gap: 0.25rem;
662+ animation: slideIn 0.3s ease-out;
663+ border: 1px solid #ffe0b2;
664+ }
665+
666+ .rate-limit-warning.with-progress {
667+ border-left: 4px solid #e65100;
668+ }
669+
670+ .rate-limit-warning small {
671+ color: #666;
672+ font-size: 0.75rem;
673+ }
674+
675+ @keyframes slideIn {
676+ from {
677+ transform: translateY(-100%);
678+ opacity: 0;
679+ }
680+ to {
681+ transform: translateY(0);
682+ opacity: 1;
683+ }
684+ }
685+
686+ .cooldown-progress {
687+ position: absolute;
688+ bottom: 0;
689+ left: 0;
690+ height: 2px;
691+ background: #e65100;
692+ transition: width 0.1s linear;
693+ }
531694 </style>⏎̸
@@ -21,6 +21,37 @@ let currentMessage = $state('');
21 let showUserList = $state(false);21 let showUserList = $state(false);
22 let isMaximized = $state(false);22 let isMaximized = $state(false);
23 23
24+// Rate limiting state
25+let cooldownEndTime = $state<number | null>(null);
26+let cooldownProgress = $state(0);
27+let cooldownInterval: ReturnType<typeof setInterval> | null = null;
28+let rateLimitWarning = $state<string | null>(null);
29+
30+function updateCooldownProgress() {
31+ if (!cooldownEndTime) return;
32+
33+ const now = Date.now();
34+ if (now >= cooldownEndTime) {
35+ cooldownEndTime = null;
36+ cooldownProgress = 0;
37+ rateLimitWarning = null;
38+ if (cooldownInterval) {
39+ clearInterval(cooldownInterval);
40+ cooldownInterval = null;
41+ }
42+ return;
43+ }
44+
45+ cooldownProgress = ((cooldownEndTime - now) / 1000);
46+}
47+
48+function startCooldownTimer(retryAfter: number) {
49+ cooldownEndTime = Date.now() + retryAfter;
50+ if (cooldownInterval) clearInterval(cooldownInterval);
51+ cooldownInterval = setInterval(updateCooldownProgress, 100);
52+ updateCooldownProgress();
53+}
54+
24 let { showChatRoom = $bindable() } = $props();55 let { showChatRoom = $bindable() } = $props();
25 56
26 function handleClose() {57 function handleClose() {
@@ -42,13 +73,22 @@ let visibleMessages = $derived((() => {
42 return result;73 return result;
43 })());74 })());
44 75
45-// Single effect to handle all state updates76+// Add SSE error state
77+let sseError = $state<string | null>(null);
78+let sseRetryAfter = $state<number | null>(null);
79+
80+// Update the effect to check for SSE errors
46 $effect(() => {81 $effect(() => {
47 const user = chatState.getCurrentUser();82 const user = chatState.getCurrentUser();
48 if (user?.id !== currentUser?.id) {83 if (user?.id !== currentUser?.id) {
49 currentUser = user;84 currentUser = user;
50 }85 }
51 86
87+ // Check for SSE errors
88+ const { error, retryAfter } = chatState.getSSEError();
89+ sseError = error;
90+ sseRetryAfter = retryAfter;
91+
52 // Only update messages if we have new ones92 // Only update messages if we have new ones
53 const stateMessages = chatState.getMessages();93 const stateMessages = chatState.getMessages();
54 if (stateMessages.length !== messages.length) {94 if (stateMessages.length !== messages.length) {
@@ -132,21 +172,35 @@ onMount(() => {
132 // Add resize listener172 // Add resize listener
133 window.addEventListener('resize', handleResize);173 window.addEventListener('resize', handleResize);
134 174
135- return () => window.removeEventListener('resize', handleResize);175+ return () => {
176+ window.removeEventListener('resize', handleResize);
177+ if (cooldownInterval) clearInterval(cooldownInterval);
178+ };
136 });179 });
137 180
138 // New reactive state to track when a message is being sent181 // New reactive state to track when a message is being sent
139 let isSendingMessage = $state(false);182 let isSendingMessage = $state(false);
140 183
141 async function handleSubmit() {184 async function handleSubmit() {
142- if (!currentMessage.trim()) return;185+ if (!currentMessage.trim() || !currentUser || cooldownEndTime) return;
186+
143 isSendingMessage = true;187 isSendingMessage = true;
144 try {188 try {
145- await chatState.sendMessage(currentMessage);189+ const response = await chatState.sendMessage(currentMessage);
146- currentMessage = '';190+ if (!response.success && response.isRateLimited && response.retryAfter) {
191+ startCooldownTimer(response.retryAfter);
192+ rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...';
193+ } else if (!response.success) {
194+ rateLimitWarning = response.error || 'Failed to send message. Please try again.';
195+ setTimeout(() => rateLimitWarning = null, 3000);
196+ } else {
197+ currentMessage = '';
198+ rateLimitWarning = null;
199+ }
147 } catch (error) {200 } catch (error) {
148 console.error('Failed to send message:', error);201 console.error('Failed to send message:', error);
149- alert('Failed to send message. Please try again.');202+ rateLimitWarning = 'Failed to send message. Please try again.';
203+ setTimeout(() => rateLimitWarning = null, 3000);
150 } finally {204 } finally {
151 isSendingMessage = false;205 isSendingMessage = false;
152 }206 }
@@ -246,7 +300,12 @@ function handleMaximizeEvent(event: CustomEvent<{
246 ondragmove={handleDragMove}300 ondragmove={handleDragMove}
247 >301 >
248 <div class="title-bar">302 <div class="title-bar">
249- <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div>303+ <div class="title-bar-text">
304+ Pdr Aim - {currentUser?.nickname}
305+ {#if sseError}
306+ <span class="connection-error">⚠️ Connection Error</span>
307+ {/if}
308+ </div>
250 <div class="title-bar-controls">309 <div class="title-bar-controls">
251 {#if !isMobile}310 {#if !isMobile}
252 <button aria-label="Minimize"></button>311 <button aria-label="Minimize"></button>
@@ -266,8 +325,29 @@ function handleMaximizeEvent(event: CustomEvent<{
266 </div>325 </div>
267 326
268 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">327 <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;">
328+ <!-- Add error message display -->
329+ {#if sseError}
330+ <div class="error-banner">
331+ {sseError}
332+ {#if sseRetryAfter}
333+ <br>
334+ <small>Retrying in {Math.ceil(sseRetryAfter)}s...</small>
335+ {/if}
336+ </div>
337+ {/if}
338+
269 <!-- Chat content area -->339 <!-- Chat content area -->
270 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">340 <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;">
341+ <!-- Add rate limit warning -->
342+ {#if rateLimitWarning}
343+ <div class="rate-limit-warning" class:with-progress={cooldownEndTime}>
344+ <span>{rateLimitWarning}</span>
345+ {#if cooldownEndTime}
346+ <small>You can send another message in {cooldownProgress.toFixed(1)}s</small>
347+ {/if}
348+ </div>
349+ {/if}
350+
271 <div 351 <div
272 class="sunken-panel chat-area"352 class="sunken-panel chat-area"
273 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"353 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
@@ -297,16 +377,19 @@ function handleMaximizeEvent(event: CustomEvent<{
297 bind:value={currentMessage}377 bind:value={currentMessage}
298 style="flex: 1;"378 style="flex: 1;"
299 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}379 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
300- placeholder="Type a message..."380+ placeholder={cooldownEndTime ? `Wait ${cooldownProgress.toFixed(1)}s...` : "Type a message..."}
301- disabled={!currentUser}381+ disabled={!currentUser || Boolean(cooldownEndTime)}
302 />382 />
303 <LoadingButton 383 <LoadingButton
304 onclick={handleSubmit} 384 onclick={handleSubmit}
305- disabled={!currentUser} 385+ disabled={!currentUser || Boolean(cooldownEndTime)}
306 loading={isSendingMessage}386 loading={isSendingMessage}
307- text="Send"387+ text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Send"}
308 />388 />
309 </div>389 </div>
390+ {#if cooldownEndTime}
391+ <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div>
392+ {/if}
310 </div>393 </div>
311 394
312 <!-- Online users list -->395 <!-- Online users list -->
@@ -528,4 +611,84 @@ function handleMaximizeEvent(event: CustomEvent<{
528 margin-right: 0.5rem;611 margin-right: 0.5rem;
529 font-style: italic;612 font-style: italic;
530 }613 }
614+
615+ .cooldown-progress {
616+ position: absolute;
617+ bottom: 0;
618+ left: 0;
619+ height: 2px;
620+ background: #2d31a6;
621+ transition: width 0.1s linear;
622+ }
623+
624+ .input-container {
625+ position: relative;
626+ }
627+
628+ .connection-error {
629+ font-size: 0.75rem;
630+ color: #ff4444;
631+ margin-left: 0.5rem;
632+ }
633+
634+ .error-banner {
635+ position: absolute;
636+ top: 2rem;
637+ left: 0;
638+ right: 0;
639+ background: #ffebee;
640+ color: #c62828;
641+ padding: 0.5rem;
642+ text-align: center;
643+ z-index: 100;
644+ border-bottom: 1px solid #ffcdd2;
645+ font-size: 0.875rem;
646+ }
647+
648+ .error-banner small {
649+ color: #666;
650+ }
651+
652+ .rate-limit-warning {
653+ background: #fff3e0;
654+ color: #e65100;
655+ padding: 0.5rem;
656+ margin-bottom: 0.5rem;
657+ border-radius: 4px;
658+ font-size: 0.875rem;
659+ display: flex;
660+ flex-direction: column;
661+ gap: 0.25rem;
662+ animation: slideIn 0.3s ease-out;
663+ border: 1px solid #ffe0b2;
664+ }
665+
666+ .rate-limit-warning.with-progress {
667+ border-left: 4px solid #e65100;
668+ }
669+
670+ .rate-limit-warning small {
671+ color: #666;
672+ font-size: 0.75rem;
673+ }
674+
675+ @keyframes slideIn {
676+ from {
677+ transform: translateY(-100%);
678+ opacity: 0;
679+ }
680+ to {
681+ transform: translateY(0);
682+ opacity: 1;
683+ }
684+ }
685+
686+ .cooldown-progress {
687+ position: absolute;
688+ bottom: 0;
689+ left: 0;
690+ height: 2px;
691+ background: #e65100;
692+ transition: width 0.1s linear;
693+ }
531 </style>⏎̸694 </style>⏎̸
M src/lib/states/chat.svelte.ts +45 -4
@@ -19,6 +19,8 @@ class ChatState {
1919 private isInitializing = $state(false);
2020 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
2121 private userCache = $state<Record<string, User>>({});
22+ private sseError = $state<string | null>(null);
23+ private sseRetryAfter = $state<number | null>(null);
2224
2325 public async reinitialize() {
2426 console.debug('Reinitializing chat state...');
@@ -300,11 +302,14 @@ class ChatState {
300302 }
301303 }
302304
303- async sendMessage(content: string, type: Message['type'] = 'chat') {
305+ async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
304306 const user = this.getCurrentUser();
305307 if (!user) {
306308 console.debug('Cannot send message: No current user');
307- return;
309+ return {
310+ success: false,
311+ error: 'Not logged in'
312+ };
308313 }
309314
310315 try {
@@ -338,9 +343,13 @@ class ChatState {
338343
339344 console.debug('Message sent successfully:', data.message);
340345 await invalidate('chat:messages');
346+ return data;
341347 } catch (error) {
342348 console.debug('Error sending message:', error);
343- throw error;
349+ return {
350+ success: false,
351+ error: 'Failed to send message'
352+ };
344353 }
345354 }
346355
@@ -410,20 +419,45 @@ class ChatState {
410419 };
411420 }
412421
413- private handleSSEError() {
422+ private async handleSSEError() {
414423 if (this.eventSource) {
415424 this.eventSource.close();
416425 this.eventSource = null;
417426 }
418427
428+ // Check if the error was due to rate limiting
429+ try {
430+ const response = await fetch('/api/sse');
431+ if (response.status === 429) {
432+ const data = await response.json();
433+ this.sseError = data.error;
434+ this.sseRetryAfter = data.retryAfter;
435+
436+ // Set up automatic retry after the rate limit expires
437+ setTimeout(() => {
438+ this.sseError = null;
439+ this.sseRetryAfter = null;
440+ this.reconnectSSE();
441+ }, data.retryAfter * 1000);
442+
443+ return;
444+ }
445+ } catch (error) {
446+ console.debug('Error checking SSE status:', error);
447+ }
448+
449+ // Handle other types of errors with exponential backoff
419450 if (this.reconnectAttempts < this.maxReconnectAttempts) {
451+ this.sseError = `Connection lost. Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`;
420452 console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
453+
421454 setTimeout(() => {
422455 this.reconnectAttempts++;
423456 this.reconnectDelay *= 2; // Exponential backoff
424457 this.connectSSE();
425458 }, this.reconnectDelay);
426459 } else {
460+ this.sseError = 'Unable to connect to chat. Please refresh the page to try again.';
427461 console.debug('Max reconnection attempts reached');
428462 }
429463 }
@@ -431,6 +465,8 @@ class ChatState {
431465 private reconnectSSE() {
432466 this.reconnectAttempts = 0;
433467 this.reconnectDelay = 1000;
468+ this.sseError = null;
469+ this.sseRetryAfter = null;
434470 this.connectSSE();
435471 }
436472
@@ -455,6 +491,11 @@ class ChatState {
455491 }
456492 };
457493 }
494+
495+ // Add getter for SSE error state
496+ public getSSEError() {
497+ return { error: this.sseError, retryAfter: this.sseRetryAfter };
498+ }
458499 }
459500
460501 // Export a singleton instance
@@ -19,6 +19,8 @@ class ChatState {
19 private isInitializing = $state(false);19 private isInitializing = $state(false);
20 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);20 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
21 private userCache = $state<Record<string, User>>({});21 private userCache = $state<Record<string, User>>({});
22+ private sseError = $state<string | null>(null);
23+ private sseRetryAfter = $state<number | null>(null);
22 24
23 public async reinitialize() {25 public async reinitialize() {
24 console.debug('Reinitializing chat state...');26 console.debug('Reinitializing chat state...');
@@ -300,11 +302,14 @@ class ChatState {
300 }302 }
301 }303 }
302 304
303- async sendMessage(content: string, type: Message['type'] = 'chat') {305+ async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
304 const user = this.getCurrentUser();306 const user = this.getCurrentUser();
305 if (!user) {307 if (!user) {
306 console.debug('Cannot send message: No current user');308 console.debug('Cannot send message: No current user');
307- return;309+ return {
310+ success: false,
311+ error: 'Not logged in'
312+ };
308 }313 }
309 314
310 try {315 try {
@@ -338,9 +343,13 @@ class ChatState {
338 343
339 console.debug('Message sent successfully:', data.message);344 console.debug('Message sent successfully:', data.message);
340 await invalidate('chat:messages');345 await invalidate('chat:messages');
346+ return data;
341 } catch (error) {347 } catch (error) {
342 console.debug('Error sending message:', error);348 console.debug('Error sending message:', error);
343- throw error;349+ return {
350+ success: false,
351+ error: 'Failed to send message'
352+ };
344 }353 }
345 }354 }
346 355
@@ -410,20 +419,45 @@ class ChatState {
410 };419 };
411 }420 }
412 421
413- private handleSSEError() {422+ private async handleSSEError() {
414 if (this.eventSource) {423 if (this.eventSource) {
415 this.eventSource.close();424 this.eventSource.close();
416 this.eventSource = null;425 this.eventSource = null;
417 }426 }
418 427
428+ // Check if the error was due to rate limiting
429+ try {
430+ const response = await fetch('/api/sse');
431+ if (response.status === 429) {
432+ const data = await response.json();
433+ this.sseError = data.error;
434+ this.sseRetryAfter = data.retryAfter;
435+
436+ // Set up automatic retry after the rate limit expires
437+ setTimeout(() => {
438+ this.sseError = null;
439+ this.sseRetryAfter = null;
440+ this.reconnectSSE();
441+ }, data.retryAfter * 1000);
442+
443+ return;
444+ }
445+ } catch (error) {
446+ console.debug('Error checking SSE status:', error);
447+ }
448+
449+ // Handle other types of errors with exponential backoff
419 if (this.reconnectAttempts < this.maxReconnectAttempts) {450 if (this.reconnectAttempts < this.maxReconnectAttempts) {
451+ this.sseError = `Connection lost. Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`;
420 console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);452 console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
453+
421 setTimeout(() => {454 setTimeout(() => {
422 this.reconnectAttempts++;455 this.reconnectAttempts++;
423 this.reconnectDelay *= 2; // Exponential backoff456 this.reconnectDelay *= 2; // Exponential backoff
424 this.connectSSE();457 this.connectSSE();
425 }, this.reconnectDelay);458 }, this.reconnectDelay);
426 } else {459 } else {
460+ this.sseError = 'Unable to connect to chat. Please refresh the page to try again.';
427 console.debug('Max reconnection attempts reached');461 console.debug('Max reconnection attempts reached');
428 }462 }
429 }463 }
@@ -431,6 +465,8 @@ class ChatState {
431 private reconnectSSE() {465 private reconnectSSE() {
432 this.reconnectAttempts = 0;466 this.reconnectAttempts = 0;
433 this.reconnectDelay = 1000;467 this.reconnectDelay = 1000;
468+ this.sseError = null;
469+ this.sseRetryAfter = null;
434 this.connectSSE();470 this.connectSSE();
435 }471 }
436 472
@@ -455,6 +491,11 @@ class ChatState {
455 }491 }
456 };492 };
457 }493 }
494+
495+ // Add getter for SSE error state
496+ public getSSEError() {
497+ return { error: this.sseError, retryAfter: this.sseRetryAfter };
498+ }
458 }499 }
459 500
460 // Export a singleton instance501 // Export a singleton instance
M src/lib/types/payloads.ts +2 -0
@@ -54,6 +54,8 @@ export interface SendMessageResponseSuccess {
5454 export interface SendMessageResponseError {
5555 success: false;
5656 error: string;
57+ retryAfter?: number; // Time in ms until next allowed message
58+ isRateLimited?: boolean;
5759 }
5860
5961 export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;
@@ -54,6 +54,8 @@ export interface SendMessageResponseSuccess {
54 export interface SendMessageResponseError {54 export interface SendMessageResponseError {
55 success: false;55 success: false;
56 error: string;56 error: string;
57+ retryAfter?: number; // Time in ms until next allowed message
58+ isRateLimited?: boolean;
57 }59 }
58 60
59 export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;61 export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;
M src/routes/api/chat/messages/+server.ts +51 -0
@@ -15,6 +15,38 @@ import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { ensureDefaultChatRoom } from '$lib/db/schema';
1717
18+// Rate limiting configuration
19+const INITIAL_COOLDOWN = 1000; // 1 second
20+const MAX_COOLDOWN = 30000; // 30 seconds
21+const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>();
22+
23+function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
24+ const now = Date.now();
25+ const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN };
26+
27+ // Check if enough time has passed since the last message
28+ const timeSinceLastMessage = now - userState.lastMessageTime;
29+ if (timeSinceLastMessage < userState.currentCooldown) {
30+ return {
31+ canSend: false,
32+ retryAfter: userState.currentCooldown - timeSinceLastMessage
33+ };
34+ }
35+
36+ // Reset cooldown if it's been long enough
37+ if (timeSinceLastMessage > userState.currentCooldown * 2) {
38+ userState.currentCooldown = INITIAL_COOLDOWN;
39+ } else {
40+ // Exponential backoff
41+ userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
42+ }
43+
44+ userState.lastMessageTime = now;
45+ userMessageTimestamps.set(userId, userState);
46+
47+ return { canSend: true };
48+}
49+
1850 // GET endpoint: fetch messages from the DB
1951 export async function GET({ request, locals }) {
2052 const isPublic = new URL(request.url).searchParams.get('public') === 'true';
@@ -75,6 +107,25 @@ export async function POST({ request, locals }: { request: Request, locals: App.
75107
76108 console.debug('Received new message POST');
77109 try {
110+ // Check rate limiting
111+ const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
112+ if (!canSend) {
113+ console.debug('Rate limited:', { userId: locals.user.id, retryAfter });
114+ const errorResponse: SendMessageResponse = {
115+ success: false,
116+ error: 'Please wait before sending another message',
117+ retryAfter,
118+ isRateLimited: true
119+ };
120+ return new Response(JSON.stringify(errorResponse), {
121+ status: 429,
122+ headers: {
123+ 'Content-Type': 'application/json',
124+ 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
125+ }
126+ });
127+ }
128+
78129 // Ensure default chat room exists
79130 await ensureDefaultChatRoom(db);
80131
@@ -15,6 +15,38 @@ import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema';
15 import { users } from '$lib/db/schema';15 import { users } from '$lib/db/schema';
16 import { ensureDefaultChatRoom } from '$lib/db/schema';16 import { ensureDefaultChatRoom } from '$lib/db/schema';
17 17
18+// Rate limiting configuration
19+const INITIAL_COOLDOWN = 1000; // 1 second
20+const MAX_COOLDOWN = 30000; // 30 seconds
21+const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>();
22+
23+function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
24+ const now = Date.now();
25+ const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN };
26+
27+ // Check if enough time has passed since the last message
28+ const timeSinceLastMessage = now - userState.lastMessageTime;
29+ if (timeSinceLastMessage < userState.currentCooldown) {
30+ return {
31+ canSend: false,
32+ retryAfter: userState.currentCooldown - timeSinceLastMessage
33+ };
34+ }
35+
36+ // Reset cooldown if it's been long enough
37+ if (timeSinceLastMessage > userState.currentCooldown * 2) {
38+ userState.currentCooldown = INITIAL_COOLDOWN;
39+ } else {
40+ // Exponential backoff
41+ userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
42+ }
43+
44+ userState.lastMessageTime = now;
45+ userMessageTimestamps.set(userId, userState);
46+
47+ return { canSend: true };
48+}
49+
18 // GET endpoint: fetch messages from the DB50 // GET endpoint: fetch messages from the DB
19 export async function GET({ request, locals }) {51 export async function GET({ request, locals }) {
20 const isPublic = new URL(request.url).searchParams.get('public') === 'true';52 const isPublic = new URL(request.url).searchParams.get('public') === 'true';
@@ -75,6 +107,25 @@ export async function POST({ request, locals }: { request: Request, locals: App.
75 107
76 console.debug('Received new message POST');108 console.debug('Received new message POST');
77 try {109 try {
110+ // Check rate limiting
111+ const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
112+ if (!canSend) {
113+ console.debug('Rate limited:', { userId: locals.user.id, retryAfter });
114+ const errorResponse: SendMessageResponse = {
115+ success: false,
116+ error: 'Please wait before sending another message',
117+ retryAfter,
118+ isRateLimited: true
119+ };
120+ return new Response(JSON.stringify(errorResponse), {
121+ status: 429,
122+ headers: {
123+ 'Content-Type': 'application/json',
124+ 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
125+ }
126+ });
127+ }
128+
78 // Ensure default chat room exists129 // Ensure default chat room exists
79 await ensureDefaultChatRoom(db);130 await ensureDefaultChatRoom(db);
80 131

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