feat: Implement SafeUser type and sanitization across API routes
- Add SafeUser type to remove sensitive user information - Create createSafeUser helper function for consistent user data sanitization - Update API routes to use SafeUser for consistent user data exposure - Refactor SSE and status update handlers to use new SafeUser type - Improve type safety and reduce potential information leakage
- commit
- a3b34f240b8ef81b897e85632866cb1e07c828d1
- author
- Kit · · 2025-02-15 08:29 UTC
- parent
- 3f69ff2
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
10 files · +136 -89
M src/lib/api/session.server.ts +1 -0
| @@ -65,6 +65,7 @@ export async function createSession(token: string, userId: string): Promise<Sess | |||
| 65 | 65 | /** | |
| 66 | 66 | * Type distributed through our API. | |
| 67 | 67 | * Returns an object with both session and user if valid, or nulls if not. | |
| 68 | + | * Note: This returns the full User type since it's used server-side in hooks. | |
| 68 | 69 | */ | |
| 69 | 70 | export type SessionValidationResult = | |
| 70 | 71 | | { session: Session; user: User } | |
| @@ -65,6 +65,7 @@ export async function createSession(token: string, userId: string): Promise<Sess | |||||
| 65 | /** | 65 | /** | ||
| 66 | * Type distributed through our API. | 66 | * Type distributed through our API. | ||
| 67 | * Returns an object with both session and user if valid, or nulls if not. | 67 | * Returns an object with both session and user if valid, or nulls if not. | ||
| 68 | + | * Note: This returns the full User type since it's used server-side in hooks. | |||
| 68 | */ | 69 | */ | ||
| 69 | export type SessionValidationResult = | 70 | export type SessionValidationResult = | ||
| 70 | | { session: Session; user: User } | 71 | | { session: Session; user: User } | ||
M src/lib/states/chat.svelte.ts +33 -19
| @@ -30,6 +30,8 @@ class ChatState { | |||
| 30 | 30 | private _lastEnrichmentKey: string = ''; | |
| 31 | 31 | // Flag to avoid re-registering SSE event handlers | |
| 32 | 32 | private sseHandlersRegistered = false; | |
| 33 | + | private lastBuddyListUpdate = 0; | |
| 34 | + | private lastBuddyListHash = ''; | |
| 33 | 35 | ||
| 34 | 36 | public async reinitialize() { | |
| 35 | 37 | if (this.isInitializing) { | |
| @@ -361,12 +363,31 @@ class ChatState { | |||
| 361 | 363 | ||
| 362 | 364 | // Add method to update online users for public access | |
| 363 | 365 | updateOnlineUsers(users: User[]) { | |
| 364 | - | console.debug('Updating online users:', users); | |
| 365 | - | this.users = users; | |
| 366 | - | // Also update cache | |
| 367 | - | users.forEach(user => { | |
| 368 | - | this.userCache[user.id] = user; | |
| 369 | - | }); | |
| 366 | + | const now = Date.now(); | |
| 367 | + | // Create a hash of the new users data | |
| 368 | + | const newHash = JSON.stringify(users); | |
| 369 | + | ||
| 370 | + | // Only update if the data has changed | |
| 371 | + | if (newHash !== this.lastBuddyListHash) { | |
| 372 | + | const statusCounts = users.reduce((acc, user) => { | |
| 373 | + | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 374 | + | return acc; | |
| 375 | + | }, {} as Record<string, number>); | |
| 376 | + | ||
| 377 | + | console.debug('Updating online users (data changed):', { | |
| 378 | + | total: users.length, | |
| 379 | + | byStatus: statusCounts, | |
| 380 | + | timestamp: new Date(now).toISOString() | |
| 381 | + | }); | |
| 382 | + | ||
| 383 | + | this.users = users; | |
| 384 | + | // Also update cache | |
| 385 | + | users.forEach(user => { | |
| 386 | + | this.userCache[user.id] = user; | |
| 387 | + | }); | |
| 388 | + | this.lastBuddyListHash = newHash; | |
| 389 | + | this.lastBuddyListUpdate = now; | |
| 390 | + | } | |
| 370 | 391 | } | |
| 371 | 392 | ||
| 372 | 393 | // Add method to update messages for public access | |
| @@ -558,13 +579,7 @@ class ChatState { | |||
| 558 | 579 | ||
| 559 | 580 | // Register SSE event handlers only once per connection. | |
| 560 | 581 | private setupMessageHandlers() { | |
| 561 | - | if (this.sseHandlersRegistered) { | |
| 562 | - | console.debug('SSE message handlers already registered, skipping.'); | |
| 563 | - | return; | |
| 564 | - | } | |
| 565 | - | ||
| 566 | - | if (!this.eventSource) { | |
| 567 | - | console.debug('No SSE connection available for registering handlers, skipping.'); | |
| 582 | + | if (this.sseHandlersRegistered || !this.eventSource) { | |
| 568 | 583 | return; | |
| 569 | 584 | } | |
| 570 | 585 | ||
| @@ -582,14 +597,13 @@ class ChatState { | |||
| 582 | 597 | } | |
| 583 | 598 | }); | |
| 584 | 599 | ||
| 585 | - | // Listen for user status updates to update the buddy list dynamically | |
| 586 | - | this.eventSource.addEventListener('userStatusUpdate', async (event: MessageEvent) => { | |
| 600 | + | // Modify buddy list update handler | |
| 601 | + | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | |
| 587 | 602 | try { | |
| 588 | - | const statusUpdate = JSON.parse(event.data) as { userId: string; status: User['status']; lastSeen: number }; | |
| 589 | - | console.debug('Received userStatusUpdate via SSE:', statusUpdate); | |
| 590 | - | this.updateUserStatus(statusUpdate.userId, statusUpdate.status, statusUpdate.lastSeen); | |
| 603 | + | const buddyList = JSON.parse(event.data) as User[]; | |
| 604 | + | this.updateOnlineUsers(buddyList); | |
| 591 | 605 | } catch (error) { | |
| 592 | - | console.debug('Error handling userStatusUpdate via SSE:', error); | |
| 606 | + | console.debug('Error handling buddy list update via SSE:', error); | |
| 593 | 607 | } | |
| 594 | 608 | }); | |
| 595 | 609 | ||
| @@ -30,6 +30,8 @@ class ChatState { | |||||
| 30 | private _lastEnrichmentKey: string = ''; | 30 | private _lastEnrichmentKey: string = ''; | ||
| 31 | // Flag to avoid re-registering SSE event handlers | 31 | // Flag to avoid re-registering SSE event handlers | ||
| 32 | private sseHandlersRegistered = false; | 32 | private sseHandlersRegistered = false; | ||
| 33 | + | private lastBuddyListUpdate = 0; | |||
| 34 | + | private lastBuddyListHash = ''; | |||
| 33 | 35 | ||||
| 34 | public async reinitialize() { | 36 | public async reinitialize() { | ||
| 35 | if (this.isInitializing) { | 37 | if (this.isInitializing) { | ||
| @@ -361,12 +363,31 @@ class ChatState { | |||||
| 361 | 363 | ||||
| 362 | // Add method to update online users for public access | 364 | // Add method to update online users for public access | ||
| 363 | updateOnlineUsers(users: User[]) { | 365 | updateOnlineUsers(users: User[]) { | ||
| 364 | - | console.debug('Updating online users:', users); | 366 | + | const now = Date.now(); |
| 365 | - | this.users = users; | 367 | + | // Create a hash of the new users data |
| 366 | - | // Also update cache | 368 | + | const newHash = JSON.stringify(users); |
| 367 | - | users.forEach(user => { | 369 | + | |
| 368 | - | this.userCache[user.id] = user; | 370 | + | // Only update if the data has changed |
| 369 | - | }); | 371 | + | if (newHash !== this.lastBuddyListHash) { |
| 372 | + | const statusCounts = users.reduce((acc, user) => { | |||
| 373 | + | acc[user.status] = (acc[user.status] || 0) + 1; | |||
| 374 | + | return acc; | |||
| 375 | + | }, {} as Record<string, number>); | |||
| 376 | + | ||||
| 377 | + | console.debug('Updating online users (data changed):', { | |||
| 378 | + | total: users.length, | |||
| 379 | + | byStatus: statusCounts, | |||
| 380 | + | timestamp: new Date(now).toISOString() | |||
| 381 | + | }); | |||
| 382 | + | ||||
| 383 | + | this.users = users; | |||
| 384 | + | // Also update cache | |||
| 385 | + | users.forEach(user => { | |||
| 386 | + | this.userCache[user.id] = user; | |||
| 387 | + | }); | |||
| 388 | + | this.lastBuddyListHash = newHash; | |||
| 389 | + | this.lastBuddyListUpdate = now; | |||
| 390 | + | } | |||
| 370 | } | 391 | } | ||
| 371 | 392 | ||||
| 372 | // Add method to update messages for public access | 393 | // Add method to update messages for public access | ||
| @@ -558,13 +579,7 @@ class ChatState { | |||||
| 558 | 579 | ||||
| 559 | // Register SSE event handlers only once per connection. | 580 | // Register SSE event handlers only once per connection. | ||
| 560 | private setupMessageHandlers() { | 581 | private setupMessageHandlers() { | ||
| 561 | - | if (this.sseHandlersRegistered) { | 582 | + | if (this.sseHandlersRegistered || !this.eventSource) { |
| 562 | - | console.debug('SSE message handlers already registered, skipping.'); | |||
| 563 | - | return; | |||
| 564 | - | } | |||
| 565 | - | ||||
| 566 | - | if (!this.eventSource) { | |||
| 567 | - | console.debug('No SSE connection available for registering handlers, skipping.'); | |||
| 568 | return; | 583 | return; | ||
| 569 | } | 584 | } | ||
| 570 | 585 | ||||
| @@ -582,14 +597,13 @@ class ChatState { | |||||
| 582 | } | 597 | } | ||
| 583 | }); | 598 | }); | ||
| 584 | 599 | ||||
| 585 | - | // Listen for user status updates to update the buddy list dynamically | 600 | + | // Modify buddy list update handler |
| 586 | - | this.eventSource.addEventListener('userStatusUpdate', async (event: MessageEvent) => { | 601 | + | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { |
| 587 | try { | 602 | try { | ||
| 588 | - | const statusUpdate = JSON.parse(event.data) as { userId: string; status: User['status']; lastSeen: number }; | 603 | + | const buddyList = JSON.parse(event.data) as User[]; |
| 589 | - | console.debug('Received userStatusUpdate via SSE:', statusUpdate); | 604 | + | this.updateOnlineUsers(buddyList); |
| 590 | - | this.updateUserStatus(statusUpdate.userId, statusUpdate.status, statusUpdate.lastSeen); | |||
| 591 | } catch (error) { | 605 | } catch (error) { | ||
| 592 | - | console.debug('Error handling userStatusUpdate via SSE:', error); | 606 | + | console.debug('Error handling buddy list update via SSE:', error); |
| 593 | } | 607 | } | ||
| 594 | }); | 608 | }); | ||
| 595 | 609 | ||||
M src/lib/types/chat.ts +12 -1
| @@ -29,8 +29,19 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||
| 29 | 29 | timestamp: number; // Timestamp when the message was sent | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | + | // Safe user type for API responses - omits sensitive information | |
| 33 | + | export type SafeUser = Omit<User, 'password' | 'createdAt'>; | |
| 34 | + | ||
| 35 | + | // Helper function to create a safe user object | |
| 36 | + | export function createSafeUser(user: User): SafeUser { | |
| 37 | + | // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| 38 | + | const { password, createdAt, ...safeUser } = user; | |
| 39 | + | return safeUser; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Update EnrichedMessage to use SafeUser | |
| 32 | 43 | export interface EnrichedMessage extends Message { | |
| 33 | - | user: User; | |
| 44 | + | user: SafeUser; | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 36 | 47 | export type MessageType = 'chat' | 'emote' | 'system' | string; | |
| @@ -29,8 +29,19 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||||
| 29 | timestamp: number; // Timestamp when the message was sent | 29 | timestamp: number; // Timestamp when the message was sent | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | + | // Safe user type for API responses - omits sensitive information | |||
| 33 | + | export type SafeUser = Omit<User, 'password' | 'createdAt'>; | |||
| 34 | + | ||||
| 35 | + | // Helper function to create a safe user object | |||
| 36 | + | export function createSafeUser(user: User): SafeUser { | |||
| 37 | + | // eslint-disable-next-line @typescript-eslint/no-unused-vars | |||
| 38 | + | const { password, createdAt, ...safeUser } = user; | |||
| 39 | + | return safeUser; | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Update EnrichedMessage to use SafeUser | |||
| 32 | export interface EnrichedMessage extends Message { | 43 | export interface EnrichedMessage extends Message { | ||
| 33 | - | user: User; | 44 | + | user: SafeUser; |
| 34 | } | 45 | } | ||
| 35 | 46 | ||||
| 36 | export type MessageType = 'chat' | 'emote' | 'system' | string; | 47 | export type MessageType = 'chat' | 'emote' | 'system' | string; | ||
M src/lib/types/payloads.ts +4 -4
| @@ -1,9 +1,9 @@ | |||
| 1 | - | import type { User, Message } from "./chat"; | |
| 1 | + | import type { SafeUser, Message } from "./chat"; | |
| 2 | 2 | ||
| 3 | 3 | // Login | |
| 4 | 4 | export interface LoginResponseSuccess { | |
| 5 | 5 | success: true; | |
| 6 | - | user: Omit<User, "password">; | |
| 6 | + | user: SafeUser; | |
| 7 | 7 | } | |
| 8 | 8 | ||
| 9 | 9 | export interface LoginResponseError { | |
| @@ -31,7 +31,7 @@ export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError; | |||
| 31 | 31 | // Session | |
| 32 | 32 | export interface SessionResponseSuccess { | |
| 33 | 33 | success: true; | |
| 34 | - | user: Omit<User, "password">; | |
| 34 | + | user: SafeUser; | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | export interface SessionResponseError { | |
| @@ -76,7 +76,7 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon | |||
| 76 | 76 | ||
| 77 | 77 | export interface PublicRoomResponseSuccess { | |
| 78 | 78 | success: true; | |
| 79 | - | buddyList: Omit<User, "password">[]; | |
| 79 | + | buddyList: SafeUser[]; | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | 82 | export interface PublicRoomResponseError { | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | - | import type { User, Message } from "./chat"; | 1 | + | import type { SafeUser, Message } from "./chat"; |
| 2 | 2 | ||||
| 3 | // Login | 3 | // Login | ||
| 4 | export interface LoginResponseSuccess { | 4 | export interface LoginResponseSuccess { | ||
| 5 | success: true; | 5 | success: true; | ||
| 6 | - | user: Omit<User, "password">; | 6 | + | user: SafeUser; |
| 7 | } | 7 | } | ||
| 8 | 8 | ||||
| 9 | export interface LoginResponseError { | 9 | export interface LoginResponseError { | ||
| @@ -31,7 +31,7 @@ export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError; | |||||
| 31 | // Session | 31 | // Session | ||
| 32 | export interface SessionResponseSuccess { | 32 | export interface SessionResponseSuccess { | ||
| 33 | success: true; | 33 | success: true; | ||
| 34 | - | user: Omit<User, "password">; | 34 | + | user: SafeUser; |
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | export interface SessionResponseError { | 37 | export interface SessionResponseError { | ||
| @@ -76,7 +76,7 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon | |||||
| 76 | 76 | ||||
| 77 | export interface PublicRoomResponseSuccess { | 77 | export interface PublicRoomResponseSuccess { | ||
| 78 | success: true; | 78 | success: true; | ||
| 79 | - | buddyList: Omit<User, "password">[]; | 79 | + | buddyList: SafeUser[]; |
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | export interface PublicRoomResponseError { | 82 | export interface PublicRoomResponseError { | ||
M src/routes/+layout.svelte +3 -3
| @@ -13,7 +13,8 @@ | |||
| 13 | 13 | // New throttle state variables | |
| 14 | 14 | let lastSentStatus = $state<string | null>(null); | |
| 15 | 15 | let lastStatusSentAt = $state<number>(0); | |
| 16 | - | const THROTTLE_TIME_MS = 5000; | |
| 16 | + | const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds | |
| 17 | + | const THROTTLE_TIME_MS = 4000; // 4 seconds to prevent edge case overlaps | |
| 17 | 18 | ||
| 18 | 19 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | |
| 19 | 20 | if (!data.user?.id || updateInProgress) return; | |
| @@ -82,8 +83,7 @@ | |||
| 82 | 83 | } | |
| 83 | 84 | }; | |
| 84 | 85 | ||
| 85 | - | statusInterval = window.setInterval(tickHandler, 60000); // Every minute | |
| 86 | - | // Removed immediate tickHandler() call to avoid duplicate update on mount | |
| 86 | + | statusInterval = window.setInterval(tickHandler, STATUS_UPDATE_INTERVAL); | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | function stopStatusTicker() { | |
| @@ -13,7 +13,8 @@ | |||||
| 13 | // New throttle state variables | 13 | // New throttle state variables | ||
| 14 | let lastSentStatus = $state<string | null>(null); | 14 | let lastSentStatus = $state<string | null>(null); | ||
| 15 | let lastStatusSentAt = $state<number>(0); | 15 | let lastStatusSentAt = $state<number>(0); | ||
| 16 | - | const THROTTLE_TIME_MS = 5000; | 16 | + | const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds |
| 17 | + | const THROTTLE_TIME_MS = 4000; // 4 seconds to prevent edge case overlaps | |||
| 17 | 18 | ||||
| 18 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | 19 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | ||
| 19 | if (!data.user?.id || updateInProgress) return; | 20 | if (!data.user?.id || updateInProgress) return; | ||
| @@ -82,8 +83,7 @@ | |||||
| 82 | } | 83 | } | ||
| 83 | }; | 84 | }; | ||
| 84 | 85 | ||||
| 85 | - | statusInterval = window.setInterval(tickHandler, 60000); // Every minute | 86 | + | statusInterval = window.setInterval(tickHandler, STATUS_UPDATE_INTERVAL); |
| 86 | - | // Removed immediate tickHandler() call to avoid duplicate update on mount | |||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | function stopStatusTicker() { | 89 | function stopStatusTicker() { | ||
M src/routes/api/rooms/[roomId]/+server.ts +4 -3
| @@ -3,6 +3,8 @@ import db from '$lib/db/db.server'; | |||
| 3 | 3 | import { messages, users } from '$lib/db/schema'; | |
| 4 | 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | |
| 5 | 5 | import { error } from '@sveltejs/kit'; | |
| 6 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | + | ||
| 6 | 8 | ||
| 7 | 9 | export async function GET({ params, url, locals }): Promise<Response> { | |
| 8 | 10 | const { roomId } = params; | |
| @@ -37,9 +39,8 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||
| 37 | 39 | const fetchedUsers = await db.select() | |
| 38 | 40 | .from(users); | |
| 39 | 41 | ||
| 40 | - | // Sanitize user data by removing sensitive fields | |
| 41 | - | // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| 42 | - | const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword); | |
| 42 | + | // Sanitize user data using createSafeUser | |
| 43 | + | const sanitizedUsers = fetchedUsers.map(user => createSafeUser(user)); | |
| 43 | 44 | ||
| 44 | 45 | const responseData: PublicRoomResponse = { | |
| 45 | 46 | success: true, | |
| @@ -3,6 +3,8 @@ import db from '$lib/db/db.server'; | |||||
| 3 | import { messages, users } from '$lib/db/schema'; | 3 | import { messages, users } from '$lib/db/schema'; | ||
| 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | 4 | import type { PublicRoomResponse } from '$lib/types/payloads'; | ||
| 5 | import { error } from '@sveltejs/kit'; | 5 | import { error } from '@sveltejs/kit'; | ||
| 6 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 7 | + | ||||
| 6 | 8 | ||||
| 7 | export async function GET({ params, url, locals }): Promise<Response> { | 9 | export async function GET({ params, url, locals }): Promise<Response> { | ||
| 8 | const { roomId } = params; | 10 | const { roomId } = params; | ||
| @@ -37,9 +39,8 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||||
| 37 | const fetchedUsers = await db.select() | 39 | const fetchedUsers = await db.select() | ||
| 38 | .from(users); | 40 | .from(users); | ||
| 39 | 41 | ||||
| 40 | - | // Sanitize user data by removing sensitive fields | 42 | + | // Sanitize user data using createSafeUser |
| 41 | - | // eslint-disable-next-line @typescript-eslint/no-unused-vars | 43 | + | const sanitizedUsers = fetchedUsers.map(user => createSafeUser(user)); |
| 42 | - | const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword); | |||
| 43 | 44 | ||||
| 44 | const responseData: PublicRoomResponse = { | 45 | const responseData: PublicRoomResponse = { | ||
| 45 | success: true, | 46 | success: true, | ||
M src/routes/api/session/login/+server.ts +2 -7
| @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm'; | |||
| 6 | 6 | import { verifyPassword } from '$lib/utils/password'; | |
| 7 | 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 8 | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 9 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 9 | 10 | ||
| 10 | 11 | // In-memory map to track failed login attempts per IP | |
| 11 | 12 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| @@ -112,13 +113,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 112 | 113 | ||
| 113 | 114 | return new Response(JSON.stringify({ | |
| 114 | 115 | success: true, | |
| 115 | - | user: { | |
| 116 | - | id: user.id, | |
| 117 | - | nickname: user.nickname, | |
| 118 | - | status: 'online', | |
| 119 | - | avatarUrl: user.avatarUrl, | |
| 120 | - | lastSeen: Date.now() | |
| 121 | - | } | |
| 116 | + | user: createSafeUser(user) | |
| 122 | 117 | } as LoginResponseSuccess), { | |
| 123 | 118 | status: 200 | |
| 124 | 119 | }); | |
| @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm'; | |||||
| 6 | import { verifyPassword } from '$lib/utils/password'; | 6 | import { verifyPassword } from '$lib/utils/password'; | ||
| 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | 7 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | ||
| 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | ||
| 9 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 9 | 10 | ||||
| 10 | // In-memory map to track failed login attempts per IP | 11 | // In-memory map to track failed login attempts per IP | ||
| 11 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | 12 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | ||
| @@ -112,13 +113,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 112 | 113 | ||||
| 113 | return new Response(JSON.stringify({ | 114 | return new Response(JSON.stringify({ | ||
| 114 | success: true, | 115 | success: true, | ||
| 115 | - | user: { | 116 | + | user: createSafeUser(user) |
| 116 | - | id: user.id, | |||
| 117 | - | nickname: user.nickname, | |||
| 118 | - | status: 'online', | |||
| 119 | - | avatarUrl: user.avatarUrl, | |||
| 120 | - | lastSeen: Date.now() | |||
| 121 | - | } | |||
| 122 | } as LoginResponseSuccess), { | 117 | } as LoginResponseSuccess), { | ||
| 123 | status: 200 | 118 | status: 200 | ||
| 124 | }); | 119 | }); | ||
M src/routes/api/sse/+server.ts +59 -29
| @@ -1,8 +1,15 @@ | |||
| 1 | + | // At the top of the file, before any code | |
| 2 | + | declare global { | |
| 3 | + | // eslint-disable-next-line no-var | |
| 4 | + | var __sseIntervalsStarted: boolean; | |
| 5 | + | } | |
| 6 | + | ||
| 1 | 7 | import type { RequestHandler } from './$types'; | |
| 2 | 8 | import { sseEmitter } from '$lib/sseEmitter'; | |
| 3 | 9 | import db from '$lib/db/db.server'; | |
| 4 | 10 | import { users } from '$lib/db/schema'; | |
| 5 | 11 | import { eq, and, lt } from 'drizzle-orm'; | |
| 12 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 6 | 13 | ||
| 7 | 14 | console.log('[SSE] Server initialized'); | |
| 8 | 15 | ||
| @@ -10,7 +17,15 @@ console.log('[SSE] Server initialized'); | |||
| 10 | 17 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | |
| 11 | 18 | const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds | |
| 12 | 19 | ||
| 20 | + | // Update the interval constant | |
| 21 | + | const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds | |
| 22 | + | ||
| 13 | 23 | let timeoutInterval: ReturnType<typeof setInterval>; | |
| 24 | + | let buddyListInterval: ReturnType<typeof setInterval>; | |
| 25 | + | ||
| 26 | + | // Add a timestamp tracker for buddy list updates | |
| 27 | + | let lastBuddyListUpdate = 0; | |
| 28 | + | let lastBuddyListData: unknown[] = []; | |
| 14 | 29 | ||
| 15 | 30 | // Initialize periodic check for timed out users | |
| 16 | 31 | function startTimeoutCheck() { | |
| @@ -26,7 +41,6 @@ function startTimeoutCheck() { | |||
| 26 | 41 | }); | |
| 27 | 42 | ||
| 28 | 43 | try { | |
| 29 | - | // Find and update users who haven't been seen recently | |
| 30 | 44 | const now = Date.now(); | |
| 31 | 45 | const result = await db.update(users) | |
| 32 | 46 | .set({ | |
| @@ -45,58 +59,82 @@ function startTimeoutCheck() { | |||
| 45 | 59 | lastSeen: users.lastSeen | |
| 46 | 60 | }); | |
| 47 | 61 | ||
| 48 | - | // Log timeout results | |
| 49 | 62 | if (result.length > 0) { | |
| 50 | 63 | console.log('[SSE] Found timed out users:', result.map(user => ({ | |
| 51 | 64 | nickname: user.nickname, | |
| 52 | 65 | lastSeen: new Date(user.lastSeen || 0).toISOString() | |
| 53 | 66 | }))); | |
| 54 | 67 | } | |
| 55 | - | ||
| 56 | - | // Broadcast status updates for timed out users | |
| 57 | - | result.forEach(user => { | |
| 58 | - | sseEmitter.emit('sse', { | |
| 59 | - | type: 'userStatusUpdate', | |
| 60 | - | data: { | |
| 61 | - | userId: user.id, | |
| 62 | - | status: 'offline', | |
| 63 | - | lastSeen: now | |
| 64 | - | } | |
| 65 | - | }); | |
| 66 | - | console.log('[SSE] User timed out and marked offline:', { | |
| 67 | - | nickname: user.nickname, | |
| 68 | - | lastSeen: new Date(now).toISOString() | |
| 69 | - | }); | |
| 70 | - | }); | |
| 71 | 68 | } catch (error) { | |
| 72 | 69 | console.error('[SSE] Error during timeout check:', error); | |
| 73 | 70 | } | |
| 74 | 71 | }, CHECK_INTERVAL_MS); | |
| 75 | 72 | } | |
| 76 | 73 | ||
| 77 | - | // Start the timeout check when this module loads | |
| 78 | - | startTimeoutCheck(); | |
| 74 | + | function startBuddyListUpdateInterval() { | |
| 75 | + | if (buddyListInterval) { | |
| 76 | + | clearInterval(buddyListInterval); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | buddyListInterval = setInterval(async () => { | |
| 80 | + | try { | |
| 81 | + | const now = Date.now(); | |
| 82 | + | // Only fetch and broadcast if 10 seconds have passed since last update | |
| 83 | + | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 84 | + | console.log('[SSE] Broadcasting buddy list update...'); | |
| 85 | + | // Query the complete buddy list from the database. | |
| 86 | + | const buddyList = await db.select().from(users); | |
| 87 | + | // Use createSafeUser to properly sanitize user data | |
| 88 | + | const safeBuddyList = buddyList.map(user => createSafeUser(user)); | |
| 89 | + | ||
| 90 | + | // Only broadcast if data has changed | |
| 91 | + | if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) { | |
| 92 | + | lastBuddyListData = safeBuddyList; | |
| 93 | + | sseEmitter.emit('sse', { | |
| 94 | + | type: 'buddyListUpdate', | |
| 95 | + | data: safeBuddyList | |
| 96 | + | }); | |
| 97 | + | } | |
| 98 | + | lastBuddyListUpdate = now; | |
| 99 | + | } | |
| 100 | + | } catch (error) { | |
| 101 | + | console.error('[SSE] Error fetching buddy list for update:', error); | |
| 102 | + | } | |
| 103 | + | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // <<< NEW: Ensure intervals are started only once >>> | |
| 107 | + | if (!globalThis.__sseIntervalsStarted) { | |
| 108 | + | startTimeoutCheck(); | |
| 109 | + | startBuddyListUpdateInterval(); | |
| 110 | + | globalThis.__sseIntervalsStarted = true; | |
| 111 | + | console.log('[SSE] Started global intervals for timeout and buddy list updates.'); | |
| 112 | + | } else { | |
| 113 | + | console.log('[SSE] Global intervals already started, skipping initialization.'); | |
| 114 | + | } | |
| 79 | 115 | ||
| 80 | 116 | // Ensure the interval is cleared if the module is reloaded | |
| 81 | 117 | if (typeof process !== 'undefined') { | |
| 82 | 118 | process.on('beforeExit', () => { | |
| 83 | 119 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 120 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 84 | 121 | }); | |
| 85 | 122 | ||
| 86 | 123 | // Also handle SIGTERM and SIGINT | |
| 87 | 124 | process.on('SIGTERM', () => { | |
| 88 | 125 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 126 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 89 | 127 | process.exit(0); | |
| 90 | 128 | }); | |
| 91 | 129 | ||
| 92 | 130 | process.on('SIGINT', () => { | |
| 93 | 131 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 132 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 94 | 133 | process.exit(0); | |
| 95 | 134 | }); | |
| 96 | 135 | } | |
| 97 | 136 | ||
| 98 | 137 | export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |
| 99 | - | // Add detailed debug logging for authentication state | |
| 100 | 138 | console.log('[SSE] Connection attempt:', { | |
| 101 | 139 | hasLocals: !!locals, | |
| 102 | 140 | hasUser: !!locals.user, | |
| @@ -120,7 +158,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 120 | 158 | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 121 | 159 | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | |
| 122 | 160 | ||
| 123 | - | // Update user status to online and lastSeen timestamp when establishing connection | |
| 124 | 161 | const now = Date.now(); | |
| 125 | 162 | ||
| 126 | 163 | try { | |
| @@ -136,7 +173,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 136 | 173 | timestamp: new Date(now).toISOString() | |
| 137 | 174 | }); | |
| 138 | 175 | ||
| 139 | - | // Broadcast the status update | |
| 140 | 176 | sseEmitter.emit('sse', { | |
| 141 | 177 | type: 'userStatusUpdate', | |
| 142 | 178 | data: { | |
| @@ -147,7 +183,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 147 | 183 | }); | |
| 148 | 184 | } catch (error) { | |
| 149 | 185 | console.error('[SSE] Error updating user status:', error); | |
| 150 | - | // Continue with SSE setup even if status update fails | |
| 151 | 186 | } | |
| 152 | 187 | ||
| 153 | 188 | const stream = new ReadableStream({ | |
| @@ -157,7 +192,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 157 | 192 | const payload = `event: ${event.type}\n` + | |
| 158 | 193 | `data: ${JSON.stringify(event.data)}\n\n`; | |
| 159 | 194 | controller.enqueue(encoder.encode(payload)); | |
| 160 | - | // Only log non-sensitive event types | |
| 161 | 195 | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | |
| 162 | 196 | console.log('[SSE] Event sent:', { type: event.type }); | |
| 163 | 197 | } | |
| @@ -179,10 +213,8 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 179 | 213 | }); | |
| 180 | 214 | console.log('[SSE] Added new listener'); | |
| 181 | 215 | ||
| 182 | - | // Send initial connection message | |
| 183 | 216 | controller.enqueue(encoder.encode('data: Connected\n\n')); | |
| 184 | 217 | ||
| 185 | - | // Keep connection alive and update lastSeen: send a comment every 20 seconds | |
| 186 | 218 | keepAliveInterval = setInterval(() => { | |
| 187 | 219 | controller.enqueue(encoder.encode(':\n\n')); | |
| 188 | 220 | }, 20000); | |
| @@ -193,13 +225,11 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||
| 193 | 225 | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |
| 194 | 226 | console.log('[SSE] Connection closed:', { userId: maskedUserId, reason }); | |
| 195 | 227 | ||
| 196 | - | // Update user status to offline in the database | |
| 197 | 228 | const now = Date.now(); | |
| 198 | 229 | await db.update(users) | |
| 199 | 230 | .set({ status: 'offline', lastSeen: now }) | |
| 200 | 231 | .where(eq(users.id, userId)); | |
| 201 | 232 | ||
| 202 | - | // Broadcast the status update | |
| 203 | 233 | sseEmitter.emit('sse', { | |
| 204 | 234 | type: 'userStatusUpdate', | |
| 205 | 235 | data: { | |
| @@ -1,8 +1,15 @@ | |||||
| 1 | + | // At the top of the file, before any code | |||
| 2 | + | declare global { | |||
| 3 | + | // eslint-disable-next-line no-var | |||
| 4 | + | var __sseIntervalsStarted: boolean; | |||
| 5 | + | } | |||
| 6 | + | ||||
| 1 | import type { RequestHandler } from './$types'; | 7 | import type { RequestHandler } from './$types'; | ||
| 2 | import { sseEmitter } from '$lib/sseEmitter'; | 8 | import { sseEmitter } from '$lib/sseEmitter'; | ||
| 3 | import db from '$lib/db/db.server'; | 9 | import db from '$lib/db/db.server'; | ||
| 4 | import { users } from '$lib/db/schema'; | 10 | import { users } from '$lib/db/schema'; | ||
| 5 | import { eq, and, lt } from 'drizzle-orm'; | 11 | import { eq, and, lt } from 'drizzle-orm'; | ||
| 12 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 6 | 13 | ||||
| 7 | console.log('[SSE] Server initialized'); | 14 | console.log('[SSE] Server initialized'); | ||
| 8 | 15 | ||||
| @@ -10,7 +17,15 @@ console.log('[SSE] Server initialized'); | |||||
| 10 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | 17 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | ||
| 11 | const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds | 18 | const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds | ||
| 12 | 19 | ||||
| 20 | + | // Update the interval constant | |||
| 21 | + | const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds | |||
| 22 | + | ||||
| 13 | let timeoutInterval: ReturnType<typeof setInterval>; | 23 | let timeoutInterval: ReturnType<typeof setInterval>; | ||
| 24 | + | let buddyListInterval: ReturnType<typeof setInterval>; | |||
| 25 | + | ||||
| 26 | + | // Add a timestamp tracker for buddy list updates | |||
| 27 | + | let lastBuddyListUpdate = 0; | |||
| 28 | + | let lastBuddyListData: unknown[] = []; | |||
| 14 | 29 | ||||
| 15 | // Initialize periodic check for timed out users | 30 | // Initialize periodic check for timed out users | ||
| 16 | function startTimeoutCheck() { | 31 | function startTimeoutCheck() { | ||
| @@ -26,7 +41,6 @@ function startTimeoutCheck() { | |||||
| 26 | }); | 41 | }); | ||
| 27 | 42 | ||||
| 28 | try { | 43 | try { | ||
| 29 | - | // Find and update users who haven't been seen recently | |||
| 30 | const now = Date.now(); | 44 | const now = Date.now(); | ||
| 31 | const result = await db.update(users) | 45 | const result = await db.update(users) | ||
| 32 | .set({ | 46 | .set({ | ||
| @@ -45,58 +59,82 @@ function startTimeoutCheck() { | |||||
| 45 | lastSeen: users.lastSeen | 59 | lastSeen: users.lastSeen | ||
| 46 | }); | 60 | }); | ||
| 47 | 61 | ||||
| 48 | - | // Log timeout results | |||
| 49 | if (result.length > 0) { | 62 | if (result.length > 0) { | ||
| 50 | console.log('[SSE] Found timed out users:', result.map(user => ({ | 63 | console.log('[SSE] Found timed out users:', result.map(user => ({ | ||
| 51 | nickname: user.nickname, | 64 | nickname: user.nickname, | ||
| 52 | lastSeen: new Date(user.lastSeen || 0).toISOString() | 65 | lastSeen: new Date(user.lastSeen || 0).toISOString() | ||
| 53 | }))); | 66 | }))); | ||
| 54 | } | 67 | } | ||
| 55 | - | ||||
| 56 | - | // Broadcast status updates for timed out users | |||
| 57 | - | result.forEach(user => { | |||
| 58 | - | sseEmitter.emit('sse', { | |||
| 59 | - | type: 'userStatusUpdate', | |||
| 60 | - | data: { | |||
| 61 | - | userId: user.id, | |||
| 62 | - | status: 'offline', | |||
| 63 | - | lastSeen: now | |||
| 64 | - | } | |||
| 65 | - | }); | |||
| 66 | - | console.log('[SSE] User timed out and marked offline:', { | |||
| 67 | - | nickname: user.nickname, | |||
| 68 | - | lastSeen: new Date(now).toISOString() | |||
| 69 | - | }); | |||
| 70 | - | }); | |||
| 71 | } catch (error) { | 68 | } catch (error) { | ||
| 72 | console.error('[SSE] Error during timeout check:', error); | 69 | console.error('[SSE] Error during timeout check:', error); | ||
| 73 | } | 70 | } | ||
| 74 | }, CHECK_INTERVAL_MS); | 71 | }, CHECK_INTERVAL_MS); | ||
| 75 | } | 72 | } | ||
| 76 | 73 | ||||
| 77 | - | // Start the timeout check when this module loads | 74 | + | function startBuddyListUpdateInterval() { |
| 78 | - | startTimeoutCheck(); | 75 | + | if (buddyListInterval) { |
| 76 | + | clearInterval(buddyListInterval); | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | buddyListInterval = setInterval(async () => { | |||
| 80 | + | try { | |||
| 81 | + | const now = Date.now(); | |||
| 82 | + | // Only fetch and broadcast if 10 seconds have passed since last update | |||
| 83 | + | if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) { | |||
| 84 | + | console.log('[SSE] Broadcasting buddy list update...'); | |||
| 85 | + | // Query the complete buddy list from the database. | |||
| 86 | + | const buddyList = await db.select().from(users); | |||
| 87 | + | // Use createSafeUser to properly sanitize user data | |||
| 88 | + | const safeBuddyList = buddyList.map(user => createSafeUser(user)); | |||
| 89 | + | ||||
| 90 | + | // Only broadcast if data has changed | |||
| 91 | + | if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) { | |||
| 92 | + | lastBuddyListData = safeBuddyList; | |||
| 93 | + | sseEmitter.emit('sse', { | |||
| 94 | + | type: 'buddyListUpdate', | |||
| 95 | + | data: safeBuddyList | |||
| 96 | + | }); | |||
| 97 | + | } | |||
| 98 | + | lastBuddyListUpdate = now; | |||
| 99 | + | } | |||
| 100 | + | } catch (error) { | |||
| 101 | + | console.error('[SSE] Error fetching buddy list for update:', error); | |||
| 102 | + | } | |||
| 103 | + | }, 1000); // Check every second but only broadcast every 10 seconds | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // <<< NEW: Ensure intervals are started only once >>> | |||
| 107 | + | if (!globalThis.__sseIntervalsStarted) { | |||
| 108 | + | startTimeoutCheck(); | |||
| 109 | + | startBuddyListUpdateInterval(); | |||
| 110 | + | globalThis.__sseIntervalsStarted = true; | |||
| 111 | + | console.log('[SSE] Started global intervals for timeout and buddy list updates.'); | |||
| 112 | + | } else { | |||
| 113 | + | console.log('[SSE] Global intervals already started, skipping initialization.'); | |||
| 114 | + | } | |||
| 79 | 115 | ||||
| 80 | // Ensure the interval is cleared if the module is reloaded | 116 | // Ensure the interval is cleared if the module is reloaded | ||
| 81 | if (typeof process !== 'undefined') { | 117 | if (typeof process !== 'undefined') { | ||
| 82 | process.on('beforeExit', () => { | 118 | process.on('beforeExit', () => { | ||
| 83 | if (timeoutInterval) clearInterval(timeoutInterval); | 119 | if (timeoutInterval) clearInterval(timeoutInterval); | ||
| 120 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |||
| 84 | }); | 121 | }); | ||
| 85 | 122 | ||||
| 86 | // Also handle SIGTERM and SIGINT | 123 | // Also handle SIGTERM and SIGINT | ||
| 87 | process.on('SIGTERM', () => { | 124 | process.on('SIGTERM', () => { | ||
| 88 | if (timeoutInterval) clearInterval(timeoutInterval); | 125 | if (timeoutInterval) clearInterval(timeoutInterval); | ||
| 126 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |||
| 89 | process.exit(0); | 127 | process.exit(0); | ||
| 90 | }); | 128 | }); | ||
| 91 | 129 | ||||
| 92 | process.on('SIGINT', () => { | 130 | process.on('SIGINT', () => { | ||
| 93 | if (timeoutInterval) clearInterval(timeoutInterval); | 131 | if (timeoutInterval) clearInterval(timeoutInterval); | ||
| 132 | + | if (buddyListInterval) clearInterval(buddyListInterval); | |||
| 94 | process.exit(0); | 133 | process.exit(0); | ||
| 95 | }); | 134 | }); | ||
| 96 | } | 135 | } | ||
| 97 | 136 | ||||
| 98 | export const GET: RequestHandler = async ({ request, locals, cookies }) => { | 137 | export const GET: RequestHandler = async ({ request, locals, cookies }) => { | ||
| 99 | - | // Add detailed debug logging for authentication state | |||
| 100 | console.log('[SSE] Connection attempt:', { | 138 | console.log('[SSE] Connection attempt:', { | ||
| 101 | hasLocals: !!locals, | 139 | hasLocals: !!locals, | ||
| 102 | hasUser: !!locals.user, | 140 | hasUser: !!locals.user, | ||
| @@ -120,7 +158,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 120 | let keepAliveInterval: ReturnType<typeof setInterval>; | 158 | let keepAliveInterval: ReturnType<typeof setInterval>; | ||
| 121 | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | 159 | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | ||
| 122 | 160 | ||||
| 123 | - | // Update user status to online and lastSeen timestamp when establishing connection | |||
| 124 | const now = Date.now(); | 161 | const now = Date.now(); | ||
| 125 | 162 | ||||
| 126 | try { | 163 | try { | ||
| @@ -136,7 +173,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 136 | timestamp: new Date(now).toISOString() | 173 | timestamp: new Date(now).toISOString() | ||
| 137 | }); | 174 | }); | ||
| 138 | 175 | ||||
| 139 | - | // Broadcast the status update | |||
| 140 | sseEmitter.emit('sse', { | 176 | sseEmitter.emit('sse', { | ||
| 141 | type: 'userStatusUpdate', | 177 | type: 'userStatusUpdate', | ||
| 142 | data: { | 178 | data: { | ||
| @@ -147,7 +183,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 147 | }); | 183 | }); | ||
| 148 | } catch (error) { | 184 | } catch (error) { | ||
| 149 | console.error('[SSE] Error updating user status:', error); | 185 | console.error('[SSE] Error updating user status:', error); | ||
| 150 | - | // Continue with SSE setup even if status update fails | |||
| 151 | } | 186 | } | ||
| 152 | 187 | ||||
| 153 | const stream = new ReadableStream({ | 188 | const stream = new ReadableStream({ | ||
| @@ -157,7 +192,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 157 | const payload = `event: ${event.type}\n` + | 192 | const payload = `event: ${event.type}\n` + | ||
| 158 | `data: ${JSON.stringify(event.data)}\n\n`; | 193 | `data: ${JSON.stringify(event.data)}\n\n`; | ||
| 159 | controller.enqueue(encoder.encode(payload)); | 194 | controller.enqueue(encoder.encode(payload)); | ||
| 160 | - | // Only log non-sensitive event types | |||
| 161 | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | 195 | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | ||
| 162 | console.log('[SSE] Event sent:', { type: event.type }); | 196 | console.log('[SSE] Event sent:', { type: event.type }); | ||
| 163 | } | 197 | } | ||
| @@ -179,10 +213,8 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 179 | }); | 213 | }); | ||
| 180 | console.log('[SSE] Added new listener'); | 214 | console.log('[SSE] Added new listener'); | ||
| 181 | 215 | ||||
| 182 | - | // Send initial connection message | |||
| 183 | controller.enqueue(encoder.encode('data: Connected\n\n')); | 216 | controller.enqueue(encoder.encode('data: Connected\n\n')); | ||
| 184 | 217 | ||||
| 185 | - | // Keep connection alive and update lastSeen: send a comment every 20 seconds | |||
| 186 | keepAliveInterval = setInterval(() => { | 218 | keepAliveInterval = setInterval(() => { | ||
| 187 | controller.enqueue(encoder.encode(':\n\n')); | 219 | controller.enqueue(encoder.encode(':\n\n')); | ||
| 188 | }, 20000); | 220 | }, 20000); | ||
| @@ -193,13 +225,11 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => { | |||||
| 193 | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | 225 | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | ||
| 194 | console.log('[SSE] Connection closed:', { userId: maskedUserId, reason }); | 226 | console.log('[SSE] Connection closed:', { userId: maskedUserId, reason }); | ||
| 195 | 227 | ||||
| 196 | - | // Update user status to offline in the database | |||
| 197 | const now = Date.now(); | 228 | const now = Date.now(); | ||
| 198 | await db.update(users) | 229 | await db.update(users) | ||
| 199 | .set({ status: 'offline', lastSeen: now }) | 230 | .set({ status: 'offline', lastSeen: now }) | ||
| 200 | .where(eq(users.id, userId)); | 231 | .where(eq(users.id, userId)); | ||
| 201 | 232 | ||||
| 202 | - | // Broadcast the status update | |||
| 203 | sseEmitter.emit('sse', { | 233 | sseEmitter.emit('sse', { | ||
| 204 | type: 'userStatusUpdate', | 234 | type: 'userStatusUpdate', | ||
| 205 | data: { | 235 | data: { | ||
M src/routes/api/status/+server.ts +11 -13
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | import { error } from '@sveltejs/kit'; | |
| 2 | - | import { sseEmitter } from '$lib/sseEmitter'; | |
| 3 | 2 | import db from '$lib/db/db.server'; | |
| 4 | 3 | import { users } from '$lib/db/schema'; | |
| 5 | 4 | import { eq } from 'drizzle-orm'; | |
| 6 | 5 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 7 | 6 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 7 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 8 | 8 | ||
| 9 | 9 | export async function POST({ request, cookies, locals }) { | |
| 10 | 10 | if (!locals.user) { | |
| @@ -25,12 +25,14 @@ export async function POST({ request, cookies, locals }) { | |||
| 25 | 25 | ||
| 26 | 26 | // Update the user status in the database | |
| 27 | 27 | // Always update lastSeen to support timeout detection | |
| 28 | - | await db.update(users) | |
| 28 | + | const updatedUser = await db.update(users) | |
| 29 | 29 | .set({ | |
| 30 | 30 | status, | |
| 31 | 31 | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 32 | 32 | }) | |
| 33 | - | .where(eq(users.id, userId)); | |
| 33 | + | .where(eq(users.id, userId)) | |
| 34 | + | .returning() | |
| 35 | + | .get(); | |
| 34 | 36 | console.log('[Status] Database updated successfully'); | |
| 35 | 37 | ||
| 36 | 38 | // Renew session if status is online and session is near expiry | |
| @@ -51,15 +53,11 @@ export async function POST({ request, cookies, locals }) { | |||
| 51 | 53 | } | |
| 52 | 54 | } | |
| 53 | 55 | ||
| 54 | - | // Broadcast the status update via SSE | |
| 55 | - | sseEmitter.emit('sse', { | |
| 56 | - | type: 'userStatusUpdate', | |
| 57 | - | data: { | |
| 58 | - | userId, | |
| 59 | - | status, | |
| 60 | - | lastSeen: now // Always send the current timestamp | |
| 61 | - | } | |
| 56 | + | return new Response(JSON.stringify({ | |
| 57 | + | success: true, | |
| 58 | + | user: createSafeUser(updatedUser) | |
| 59 | + | }), { | |
| 60 | + | status: 200, | |
| 61 | + | headers: { 'Content-Type': 'application/json' } | |
| 62 | 62 | }); | |
| 63 | - | ||
| 64 | - | return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); | |
| 65 | 63 | }⏎̸ | |
| @@ -1,10 +1,10 @@ | |||||
| 1 | import { error } from '@sveltejs/kit'; | 1 | import { error } from '@sveltejs/kit'; | ||
| 2 | - | import { sseEmitter } from '$lib/sseEmitter'; | |||
| 3 | import db from '$lib/db/db.server'; | 2 | import db from '$lib/db/db.server'; | ||
| 4 | import { users } from '$lib/db/schema'; | 3 | import { users } from '$lib/db/schema'; | ||
| 5 | import { eq } from 'drizzle-orm'; | 4 | import { eq } from 'drizzle-orm'; | ||
| 6 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | 5 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | ||
| 7 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | 6 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | ||
| 7 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 8 | 8 | ||||
| 9 | export async function POST({ request, cookies, locals }) { | 9 | export async function POST({ request, cookies, locals }) { | ||
| 10 | if (!locals.user) { | 10 | if (!locals.user) { | ||
| @@ -25,12 +25,14 @@ export async function POST({ request, cookies, locals }) { | |||||
| 25 | 25 | ||||
| 26 | // Update the user status in the database | 26 | // Update the user status in the database | ||
| 27 | // Always update lastSeen to support timeout detection | 27 | // Always update lastSeen to support timeout detection | ||
| 28 | - | await db.update(users) | 28 | + | const updatedUser = await db.update(users) |
| 29 | .set({ | 29 | .set({ | ||
| 30 | status, | 30 | status, | ||
| 31 | lastSeen: now // Always update lastSeen for proper timeout detection | 31 | lastSeen: now // Always update lastSeen for proper timeout detection | ||
| 32 | }) | 32 | }) | ||
| 33 | - | .where(eq(users.id, userId)); | 33 | + | .where(eq(users.id, userId)) |
| 34 | + | .returning() | |||
| 35 | + | .get(); | |||
| 34 | console.log('[Status] Database updated successfully'); | 36 | console.log('[Status] Database updated successfully'); | ||
| 35 | 37 | ||||
| 36 | // Renew session if status is online and session is near expiry | 38 | // Renew session if status is online and session is near expiry | ||
| @@ -51,15 +53,11 @@ export async function POST({ request, cookies, locals }) { | |||||
| 51 | } | 53 | } | ||
| 52 | } | 54 | } | ||
| 53 | 55 | ||||
| 54 | - | // Broadcast the status update via SSE | 56 | + | return new Response(JSON.stringify({ |
| 55 | - | sseEmitter.emit('sse', { | 57 | + | success: true, |
| 56 | - | type: 'userStatusUpdate', | 58 | + | user: createSafeUser(updatedUser) |
| 57 | - | data: { | 59 | + | }), { |
| 58 | - | userId, | 60 | + | status: 200, |
| 59 | - | status, | 61 | + | headers: { 'Content-Type': 'application/json' } |
| 60 | - | lastSeen: now // Always send the current timestamp | |||
| 61 | - | } | |||
| 62 | }); | 62 | }); | ||
| 63 | - | ||||
| 64 | - | return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); | |||
| 65 | }⏎̸ | 63 | }⏎̸ | ||
M src/routes/api/users/[userId]/+server.ts +7 -10
| @@ -3,19 +3,14 @@ import db from '$lib/db/db.server'; | |||
| 3 | 3 | import { users } from '$lib/db/schema'; | |
| 4 | 4 | import { eq } from 'drizzle-orm'; | |
| 5 | 5 | import type { RequestHandler } from './$types'; | |
| 6 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 6 | 7 | ||
| 7 | 8 | export const GET: RequestHandler = async ({ params }) => { | |
| 8 | 9 | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | |
| 9 | 10 | console.log('[Users] Fetching user data:', { userId: maskedUserId }); | |
| 10 | 11 | ||
| 11 | 12 | try { | |
| 12 | - | const user = await db.select({ | |
| 13 | - | id: users.id, | |
| 14 | - | nickname: users.nickname, | |
| 15 | - | status: users.status, | |
| 16 | - | avatarUrl: users.avatarUrl, | |
| 17 | - | lastSeen: users.lastSeen | |
| 18 | - | }) | |
| 13 | + | const user = await db.select() | |
| 19 | 14 | .from(users) | |
| 20 | 15 | .where(eq(users.id, params.userId)) | |
| 21 | 16 | .get(); | |
| @@ -25,14 +20,16 @@ export const GET: RequestHandler = async ({ params }) => { | |||
| 25 | 20 | throw error(404, 'User not found'); | |
| 26 | 21 | } | |
| 27 | 22 | ||
| 23 | + | const safeUser = createSafeUser(user); | |
| 24 | + | ||
| 28 | 25 | console.log('[Users] User data retrieved:', { | |
| 29 | 26 | userId: maskedUserId, | |
| 30 | - | status: user.status, | |
| 31 | - | lastSeen: user.lastSeen ? new Date(user.lastSeen).toISOString() : null | |
| 27 | + | status: safeUser.status, | |
| 28 | + | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null | |
| 32 | 29 | }); | |
| 33 | 30 | return json({ | |
| 34 | 31 | success: true, | |
| 35 | - | user | |
| 32 | + | user: safeUser | |
| 36 | 33 | }); | |
| 37 | 34 | } catch { | |
| 38 | 35 | console.log('[Users] Error fetching user data:', { userId: maskedUserId }); | |
| @@ -3,19 +3,14 @@ import db from '$lib/db/db.server'; | |||||
| 3 | import { users } from '$lib/db/schema'; | 3 | import { users } from '$lib/db/schema'; | ||
| 4 | import { eq } from 'drizzle-orm'; | 4 | import { eq } from 'drizzle-orm'; | ||
| 5 | import type { RequestHandler } from './$types'; | 5 | import type { RequestHandler } from './$types'; | ||
| 6 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 6 | 7 | ||||
| 7 | export const GET: RequestHandler = async ({ params }) => { | 8 | export const GET: RequestHandler = async ({ params }) => { | ||
| 8 | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | 9 | const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`; | ||
| 9 | console.log('[Users] Fetching user data:', { userId: maskedUserId }); | 10 | console.log('[Users] Fetching user data:', { userId: maskedUserId }); | ||
| 10 | 11 | ||||
| 11 | try { | 12 | try { | ||
| 12 | - | const user = await db.select({ | 13 | + | const user = await db.select() |
| 13 | - | id: users.id, | |||
| 14 | - | nickname: users.nickname, | |||
| 15 | - | status: users.status, | |||
| 16 | - | avatarUrl: users.avatarUrl, | |||
| 17 | - | lastSeen: users.lastSeen | |||
| 18 | - | }) | |||
| 19 | .from(users) | 14 | .from(users) | ||
| 20 | .where(eq(users.id, params.userId)) | 15 | .where(eq(users.id, params.userId)) | ||
| 21 | .get(); | 16 | .get(); | ||
| @@ -25,14 +20,16 @@ export const GET: RequestHandler = async ({ params }) => { | |||||
| 25 | throw error(404, 'User not found'); | 20 | throw error(404, 'User not found'); | ||
| 26 | } | 21 | } | ||
| 27 | 22 | ||||
| 23 | + | const safeUser = createSafeUser(user); | |||
| 24 | + | ||||
| 28 | console.log('[Users] User data retrieved:', { | 25 | console.log('[Users] User data retrieved:', { | ||
| 29 | userId: maskedUserId, | 26 | userId: maskedUserId, | ||
| 30 | - | status: user.status, | 27 | + | status: safeUser.status, |
| 31 | - | lastSeen: user.lastSeen ? new Date(user.lastSeen).toISOString() : null | 28 | + | lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null |
| 32 | }); | 29 | }); | ||
| 33 | return json({ | 30 | return json({ | ||
| 34 | success: true, | 31 | success: true, | ||
| 35 | - | user | 32 | + | user: safeUser |
| 36 | }); | 33 | }); | ||
| 37 | } catch { | 34 | } catch { | ||
| 38 | console.log('[Users] Error fetching user data:', { userId: maskedUserId }); | 35 | console.log('[Users] Error fetching user data:', { userId: maskedUserId }); | ||