detached 6aaf2c1

Merge pull request #76 from kitpdr/fix/online-status-timeout

fix(status): compute online status from lastSeen timeout + cleanup unused endpoints
commit
6aaf2c16d270730d38b5b30e33c1fa47c713a5c6
author
KiT · · 2026-01-18 22:30 UTC
committer
GitHub · · 2026-01-18 22:30 UTC
merge of
de99bb9 + 547efab · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +37 -319
M src/convex/queries.ts +27 -6
@@ -2,6 +2,27 @@ import { query } from './_generated/server';
22 import { authQuery } from './auth';
33 import { v } from 'convex/values';
44
5+// Timeout threshold for marking users as offline (2 minutes)
6+const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
7+
8+// Helper to compute effective status based on lastSeen timeout.
9+// Returns 'online', 'away', 'busy', 'idle', or 'offline'.
10+// Note: This is a read-time computation - the database stores the last known status
11+// and lastSeen timestamp. The client heartbeat (via /api/status) updates these values.
12+// This function computes what to display when a user stops sending heartbeats.
13+function computeEffectiveStatus(
14+ status: string,
15+ lastSeen: number | undefined
16+): 'online' | 'away' | 'busy' | 'idle' | 'offline' {
17+ if (status === 'offline') return 'offline';
18+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
19+ const effectiveLastSeen = lastSeen ?? 0;
20+ if (effectiveLastSeen < timeoutThreshold) {
21+ return 'offline';
22+ }
23+ return status as 'online' | 'away' | 'busy' | 'idle';
24+}
25+
526 // ============ PUBLIC QUERIES (no auth required) ============
627
728 // Public query: Get messages for a chat room (real-time subscription)
@@ -36,7 +57,7 @@ export const getMessagesPublic = query({
3657 ? {
3758 id: sender._id,
3859 nickname: sender.nickname,
39- status: sender.status,
60+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
4061 avatarUrl: sender.avatarUrl
4162 }
4263 : null
@@ -55,11 +76,11 @@ export const getUsersPublic = query({
5576 handler: async (ctx) => {
5677 const users = await ctx.db.query('users').collect();
5778
58- // Return safe user data (no passwords)
79+ // Return safe user data (no passwords) with computed status
5980 return users.map((user) => ({
6081 id: user._id,
6182 nickname: user.nickname,
62- status: user.status,
83+ status: computeEffectiveStatus(user.status, user.lastSeen),
6384 avatarUrl: user.avatarUrl,
6485 lastSeen: user.lastSeen
6586 }));
@@ -131,7 +152,7 @@ export const getMessages = authQuery({
131152 ? {
132153 id: sender._id,
133154 nickname: sender.nickname,
134- status: sender.status,
155+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
135156 avatarUrl: sender.avatarUrl
136157 }
137158 : null
@@ -150,11 +171,11 @@ export const getUsers = authQuery({
150171 handler: async (ctx) => {
151172 const users = await ctx.db.query('users').collect();
152173
153- // Return safe user data (no passwords)
174+ // Return safe user data (no passwords) with computed status
154175 return users.map((user) => ({
155176 id: user._id,
156177 nickname: user.nickname,
157- status: user.status,
178+ status: computeEffectiveStatus(user.status, user.lastSeen),
158179 avatarUrl: user.avatarUrl,
159180 lastSeen: user.lastSeen
160181 }));
@@ -2,6 +2,27 @@ import { query } from './_generated/server';
2 import { authQuery } from './auth';2 import { authQuery } from './auth';
3 import { v } from 'convex/values';3 import { v } from 'convex/values';
4 4
5+// Timeout threshold for marking users as offline (2 minutes)
6+const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
7+
8+// Helper to compute effective status based on lastSeen timeout.
9+// Returns 'online', 'away', 'busy', 'idle', or 'offline'.
10+// Note: This is a read-time computation - the database stores the last known status
11+// and lastSeen timestamp. The client heartbeat (via /api/status) updates these values.
12+// This function computes what to display when a user stops sending heartbeats.
13+function computeEffectiveStatus(
14+ status: string,
15+ lastSeen: number | undefined
16+): 'online' | 'away' | 'busy' | 'idle' | 'offline' {
17+ if (status === 'offline') return 'offline';
18+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
19+ const effectiveLastSeen = lastSeen ?? 0;
20+ if (effectiveLastSeen < timeoutThreshold) {
21+ return 'offline';
22+ }
23+ return status as 'online' | 'away' | 'busy' | 'idle';
24+}
25+
5 // ============ PUBLIC QUERIES (no auth required) ============26 // ============ PUBLIC QUERIES (no auth required) ============
6 27
7 // Public query: Get messages for a chat room (real-time subscription)28 // Public query: Get messages for a chat room (real-time subscription)
@@ -36,7 +57,7 @@ export const getMessagesPublic = query({
36 ? {57 ? {
37 id: sender._id,58 id: sender._id,
38 nickname: sender.nickname,59 nickname: sender.nickname,
39- status: sender.status,60+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
40 avatarUrl: sender.avatarUrl61 avatarUrl: sender.avatarUrl
41 }62 }
42 : null63 : null
@@ -55,11 +76,11 @@ export const getUsersPublic = query({
55 handler: async (ctx) => {76 handler: async (ctx) => {
56 const users = await ctx.db.query('users').collect();77 const users = await ctx.db.query('users').collect();
57 78
58- // Return safe user data (no passwords)79+ // Return safe user data (no passwords) with computed status
59 return users.map((user) => ({80 return users.map((user) => ({
60 id: user._id,81 id: user._id,
61 nickname: user.nickname,82 nickname: user.nickname,
62- status: user.status,83+ status: computeEffectiveStatus(user.status, user.lastSeen),
63 avatarUrl: user.avatarUrl,84 avatarUrl: user.avatarUrl,
64 lastSeen: user.lastSeen85 lastSeen: user.lastSeen
65 }));86 }));
@@ -131,7 +152,7 @@ export const getMessages = authQuery({
131 ? {152 ? {
132 id: sender._id,153 id: sender._id,
133 nickname: sender.nickname,154 nickname: sender.nickname,
134- status: sender.status,155+ status: computeEffectiveStatus(sender.status, sender.lastSeen),
135 avatarUrl: sender.avatarUrl156 avatarUrl: sender.avatarUrl
136 }157 }
137 : null158 : null
@@ -150,11 +171,11 @@ export const getUsers = authQuery({
150 handler: async (ctx) => {171 handler: async (ctx) => {
151 const users = await ctx.db.query('users').collect();172 const users = await ctx.db.query('users').collect();
152 173
153- // Return safe user data (no passwords)174+ // Return safe user data (no passwords) with computed status
154 return users.map((user) => ({175 return users.map((user) => ({
155 id: user._id,176 id: user._id,
156 nickname: user.nickname,177 nickname: user.nickname,
157- status: user.status,178+ status: computeEffectiveStatus(user.status, user.lastSeen),
158 avatarUrl: user.avatarUrl,179 avatarUrl: user.avatarUrl,
159 lastSeen: user.lastSeen180 lastSeen: user.lastSeen
160 }));181 }));
M src/hooks.server.ts +3 -11
@@ -123,13 +123,11 @@ export const handle: Handle = async ({ event, resolve }) => {
123123 }
124124 }
125125
126- // Identify public chat and room requests if there is no valid session
126+ // Identify public chat requests if there is no valid session
127127 const isPublicChatRequest =
128128 event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';
129- const isPublicRoomRequest =
130- event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET';
131129
132- if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
130+ if (isPublicChatRequest && !event.locals.session) {
133131 log.debug('Public request accessed', { path: event.url.pathname });
134132 const response = await resolve(event);
135133 return addSecurityHeaders(response);
@@ -152,14 +150,8 @@ export const handle: Handle = async ({ event, resolve }) => {
152150 return addSecurityHeaders(response);
153151 }
154152
155- // Skip rate limiting for SSE endpoint when authenticated
156- if (event.url.pathname.startsWith('/api/sse')) {
157- const response = await resolve(event);
158- return addSecurityHeaders(response);
159- }
160-
161153 // Apply rate limiting for non-public endpoints
162- if (!isPublicChatRequest && !isPublicRoomRequest) {
154+ if (!isPublicChatRequest) {
163155 const rateLimitResult = await handleRateLimit(event);
164156 if (rateLimitResult?.status === 429) {
165157 return new Response('Too Many Requests', {
@@ -123,13 +123,11 @@ export const handle: Handle = async ({ event, resolve }) => {
123 }123 }
124 }124 }
125 125
126- // Identify public chat and room requests if there is no valid session126+ // Identify public chat requests if there is no valid session
127 const isPublicChatRequest =127 const isPublicChatRequest =
128 event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';128 event.url.pathname.startsWith('/api/chat/messages') && event.request.method === 'GET';
129- const isPublicRoomRequest =
130- event.url.pathname.startsWith('/api/rooms/') && event.request.method === 'GET';
131 129
132- if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {130+ if (isPublicChatRequest && !event.locals.session) {
133 log.debug('Public request accessed', { path: event.url.pathname });131 log.debug('Public request accessed', { path: event.url.pathname });
134 const response = await resolve(event);132 const response = await resolve(event);
135 return addSecurityHeaders(response);133 return addSecurityHeaders(response);
@@ -152,14 +150,8 @@ export const handle: Handle = async ({ event, resolve }) => {
152 return addSecurityHeaders(response);150 return addSecurityHeaders(response);
153 }151 }
154 152
155- // Skip rate limiting for SSE endpoint when authenticated
156- if (event.url.pathname.startsWith('/api/sse')) {
157- const response = await resolve(event);
158- return addSecurityHeaders(response);
159- }
160-
161 // Apply rate limiting for non-public endpoints153 // Apply rate limiting for non-public endpoints
162- if (!isPublicChatRequest && !isPublicRoomRequest) {154+ if (!isPublicChatRequest) {
163 const rateLimitResult = await handleRateLimit(event);155 const rateLimitResult = await handleRateLimit(event);
164 if (rateLimitResult?.status === 429) {156 if (rateLimitResult?.status === 429) {
165 return new Response('Too Many Requests', {157 return new Response('Too Many Requests', {
M src/lib/api/rate-limiter.ts +7 -80
@@ -13,7 +13,7 @@ const RATE_LIMITS = {
1313 durationMs: 5 * 60 * 1000
1414 },
1515
16- // Public endpoints (public chat, rooms)
16+ // Public endpoints (public chat)
1717 public: {
1818 points: isDev ? 1000 : 30,
1919 durationMs: 60 * 1000
@@ -23,18 +23,6 @@ const RATE_LIMITS = {
2323 protected: {
2424 points: isDev ? 1000 : 100,
2525 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- }
3826 }
3927 } as const;
4028
@@ -45,9 +33,7 @@ const ipRequests = new Map<string, Array<{ timestamp: number }>>();
4533 const MAX_RATE_LIMIT_DURATION = Math.max(
4634 RATE_LIMITS.auth.durationMs,
4735 RATE_LIMITS.public.durationMs,
48- RATE_LIMITS.protected.durationMs,
49- RATE_LIMITS.sse.authenticated.durationMs,
50- RATE_LIMITS.sse.unauthenticated.durationMs
36+ RATE_LIMITS.protected.durationMs
5137 );
5238
5339 // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope)
@@ -73,31 +59,15 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
7359 if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
7460 return 'auth';
7561 }
76- if (pathname.startsWith('/api/sse')) {
77- return 'sse';
78- }
7962 if (isPublic) {
8063 return 'public';
8164 }
8265 return 'protected';
8366 }
8467
85-function getRateLimitPoints(
86- endpointType: keyof typeof RATE_LIMITS,
87- isAuthenticated: boolean
88-): number {
89- if (endpointType === 'sse') {
90- return isAuthenticated
91- ? RATE_LIMITS.sse.authenticated.points
92- : RATE_LIMITS.sse.unauthenticated.points;
93- }
94- return RATE_LIMITS[endpointType].points;
95-}
96-
9768 function isRateLimited(
9869 ip: string,
99- endpointType: keyof typeof RATE_LIMITS,
100- isAuthenticated: boolean = false
70+ endpointType: keyof typeof RATE_LIMITS
10171 ): { limited: boolean; retryAfter?: number } {
10272 // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility)
10373 cleanupStaleEntries();
@@ -108,38 +78,6 @@ function isRateLimited(
10878 }
10979
11080 const now = Date.now();
111-
112- // Handle SSE endpoints differently based on authentication status
113- if (endpointType === 'sse') {
114- const config = isAuthenticated
115- ? RATE_LIMITS.sse.authenticated
116- : RATE_LIMITS.sse.unauthenticated;
117- const { points, durationMs } = config;
118-
119- // Get or initialize request history for this IP
120- const requests = ipRequests.get(ip) || [];
121-
122- // Clean old requests outside the window
123- const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
124-
125- // Check if we're over the limit
126- if (validRequests.length >= points) {
127- log.debug(
128- `Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`
129- );
130- const oldestRequest = validRequests[0];
131- const retryAfter = durationMs - (now - oldestRequest.timestamp);
132- return { limited: true, retryAfter };
133- }
134-
135- // Add this request
136- validRequests.push({ timestamp: now });
137- ipRequests.set(ip, validRequests);
138-
139- return { limited: false };
140- }
141-
142- // Handle other endpoints
14381 const { points, durationMs } = RATE_LIMITS[endpointType];
14482 const requests = ipRequests.get(ip) || [];
14583 const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
@@ -164,7 +102,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
164102 const ip = event.getClientAddress();
165103 const url = new URL(event.request.url);
166104 const isPublic = url.searchParams.get('public') === 'true';
167- const isAuthenticated = Boolean(event.locals.user);
168105
169106 // Skip rate limiting for non-API routes
170107 if (!url.pathname.startsWith('/api')) {
@@ -172,16 +109,12 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172109 }
173110
174111 const endpointType = getEndpointType(url.pathname, isPublic);
175- const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
112+ const { limited, retryAfter } = isRateLimited(ip, endpointType);
176113
177114 if (limited) {
178115 // Construct a user-friendly error message
179116 let errorMessage = 'Too many requests. ';
180- if (endpointType === 'sse') {
181- errorMessage += isAuthenticated
182- ? 'Please wait before attempting to reconnect to the chat.'
183- : 'Please wait before attempting to view the public chat again.';
184- } else if (endpointType === 'auth') {
117+ if (endpointType === 'auth') {
185118 errorMessage += 'Please wait before attempting to log in again.';
186119 } else {
187120 errorMessage += 'Please try again later.';
@@ -196,13 +129,11 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
196129 // Only include potentially sensitive info in dev mode
197130 if (isDev) {
198131 responseBody.endpointType = endpointType;
199- responseBody.isAuthenticated = isAuthenticated;
200132 responseBody.debug = {
201- isAuthenticated,
202133 hasUser: Boolean(event.locals.user),
203134 hasSession: Boolean(event.locals.session),
204135 endpointType,
205- rateLimitPoints: getRateLimitPoints(endpointType, isAuthenticated)
136+ rateLimitPoints: RATE_LIMITS[endpointType].points
206137 };
207138 }
208139
@@ -213,14 +144,10 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
213144 };
214145
215146 if (isDev) {
216- headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
217147 headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
218148 headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
219149 headers['X-Debug-Endpoint-Type'] = endpointType;
220- headers['X-Debug-Rate-Limit-Points'] = getRateLimitPoints(
221- endpointType,
222- isAuthenticated
223- ).toString();
150+ headers['X-Debug-Rate-Limit-Points'] = RATE_LIMITS[endpointType].points.toString();
224151 }
225152
226153 return new Response(JSON.stringify(responseBody), {
@@ -13,7 +13,7 @@ const RATE_LIMITS = {
13 durationMs: 5 * 60 * 100013 durationMs: 5 * 60 * 1000
14 },14 },
15 15
16- // Public endpoints (public chat, rooms)16+ // Public endpoints (public chat)
17 public: {17 public: {
18 points: isDev ? 1000 : 30,18 points: isDev ? 1000 : 30,
19 durationMs: 60 * 100019 durationMs: 60 * 1000
@@ -23,18 +23,6 @@ const RATE_LIMITS = {
23 protected: {23 protected: {
24 points: isDev ? 1000 : 100,24 points: isDev ? 1000 : 100,
25 durationMs: 60 * 100025 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 }26 }
39 } as const;27 } as const;
40 28
@@ -45,9 +33,7 @@ const ipRequests = new Map<string, Array<{ timestamp: number }>>();
45 const MAX_RATE_LIMIT_DURATION = Math.max(33 const MAX_RATE_LIMIT_DURATION = Math.max(
46 RATE_LIMITS.auth.durationMs,34 RATE_LIMITS.auth.durationMs,
47 RATE_LIMITS.public.durationMs,35 RATE_LIMITS.public.durationMs,
48- RATE_LIMITS.protected.durationMs,36+ RATE_LIMITS.protected.durationMs
49- RATE_LIMITS.sse.authenticated.durationMs,
50- RATE_LIMITS.sse.unauthenticated.durationMs
51 );37 );
52 38
53 // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope)39 // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope)
@@ -73,31 +59,15 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
73 if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {59 if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) {
74 return 'auth';60 return 'auth';
75 }61 }
76- if (pathname.startsWith('/api/sse')) {
77- return 'sse';
78- }
79 if (isPublic) {62 if (isPublic) {
80 return 'public';63 return 'public';
81 }64 }
82 return 'protected';65 return 'protected';
83 }66 }
84 67
85-function getRateLimitPoints(
86- endpointType: keyof typeof RATE_LIMITS,
87- isAuthenticated: boolean
88-): number {
89- if (endpointType === 'sse') {
90- return isAuthenticated
91- ? RATE_LIMITS.sse.authenticated.points
92- : RATE_LIMITS.sse.unauthenticated.points;
93- }
94- return RATE_LIMITS[endpointType].points;
95-}
96-
97 function isRateLimited(68 function isRateLimited(
98 ip: string,69 ip: string,
99- endpointType: keyof typeof RATE_LIMITS,70+ endpointType: keyof typeof RATE_LIMITS
100- isAuthenticated: boolean = false
101 ): { limited: boolean; retryAfter?: number } {71 ): { limited: boolean; retryAfter?: number } {
102 // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility)72 // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility)
103 cleanupStaleEntries();73 cleanupStaleEntries();
@@ -108,38 +78,6 @@ function isRateLimited(
108 }78 }
109 79
110 const now = Date.now();80 const now = Date.now();
111-
112- // Handle SSE endpoints differently based on authentication status
113- if (endpointType === 'sse') {
114- const config = isAuthenticated
115- ? RATE_LIMITS.sse.authenticated
116- : RATE_LIMITS.sse.unauthenticated;
117- const { points, durationMs } = config;
118-
119- // Get or initialize request history for this IP
120- const requests = ipRequests.get(ip) || [];
121-
122- // Clean old requests outside the window
123- const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
124-
125- // Check if we're over the limit
126- if (validRequests.length >= points) {
127- log.debug(
128- `Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`
129- );
130- const oldestRequest = validRequests[0];
131- const retryAfter = durationMs - (now - oldestRequest.timestamp);
132- return { limited: true, retryAfter };
133- }
134-
135- // Add this request
136- validRequests.push({ timestamp: now });
137- ipRequests.set(ip, validRequests);
138-
139- return { limited: false };
140- }
141-
142- // Handle other endpoints
143 const { points, durationMs } = RATE_LIMITS[endpointType];81 const { points, durationMs } = RATE_LIMITS[endpointType];
144 const requests = ipRequests.get(ip) || [];82 const requests = ipRequests.get(ip) || [];
145 const validRequests = requests.filter((req) => now - req.timestamp < durationMs);83 const validRequests = requests.filter((req) => now - req.timestamp < durationMs);
@@ -164,7 +102,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
164 const ip = event.getClientAddress();102 const ip = event.getClientAddress();
165 const url = new URL(event.request.url);103 const url = new URL(event.request.url);
166 const isPublic = url.searchParams.get('public') === 'true';104 const isPublic = url.searchParams.get('public') === 'true';
167- const isAuthenticated = Boolean(event.locals.user);
168 105
169 // Skip rate limiting for non-API routes106 // Skip rate limiting for non-API routes
170 if (!url.pathname.startsWith('/api')) {107 if (!url.pathname.startsWith('/api')) {
@@ -172,16 +109,12 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172 }109 }
173 110
174 const endpointType = getEndpointType(url.pathname, isPublic);111 const endpointType = getEndpointType(url.pathname, isPublic);
175- const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);112+ const { limited, retryAfter } = isRateLimited(ip, endpointType);
176 113
177 if (limited) {114 if (limited) {
178 // Construct a user-friendly error message115 // Construct a user-friendly error message
179 let errorMessage = 'Too many requests. ';116 let errorMessage = 'Too many requests. ';
180- if (endpointType === 'sse') {117+ if (endpointType === 'auth') {
181- errorMessage += isAuthenticated
182- ? 'Please wait before attempting to reconnect to the chat.'
183- : 'Please wait before attempting to view the public chat again.';
184- } else if (endpointType === 'auth') {
185 errorMessage += 'Please wait before attempting to log in again.';118 errorMessage += 'Please wait before attempting to log in again.';
186 } else {119 } else {
187 errorMessage += 'Please try again later.';120 errorMessage += 'Please try again later.';
@@ -196,13 +129,11 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
196 // Only include potentially sensitive info in dev mode129 // Only include potentially sensitive info in dev mode
197 if (isDev) {130 if (isDev) {
198 responseBody.endpointType = endpointType;131 responseBody.endpointType = endpointType;
199- responseBody.isAuthenticated = isAuthenticated;
200 responseBody.debug = {132 responseBody.debug = {
201- isAuthenticated,
202 hasUser: Boolean(event.locals.user),133 hasUser: Boolean(event.locals.user),
203 hasSession: Boolean(event.locals.session),134 hasSession: Boolean(event.locals.session),
204 endpointType,135 endpointType,
205- rateLimitPoints: getRateLimitPoints(endpointType, isAuthenticated)136+ rateLimitPoints: RATE_LIMITS[endpointType].points
206 };137 };
207 }138 }
208 139
@@ -213,14 +144,10 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
213 };144 };
214 145
215 if (isDev) {146 if (isDev) {
216- headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
217 headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();147 headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
218 headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();148 headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
219 headers['X-Debug-Endpoint-Type'] = endpointType;149 headers['X-Debug-Endpoint-Type'] = endpointType;
220- headers['X-Debug-Rate-Limit-Points'] = getRateLimitPoints(150+ headers['X-Debug-Rate-Limit-Points'] = RATE_LIMITS[endpointType].points.toString();
221- endpointType,
222- isAuthenticated
223- ).toString();
224 }151 }
225 152
226 return new Response(JSON.stringify(responseBody), {153 return new Response(JSON.stringify(responseBody), {
D src/routes/api/rooms/[roomId]/+server.ts +0 -101
@@ -1,101 +0,0 @@
1-import { users } from '$lib/db/convex.server';
2-import type { SafeUser } from '$lib/types/chat';
3-import type { PublicRoomResponse } from '$lib/types/payloads';
4-import { createSafeUser } from '$lib/types/chat';
5-import { createLogger } from '$lib/utils/logger.server';
6-import type { RequestHandler } from './$types';
7-
8-const log = createLogger('rooms-server');
9-
10-const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
11-
12-export const GET: RequestHandler = async ({ params, url, locals }) => {
13- const { roomId } = params;
14- const isPublic = !locals.user || url.searchParams.get('public') === 'true';
15-
16- try {
17- log.debug('Fetching room data', {
18- roomId,
19- isPublic,
20- requestedBy: locals.user
21- ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
22- : 'public'
23- });
24-
25- // Calculate timeout threshold
26- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
27-
28- // Fetch all users from Convex
29- const fetchedUsers = await users.list();
30-
31- // Process users to mark them as offline if they've timed out
32- const processedUsers = fetchedUsers.map((user) => {
33- const lastSeen = user.lastSeen ?? 0;
34- const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold;
35- return {
36- id: user._id,
37- nickname: user.nickname,
38- status: shouldBeOffline ? 'offline' : user.status,
39- avatarUrl: user.avatarUrl,
40- lastSeen
41- };
42- });
43-
44- // Find users who should be marked offline and update them
45- const usersToUpdate = processedUsers.filter((user, index) => {
46- const original = fetchedUsers[index];
47- return user.status === 'offline' && original.status !== 'offline';
48- });
49-
50- // Update timed-out users in Convex
51- if (usersToUpdate.length > 0) {
52- await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline')));
53-
54- log.debug('Updated offline status for users', {
55- count: usersToUpdate.length,
56- userIds: usersToUpdate.map((u) => u.id)
57- });
58- }
59-
60- // Sanitize user data
61- const sanitizedUsers: SafeUser[] = processedUsers.map((user) =>
62- createSafeUser({
63- id: user.id,
64- nickname: user.nickname,
65- status: user.status,
66- avatarUrl: user.avatarUrl,
67- lastSeen: user.lastSeen
68- })
69- );
70-
71- const responseData: PublicRoomResponse = {
72- success: true,
73- buddyList: sanitizedUsers
74- };
75-
76- log.debug('Successfully fetched room data', {
77- roomId,
78- userCount: sanitizedUsers.length,
79- isPublic,
80- offlineUpdates: usersToUpdate.length
81- });
82-
83- return new Response(JSON.stringify(responseData), {
84- status: 200,
85- headers: { 'Content-Type': 'application/json' }
86- });
87- } catch (error) {
88- log.error('Error fetching room data', {
89- roomId,
90- error: error instanceof Error ? error.message : 'Unknown error'
91- });
92- const errorResponse: PublicRoomResponse = {
93- success: false,
94- error: 'Failed to fetch room data'
95- };
96- return new Response(JSON.stringify(errorResponse), {
97- status: 500,
98- headers: { 'Content-Type': 'application/json' }
99- });
100- }
101-};
@@ -1,101 +0,0 @@
1-import { users } from '$lib/db/convex.server';
2-import type { SafeUser } from '$lib/types/chat';
3-import type { PublicRoomResponse } from '$lib/types/payloads';
4-import { createSafeUser } from '$lib/types/chat';
5-import { createLogger } from '$lib/utils/logger.server';
6-import type { RequestHandler } from './$types';
7-
8-const log = createLogger('rooms-server');
9-
10-const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
11-
12-export const GET: RequestHandler = async ({ params, url, locals }) => {
13- const { roomId } = params;
14- const isPublic = !locals.user || url.searchParams.get('public') === 'true';
15-
16- try {
17- log.debug('Fetching room data', {
18- roomId,
19- isPublic,
20- requestedBy: locals.user
21- ? `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
22- : 'public'
23- });
24-
25- // Calculate timeout threshold
26- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
27-
28- // Fetch all users from Convex
29- const fetchedUsers = await users.list();
30-
31- // Process users to mark them as offline if they've timed out
32- const processedUsers = fetchedUsers.map((user) => {
33- const lastSeen = user.lastSeen ?? 0;
34- const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold;
35- return {
36- id: user._id,
37- nickname: user.nickname,
38- status: shouldBeOffline ? 'offline' : user.status,
39- avatarUrl: user.avatarUrl,
40- lastSeen
41- };
42- });
43-
44- // Find users who should be marked offline and update them
45- const usersToUpdate = processedUsers.filter((user, index) => {
46- const original = fetchedUsers[index];
47- return user.status === 'offline' && original.status !== 'offline';
48- });
49-
50- // Update timed-out users in Convex
51- if (usersToUpdate.length > 0) {
52- await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline')));
53-
54- log.debug('Updated offline status for users', {
55- count: usersToUpdate.length,
56- userIds: usersToUpdate.map((u) => u.id)
57- });
58- }
59-
60- // Sanitize user data
61- const sanitizedUsers: SafeUser[] = processedUsers.map((user) =>
62- createSafeUser({
63- id: user.id,
64- nickname: user.nickname,
65- status: user.status,
66- avatarUrl: user.avatarUrl,
67- lastSeen: user.lastSeen
68- })
69- );
70-
71- const responseData: PublicRoomResponse = {
72- success: true,
73- buddyList: sanitizedUsers
74- };
75-
76- log.debug('Successfully fetched room data', {
77- roomId,
78- userCount: sanitizedUsers.length,
79- isPublic,
80- offlineUpdates: usersToUpdate.length
81- });
82-
83- return new Response(JSON.stringify(responseData), {
84- status: 200,
85- headers: { 'Content-Type': 'application/json' }
86- });
87- } catch (error) {
88- log.error('Error fetching room data', {
89- roomId,
90- error: error instanceof Error ? error.message : 'Unknown error'
91- });
92- const errorResponse: PublicRoomResponse = {
93- success: false,
94- error: 'Failed to fetch room data'
95- };
96- return new Response(JSON.stringify(errorResponse), {
97- status: 500,
98- headers: { 'Content-Type': 'application/json' }
99- });
100- }
101-};
D src/routes/api/rooms/default/+server.ts +0 -42
@@ -1,42 +0,0 @@
1-import { chatRooms } from '$lib/db/convex.server';
2-import { createLogger } from '$lib/utils/logger.server';
3-import type { RequestHandler } from './$types';
4-
5-const log = createLogger('rooms-default-server');
6-
7-export const GET: RequestHandler = async () => {
8- try {
9- log.debug('Fetching default room');
10-
11- const room = await chatRooms.getOrCreateDefault();
12-
13- log.debug('Successfully fetched default room', {
14- roomId: room._id
15- });
16-
17- return new Response(
18- JSON.stringify({
19- success: true,
20- roomId: room._id
21- }),
22- {
23- status: 200,
24- headers: { 'Content-Type': 'application/json' }
25- }
26- );
27- } catch (error) {
28- log.error('Error fetching default room', {
29- error: error instanceof Error ? error.message : 'Unknown error'
30- });
31- return new Response(
32- JSON.stringify({
33- success: false,
34- error: 'Failed to fetch default room'
35- }),
36- {
37- status: 500,
38- headers: { 'Content-Type': 'application/json' }
39- }
40- );
41- }
42-};
@@ -1,42 +0,0 @@
1-import { chatRooms } from '$lib/db/convex.server';
2-import { createLogger } from '$lib/utils/logger.server';
3-import type { RequestHandler } from './$types';
4-
5-const log = createLogger('rooms-default-server');
6-
7-export const GET: RequestHandler = async () => {
8- try {
9- log.debug('Fetching default room');
10-
11- const room = await chatRooms.getOrCreateDefault();
12-
13- log.debug('Successfully fetched default room', {
14- roomId: room._id
15- });
16-
17- return new Response(
18- JSON.stringify({
19- success: true,
20- roomId: room._id
21- }),
22- {
23- status: 200,
24- headers: { 'Content-Type': 'application/json' }
25- }
26- );
27- } catch (error) {
28- log.error('Error fetching default room', {
29- error: error instanceof Error ? error.message : 'Unknown error'
30- });
31- return new Response(
32- JSON.stringify({
33- success: false,
34- error: 'Failed to fetch default room'
35- }),
36- {
37- status: 500,
38- headers: { 'Content-Type': 'application/json' }
39- }
40- );
41- }
42-};
D src/routes/api/session/logout/+server.ts +0 -33
@@ -1,33 +0,0 @@
1-import { json } from '@sveltejs/kit';
2-import type { RequestHandler } from './$types';
3-import { invalidateSession } from '$lib/api/session.server';
4-import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id
5-import { createLogger } from '$lib/utils/logger.server';
6-
7-const log = createLogger('logout-server');
8-
9-export const POST: RequestHandler = async ({ cookies }) => {
10- const token = cookies.get('session');
11-
12- if (!token) {
13- log.debug('No session token found in cookies');
14- return json({ success: true, invalidated: false });
15- }
16-
17- try {
18- const sessionId = await sha256(token);
19- await invalidateSession(sessionId);
20- cookies.delete('session', { path: '/' });
21- log.info('Session invalidated', {
22- sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
23- });
24- return json({ success: true, invalidated: true });
25- } catch (error) {
26- log.error('Failed to invalidate session', {
27- error: error instanceof Error ? error.message : 'Unknown error'
28- });
29- // Still delete cookie to ensure client is logged out
30- cookies.delete('session', { path: '/' });
31- return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 });
32- }
33-};
@@ -1,33 +0,0 @@
1-import { json } from '@sveltejs/kit';
2-import type { RequestHandler } from './$types';
3-import { invalidateSession } from '$lib/api/session.server';
4-import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id
5-import { createLogger } from '$lib/utils/logger.server';
6-
7-const log = createLogger('logout-server');
8-
9-export const POST: RequestHandler = async ({ cookies }) => {
10- const token = cookies.get('session');
11-
12- if (!token) {
13- log.debug('No session token found in cookies');
14- return json({ success: true, invalidated: false });
15- }
16-
17- try {
18- const sessionId = await sha256(token);
19- await invalidateSession(sessionId);
20- cookies.delete('session', { path: '/' });
21- log.info('Session invalidated', {
22- sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
23- });
24- return json({ success: true, invalidated: true });
25- } catch (error) {
26- log.error('Failed to invalidate session', {
27- error: error instanceof Error ? error.message : 'Unknown error'
28- });
29- // Still delete cookie to ensure client is logged out
30- cookies.delete('session', { path: '/' });
31- return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 });
32- }
33-};
D src/routes/api/users/[userId]/+server.ts +0 -46
@@ -1,46 +0,0 @@
1-import { error, json } from '@sveltejs/kit';
2-import { users } from '$lib/db/convex.server';
3-import type { RequestHandler } from './$types';
4-import { createSafeUser } from '$lib/types/chat';
5-import { createLogger } from '$lib/utils/logger.server';
6-
7-const log = createLogger('users-server');
8-
9-export const GET: RequestHandler = async ({ params }) => {
10- const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
11- log.debug('Fetching user data', { userId: maskedUserId });
12-
13- try {
14- const user = await users.getById(params.userId);
15-
16- if (!user) {
17- log.warn('User not found', { userId: maskedUserId });
18- throw error(404, 'User not found');
19- }
20-
21- const safeUser = createSafeUser({
22- id: user._id,
23- nickname: user.nickname,
24- status: user.status,
25- avatarUrl: user.avatarUrl,
26- lastSeen: user.lastSeen
27- });
28-
29- log.debug('User data retrieved', {
30- userId: maskedUserId,
31- status: safeUser.status,
32- lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
33- });
34-
35- return json({
36- success: true,
37- user: safeUser
38- });
39- } catch (err) {
40- log.error('Error fetching user data', {
41- userId: maskedUserId,
42- error: err instanceof Error ? err.message : 'Unknown error'
43- });
44- throw error(500, 'Failed to fetch user information');
45- }
46-};
@@ -1,46 +0,0 @@
1-import { error, json } from '@sveltejs/kit';
2-import { users } from '$lib/db/convex.server';
3-import type { RequestHandler } from './$types';
4-import { createSafeUser } from '$lib/types/chat';
5-import { createLogger } from '$lib/utils/logger.server';
6-
7-const log = createLogger('users-server');
8-
9-export const GET: RequestHandler = async ({ params }) => {
10- const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
11- log.debug('Fetching user data', { userId: maskedUserId });
12-
13- try {
14- const user = await users.getById(params.userId);
15-
16- if (!user) {
17- log.warn('User not found', { userId: maskedUserId });
18- throw error(404, 'User not found');
19- }
20-
21- const safeUser = createSafeUser({
22- id: user._id,
23- nickname: user.nickname,
24- status: user.status,
25- avatarUrl: user.avatarUrl,
26- lastSeen: user.lastSeen
27- });
28-
29- log.debug('User data retrieved', {
30- userId: maskedUserId,
31- status: safeUser.status,
32- lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
33- });
34-
35- return json({
36- success: true,
37- user: safeUser
38- });
39- } catch (err) {
40- log.error('Error fetching user data', {
41- userId: maskedUserId,
42- error: err instanceof Error ? err.message : 'Unknown error'
43- });
44- throw error(500, 'Failed to fetch user information');
45- }
46-};

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