Merge pull request #76 from kitpdr/fix/online-status-timeout
fix(status): compute online status from lastSeen timeout + cleanup unused endpoints
7 files · +37 -319
M src/convex/queries.ts +27 -6
| @@ -2,6 +2,27 @@ import { query } from './_generated/server'; | |||
| 2 | 2 | import { authQuery } from './auth'; | |
| 3 | 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 | 26 | // ============ PUBLIC QUERIES (no auth required) ============ | |
| 6 | 27 | ||
| 7 | 28 | // Public query: Get messages for a chat room (real-time subscription) | |
| @@ -36,7 +57,7 @@ export const getMessagesPublic = query({ | |||
| 36 | 57 | ? { | |
| 37 | 58 | id: sender._id, | |
| 38 | 59 | nickname: sender.nickname, | |
| 39 | - | status: sender.status, | |
| 60 | + | status: computeEffectiveStatus(sender.status, sender.lastSeen), | |
| 40 | 61 | avatarUrl: sender.avatarUrl | |
| 41 | 62 | } | |
| 42 | 63 | : null | |
| @@ -55,11 +76,11 @@ export const getUsersPublic = query({ | |||
| 55 | 76 | handler: async (ctx) => { | |
| 56 | 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 | 80 | return users.map((user) => ({ | |
| 60 | 81 | id: user._id, | |
| 61 | 82 | nickname: user.nickname, | |
| 62 | - | status: user.status, | |
| 83 | + | status: computeEffectiveStatus(user.status, user.lastSeen), | |
| 63 | 84 | avatarUrl: user.avatarUrl, | |
| 64 | 85 | lastSeen: user.lastSeen | |
| 65 | 86 | })); | |
| @@ -131,7 +152,7 @@ export const getMessages = authQuery({ | |||
| 131 | 152 | ? { | |
| 132 | 153 | id: sender._id, | |
| 133 | 154 | nickname: sender.nickname, | |
| 134 | - | status: sender.status, | |
| 155 | + | status: computeEffectiveStatus(sender.status, sender.lastSeen), | |
| 135 | 156 | avatarUrl: sender.avatarUrl | |
| 136 | 157 | } | |
| 137 | 158 | : null | |
| @@ -150,11 +171,11 @@ export const getUsers = authQuery({ | |||
| 150 | 171 | handler: async (ctx) => { | |
| 151 | 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 | 175 | return users.map((user) => ({ | |
| 155 | 176 | id: user._id, | |
| 156 | 177 | nickname: user.nickname, | |
| 157 | - | status: user.status, | |
| 178 | + | status: computeEffectiveStatus(user.status, user.lastSeen), | |
| 158 | 179 | avatarUrl: user.avatarUrl, | |
| 159 | 180 | lastSeen: user.lastSeen | |
| 160 | 181 | })); | |
| @@ -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.avatarUrl | 61 | avatarUrl: sender.avatarUrl | ||
| 41 | } | 62 | } | ||
| 42 | : null | 63 | : 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.lastSeen | 85 | 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.avatarUrl | 156 | avatarUrl: sender.avatarUrl | ||
| 136 | } | 157 | } | ||
| 137 | : null | 158 | : 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.lastSeen | 180 | lastSeen: user.lastSeen | ||
| 160 | })); | 181 | })); | ||
M src/hooks.server.ts +3 -11
| @@ -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 session | |
| 126 | + | // Identify public chat requests if there is no valid session | |
| 127 | 127 | const isPublicChatRequest = | |
| 128 | 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 | 131 | log.debug('Public request accessed', { path: event.url.pathname }); | |
| 134 | 132 | const response = await resolve(event); | |
| 135 | 133 | return addSecurityHeaders(response); | |
| @@ -152,14 +150,8 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 152 | 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 | 153 | // Apply rate limiting for non-public endpoints | |
| 162 | - | if (!isPublicChatRequest && !isPublicRoomRequest) { | |
| 154 | + | if (!isPublicChatRequest) { | |
| 163 | 155 | const rateLimitResult = await handleRateLimit(event); | |
| 164 | 156 | if (rateLimitResult?.status === 429) { | |
| 165 | 157 | 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 session | 126 | + | // 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 endpoints | 153 | // 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 = { | |||
| 13 | 13 | durationMs: 5 * 60 * 1000 | |
| 14 | 14 | }, | |
| 15 | 15 | ||
| 16 | - | // Public endpoints (public chat, rooms) | |
| 16 | + | // Public endpoints (public chat) | |
| 17 | 17 | public: { | |
| 18 | 18 | points: isDev ? 1000 : 30, | |
| 19 | 19 | durationMs: 60 * 1000 | |
| @@ -23,18 +23,6 @@ const RATE_LIMITS = { | |||
| 23 | 23 | protected: { | |
| 24 | 24 | points: isDev ? 1000 : 100, | |
| 25 | 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 | 26 | } | |
| 39 | 27 | } as const; | |
| 40 | 28 | ||
| @@ -45,9 +33,7 @@ const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | |||
| 45 | 33 | const MAX_RATE_LIMIT_DURATION = Math.max( | |
| 46 | 34 | RATE_LIMITS.auth.durationMs, | |
| 47 | 35 | 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 | |
| 51 | 37 | ); | |
| 52 | 38 | ||
| 53 | 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 | 59 | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | |
| 74 | 60 | return 'auth'; | |
| 75 | 61 | } | |
| 76 | - | if (pathname.startsWith('/api/sse')) { | |
| 77 | - | return 'sse'; | |
| 78 | - | } | |
| 79 | 62 | if (isPublic) { | |
| 80 | 63 | return 'public'; | |
| 81 | 64 | } | |
| 82 | 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 | 68 | function isRateLimited( | |
| 98 | 69 | ip: string, | |
| 99 | - | endpointType: keyof typeof RATE_LIMITS, | |
| 100 | - | isAuthenticated: boolean = false | |
| 70 | + | endpointType: keyof typeof RATE_LIMITS | |
| 101 | 71 | ): { limited: boolean; retryAfter?: number } { | |
| 102 | 72 | // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility) | |
| 103 | 73 | cleanupStaleEntries(); | |
| @@ -108,38 +78,6 @@ function isRateLimited( | |||
| 108 | 78 | } | |
| 109 | 79 | ||
| 110 | 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 | 81 | const { points, durationMs } = RATE_LIMITS[endpointType]; | |
| 144 | 82 | const requests = ipRequests.get(ip) || []; | |
| 145 | 83 | const validRequests = requests.filter((req) => now - req.timestamp < durationMs); | |
| @@ -164,7 +102,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 164 | 102 | const ip = event.getClientAddress(); | |
| 165 | 103 | const url = new URL(event.request.url); | |
| 166 | 104 | const isPublic = url.searchParams.get('public') === 'true'; | |
| 167 | - | const isAuthenticated = Boolean(event.locals.user); | |
| 168 | 105 | ||
| 169 | 106 | // Skip rate limiting for non-API routes | |
| 170 | 107 | if (!url.pathname.startsWith('/api')) { | |
| @@ -172,16 +109,12 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 172 | 109 | } | |
| 173 | 110 | ||
| 174 | 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 | 114 | if (limited) { | |
| 178 | 115 | // Construct a user-friendly error message | |
| 179 | 116 | 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') { | |
| 185 | 118 | errorMessage += 'Please wait before attempting to log in again.'; | |
| 186 | 119 | } else { | |
| 187 | 120 | errorMessage += 'Please try again later.'; | |
| @@ -196,13 +129,11 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 196 | 129 | // Only include potentially sensitive info in dev mode | |
| 197 | 130 | if (isDev) { | |
| 198 | 131 | responseBody.endpointType = endpointType; | |
| 199 | - | responseBody.isAuthenticated = isAuthenticated; | |
| 200 | 132 | responseBody.debug = { | |
| 201 | - | isAuthenticated, | |
| 202 | 133 | hasUser: Boolean(event.locals.user), | |
| 203 | 134 | hasSession: Boolean(event.locals.session), | |
| 204 | 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 | 146 | if (isDev) { | |
| 216 | - | headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString(); | |
| 217 | 147 | headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString(); | |
| 218 | 148 | headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString(); | |
| 219 | 149 | 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(); | |
| 224 | 151 | } | |
| 225 | 152 | ||
| 226 | 153 | return new Response(JSON.stringify(responseBody), { | |
| @@ -13,7 +13,7 @@ const RATE_LIMITS = { | |||||
| 13 | durationMs: 5 * 60 * 1000 | 13 | 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 * 1000 | 19 | 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 * 1000 | 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 | } | 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 routes | 106 | // 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 message | 115 | // 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 mode | 129 | // 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 | - | }; | |||