fix(security): prevent sensitive user data exposure by enforcing SafeUser type throughout frontend
- commit
- 93e8e7a9fe6d04110cb3b1b3bc5ec5f4b6efb348
- author
- Kit · · 2025-02-17 10:29 UTC
- parent
- 17f3fb1
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
3 files · +59 -57
M src/lib/components/aim-login.svelte +14 -15
| @@ -2,20 +2,19 @@ | |||
| 2 | 2 | <script lang="ts"> | |
| 3 | 3 | import { draggable } from '$lib/actions/draggable'; | |
| 4 | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | - | import type { User } from '$lib/types/chat'; | |
| 5 | + | import type { User, SafeUser } from '$lib/types/chat'; | |
| 6 | 6 | import { invalidateAll } from '$app/navigation'; | |
| 7 | 7 | import { chatState } from '$lib/states/chat.svelte'; | |
| 8 | 8 | import Turnstile from './turnstile.svelte'; | |
| 9 | + | import { createSafeUser } from '$lib/types/chat'; | |
| 9 | 10 | ||
| 10 | - | let { | |
| 11 | - | onLoginSuccess, | |
| 12 | - | showAuth = $bindable(), | |
| 13 | - | activeTab: initialTab = 'signin' | |
| 14 | - | }: { | |
| 15 | - | onLoginSuccess?: (user: User) => void, | |
| 16 | - | showAuth: Boolean, | |
| 17 | - | activeTab?: 'signin' | 'signup' | |
| 18 | - | } = $props(); | |
| 11 | + | interface $$Props { | |
| 12 | + | showAuth: boolean; | |
| 13 | + | onLoginSuccess?: (user: SafeUser | null) => void; | |
| 14 | + | activeTab?: 'signin' | 'signup'; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | let { showAuth, onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); | |
| 19 | 18 | ||
| 20 | 19 | let activeTab = $state(initialTab); | |
| 21 | 20 | ||
| @@ -69,12 +68,12 @@ | |||
| 69 | 68 | return; | |
| 70 | 69 | } | |
| 71 | 70 | ||
| 72 | - | console.debug("Login succeeded:", data); | |
| 73 | - | await chatState.setCurrentUser(data.user); | |
| 71 | + | console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null }); | |
| 72 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 74 | 73 | loginStatus = 'success'; | |
| 75 | 74 | ||
| 76 | 75 | // Call the callback prop if provided | |
| 77 | - | onLoginSuccess?.(data.user); | |
| 76 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 78 | 77 | // Auto close login window after 3 seconds on successful login | |
| 79 | 78 | setTimeout(() => { | |
| 80 | 79 | console.debug("Auto closing AIM Login component after successful login"); | |
| @@ -152,8 +151,8 @@ | |||
| 152 | 151 | return; | |
| 153 | 152 | } | |
| 154 | 153 | console.debug("Auto login after registration succeeded:", data); | |
| 155 | - | await chatState.setCurrentUser(data.user); | |
| 156 | - | onLoginSuccess?.(data.user); | |
| 154 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); | |
| 155 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); | |
| 157 | 156 | setTimeout(() => { | |
| 158 | 157 | console.debug("Auto closing AIM Login component after auto login on registration"); | |
| 159 | 158 | invalidateAll(); | |
| @@ -2,20 +2,19 @@ | |||||
| 2 | <script lang="ts"> | 2 | <script lang="ts"> | ||
| 3 | import { draggable } from '$lib/actions/draggable'; | 3 | import { draggable } from '$lib/actions/draggable'; | ||
| 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | ||
| 5 | - | import type { User } from '$lib/types/chat'; | 5 | + | import type { User, SafeUser } from '$lib/types/chat'; |
| 6 | import { invalidateAll } from '$app/navigation'; | 6 | import { invalidateAll } from '$app/navigation'; | ||
| 7 | import { chatState } from '$lib/states/chat.svelte'; | 7 | import { chatState } from '$lib/states/chat.svelte'; | ||
| 8 | import Turnstile from './turnstile.svelte'; | 8 | import Turnstile from './turnstile.svelte'; | ||
| 9 | + | import { createSafeUser } from '$lib/types/chat'; | |||
| 9 | 10 | ||||
| 10 | - | let { | 11 | + | interface $$Props { |
| 11 | - | onLoginSuccess, | 12 | + | showAuth: boolean; |
| 12 | - | showAuth = $bindable(), | 13 | + | onLoginSuccess?: (user: SafeUser | null) => void; |
| 13 | - | activeTab: initialTab = 'signin' | 14 | + | activeTab?: 'signin' | 'signup'; |
| 14 | - | }: { | 15 | + | } |
| 15 | - | onLoginSuccess?: (user: User) => void, | 16 | + | |
| 16 | - | showAuth: Boolean, | 17 | + | let { showAuth, onLoginSuccess, activeTab: initialTab = 'signin' } = $props(); |
| 17 | - | activeTab?: 'signin' | 'signup' | |||
| 18 | - | } = $props(); | |||
| 19 | 18 | ||||
| 20 | let activeTab = $state(initialTab); | 19 | let activeTab = $state(initialTab); | ||
| 21 | 20 | ||||
| @@ -69,12 +68,12 @@ | |||||
| 69 | return; | 68 | return; | ||
| 70 | } | 69 | } | ||
| 71 | 70 | ||||
| 72 | - | console.debug("Login succeeded:", data); | 71 | + | console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null }); |
| 73 | - | await chatState.setCurrentUser(data.user); | 72 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); |
| 74 | loginStatus = 'success'; | 73 | loginStatus = 'success'; | ||
| 75 | 74 | ||||
| 76 | // Call the callback prop if provided | 75 | // Call the callback prop if provided | ||
| 77 | - | onLoginSuccess?.(data.user); | 76 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); |
| 78 | // Auto close login window after 3 seconds on successful login | 77 | // Auto close login window after 3 seconds on successful login | ||
| 79 | setTimeout(() => { | 78 | setTimeout(() => { | ||
| 80 | console.debug("Auto closing AIM Login component after successful login"); | 79 | console.debug("Auto closing AIM Login component after successful login"); | ||
| @@ -152,8 +151,8 @@ | |||||
| 152 | return; | 151 | return; | ||
| 153 | } | 152 | } | ||
| 154 | console.debug("Auto login after registration succeeded:", data); | 153 | console.debug("Auto login after registration succeeded:", data); | ||
| 155 | - | await chatState.setCurrentUser(data.user); | 154 | + | await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null); |
| 156 | - | onLoginSuccess?.(data.user); | 155 | + | onLoginSuccess?.(data.user ? createSafeUser(data.user) : null); |
| 157 | setTimeout(() => { | 156 | setTimeout(() => { | ||
| 158 | console.debug("Auto closing AIM Login component after auto login on registration"); | 157 | console.debug("Auto closing AIM Login component after auto login on registration"); | ||
| 159 | invalidateAll(); | 158 | invalidateAll(); | ||
M src/lib/components/chat-room.svelte +3 -3
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | - | import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat'; | |
| 3 | + | import type { MessageType, User, ChatRoom, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 4 | 4 | import { onMount } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| 6 | 6 | import { draggable } from '$lib/actions/draggable'; | |
| @@ -63,8 +63,8 @@ function handleClose() { | |||
| 63 | 63 | ||
| 64 | 64 | // Reactive state using derived values | |
| 65 | 65 | let messages = $state<EnrichedMessage[]>([]); | |
| 66 | - | let onlineUsers = $state<User[]>([]); | |
| 67 | - | let currentUser = $state<User | null>(null); | |
| 66 | + | let onlineUsers = $state<SafeUser[]>([]); | |
| 67 | + | let currentUser = $state<SafeUser | null>(null); | |
| 68 | 68 | let isLoadingMore = $state(false); | |
| 69 | 69 | let hasMoreMessages = $state(true); | |
| 70 | 70 | let oldestMessageTimestamp = $state<number | null>(null); | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | <script lang="ts"> | 1 | <script lang="ts"> | ||
| 2 | import { chatState } from '../states/chat.svelte'; | 2 | import { chatState } from '../states/chat.svelte'; | ||
| 3 | - | import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat'; | 3 | + | import type { MessageType, User, ChatRoom, Message, EnrichedMessage, SafeUser } from '../types/chat'; |
| 4 | import { onMount } from 'svelte'; | 4 | import { onMount } from 'svelte'; | ||
| 5 | import { browser } from '$app/environment'; | 5 | import { browser } from '$app/environment'; | ||
| 6 | import { draggable } from '$lib/actions/draggable'; | 6 | import { draggable } from '$lib/actions/draggable'; | ||
| @@ -63,8 +63,8 @@ function handleClose() { | |||||
| 63 | 63 | ||||
| 64 | // Reactive state using derived values | 64 | // Reactive state using derived values | ||
| 65 | let messages = $state<EnrichedMessage[]>([]); | 65 | let messages = $state<EnrichedMessage[]>([]); | ||
| 66 | - | let onlineUsers = $state<User[]>([]); | 66 | + | let onlineUsers = $state<SafeUser[]>([]); |
| 67 | - | let currentUser = $state<User | null>(null); | 67 | + | let currentUser = $state<SafeUser | null>(null); |
| 68 | let isLoadingMore = $state(false); | 68 | let isLoadingMore = $state(false); | ||
| 69 | let hasMoreMessages = $state(true); | 69 | let hasMoreMessages = $state(true); | ||
| 70 | let oldestMessageTimestamp = $state<number | null>(null); | 70 | let oldestMessageTimestamp = $state<number | null>(null); | ||
M src/lib/states/chat.svelte.ts +42 -39
| @@ -1,4 +1,5 @@ | |||
| 1 | - | import type { User, Message, EnrichedMessage } from '../types/chat'; | |
| 1 | + | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 2 | + | import { createSafeUser } from '../types/chat'; | |
| 2 | 3 | import type { | |
| 3 | 4 | SendMessageRequest, | |
| 4 | 5 | SendMessageResponse, | |
| @@ -11,16 +12,16 @@ import { env } from '$env/dynamic/public'; | |||
| 11 | 12 | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 12 | 13 | ||
| 13 | 14 | class ChatState { | |
| 14 | - | private users = $state<User[]>([]); | |
| 15 | + | private users = $state<SafeUser[]>([]); | |
| 15 | 16 | private messages = $state<Message[]>([]); | |
| 16 | - | private currentUser = $state<User | null>(null); | |
| 17 | + | private currentUser = $state<SafeUser | null>(null); | |
| 17 | 18 | private eventSource: EventSource | null = null; | |
| 18 | 19 | private reconnectAttempts = 0; | |
| 19 | 20 | private maxReconnectAttempts = 5; | |
| 20 | 21 | private reconnectDelay = 1000; // Start with 1 second | |
| 21 | 22 | private isInitializing = $state(false); | |
| 22 | 23 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 23 | - | private userCache = $state<Record<string, User>>({}); | |
| 24 | + | private userCache = $state<Record<string, SafeUser>>({}); | |
| 24 | 25 | private sseError = $state<string | null>(null); | |
| 25 | 26 | private sseRetryAfter = $state<number | null>(null); | |
| 26 | 27 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | |
| @@ -80,8 +81,8 @@ class ChatState { | |||
| 80 | 81 | return this.currentUser; | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | - | async setCurrentUser(user: User | null) { | |
| 84 | - | console.debug('setCurrentUser called with', user); | |
| 84 | + | async setCurrentUser(user: User | SafeUser | null) { | |
| 85 | + | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 85 | 86 | ||
| 86 | 87 | // Prevent concurrent setCurrentUser calls | |
| 87 | 88 | if (this.isSettingUser) { | |
| @@ -97,9 +98,12 @@ class ChatState { | |||
| 97 | 98 | console.debug('No user to set and no current user, skipping'); | |
| 98 | 99 | return; | |
| 99 | 100 | } | |
| 101 | + | ||
| 102 | + | // Convert to SafeUser if full User object is passed | |
| 103 | + | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 100 | 104 | ||
| 101 | 105 | // If same user is being set, check SSE connection | |
| 102 | - | if (user?.id === this.currentUser?.id) { | |
| 106 | + | if (safeUser?.id === this.currentUser?.id) { | |
| 103 | 107 | if (!this.eventSource) { | |
| 104 | 108 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 105 | 109 | } else { | |
| @@ -110,11 +114,11 @@ class ChatState { | |||
| 110 | 114 | ||
| 111 | 115 | // Clear existing messages before setting new user | |
| 112 | 116 | this.messages = []; | |
| 113 | - | this.currentUser = user; | |
| 117 | + | this.currentUser = safeUser; | |
| 114 | 118 | ||
| 115 | - | if (user) { | |
| 119 | + | if (safeUser) { | |
| 116 | 120 | // Update user cache with current user | |
| 117 | - | this.userCache[user.id] = user; | |
| 121 | + | this.userCache[safeUser.id] = safeUser; | |
| 118 | 122 | ||
| 119 | 123 | // Only reinitialize if we have a valid user | |
| 120 | 124 | await this.reinitialize(); | |
| @@ -178,8 +182,8 @@ class ChatState { | |||
| 178 | 182 | ||
| 179 | 183 | getOnlineUsers() { | |
| 180 | 184 | // Sort users by status: online first, then away, then busy, then offline | |
| 181 | - | return [...this.users].sort((a: User, b: User) => { | |
| 182 | - | const statusOrder: Record<User['status'], number> = { | |
| 185 | + | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 186 | + | const statusOrder: Record<SafeUser['status'], number> = { | |
| 183 | 187 | 'online': 0, | |
| 184 | 188 | 'away': 1, | |
| 185 | 189 | 'busy': 2, | |
| @@ -196,12 +200,10 @@ class ChatState { | |||
| 196 | 200 | } | |
| 197 | 201 | ||
| 198 | 202 | // Return a fallback object and cache it to avoid repeated fetches | |
| 199 | - | const fallback = { | |
| 203 | + | const fallback: SafeUser = { | |
| 200 | 204 | id: userId, | |
| 201 | 205 | nickname: 'Unknown User', | |
| 202 | 206 | status: 'offline', | |
| 203 | - | password: '', // system user, no password needed | |
| 204 | - | createdAt: Date.now(), | |
| 205 | 207 | lastSeen: null, | |
| 206 | 208 | avatarUrl: null | |
| 207 | 209 | }; | |
| @@ -231,11 +233,11 @@ class ChatState { | |||
| 231 | 233 | } | |
| 232 | 234 | ||
| 233 | 235 | // Try to get the user from cache first | |
| 234 | - | let user: User | undefined = this.userCache[userId]; | |
| 236 | + | let user: SafeUser | undefined = this.userCache[userId]; | |
| 235 | 237 | ||
| 236 | 238 | // If not in cache, try to find in users array | |
| 237 | 239 | if (!user) { | |
| 238 | - | const foundUser = this.users.find((u: User) => u.id === userId); | |
| 240 | + | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 239 | 241 | if (foundUser) { | |
| 240 | 242 | user = foundUser; | |
| 241 | 243 | } | |
| @@ -243,10 +245,9 @@ class ChatState { | |||
| 243 | 245 | ||
| 244 | 246 | const now = Date.now(); | |
| 245 | 247 | if (user) { | |
| 246 | - | const updatedUser: User = { | |
| 248 | + | const updatedUser: SafeUser = { | |
| 247 | 249 | ...user, | |
| 248 | 250 | status, | |
| 249 | - | // Update lastSeen based on status | |
| 250 | 251 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 251 | 252 | }; | |
| 252 | 253 | ||
| @@ -254,7 +255,7 @@ class ChatState { | |||
| 254 | 255 | this.userCache[userId] = updatedUser; | |
| 255 | 256 | ||
| 256 | 257 | // Then update or add to users array | |
| 257 | - | const index = this.users.findIndex((u: User) => u.id === userId); | |
| 258 | + | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 258 | 259 | if (index !== -1) { | |
| 259 | 260 | // Create new array with updated user | |
| 260 | 261 | this.users = [ | |
| @@ -280,7 +281,7 @@ class ChatState { | |||
| 280 | 281 | .then(response => response.json()) | |
| 281 | 282 | .then(data => { | |
| 282 | 283 | if (data.success && data.user) { | |
| 283 | - | const newUser: User = { | |
| 284 | + | const newUser: SafeUser = { | |
| 284 | 285 | ...data.user, | |
| 285 | 286 | status, | |
| 286 | 287 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| @@ -359,20 +360,21 @@ class ChatState { | |||
| 359 | 360 | } | |
| 360 | 361 | ||
| 361 | 362 | // Add method to update user cache for public access | |
| 362 | - | updateUserCache(users: User[]) { | |
| 363 | - | console.debug('Updating user cache with users:', users); | |
| 364 | - | // Merge incoming users with the existing cache | |
| 363 | + | updateUserCache(users: (User | SafeUser)[]) { | |
| 364 | + | console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' }))); | |
| 365 | + | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |
| 365 | 366 | users.forEach(user => { | |
| 366 | - | if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) { | |
| 367 | - | this.userCache[user.id] = user; | |
| 367 | + | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 368 | + | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 369 | + | this.userCache[user.id] = safeUser; | |
| 368 | 370 | // Update users array if not already present or if status changed | |
| 369 | 371 | const existingIndex = this.users.findIndex(u => u.id === user.id); | |
| 370 | 372 | if (existingIndex === -1) { | |
| 371 | - | this.users = [...this.users, user]; | |
| 372 | - | } else if (user.status !== this.users[existingIndex].status) { | |
| 373 | + | this.users = [...this.users, safeUser]; | |
| 374 | + | } else if (safeUser.status !== this.users[existingIndex].status) { | |
| 373 | 375 | this.users = [ | |
| 374 | 376 | ...this.users.slice(0, existingIndex), | |
| 375 | - | user, | |
| 377 | + | safeUser, | |
| 376 | 378 | ...this.users.slice(existingIndex + 1) | |
| 377 | 379 | ]; | |
| 378 | 380 | } | |
| @@ -381,27 +383,28 @@ class ChatState { | |||
| 381 | 383 | } | |
| 382 | 384 | ||
| 383 | 385 | // Add method to update online users for public access | |
| 384 | - | updateOnlineUsers(users: User[]) { | |
| 386 | + | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 385 | 387 | const now = Date.now(); | |
| 386 | - | // Create a hash of the new users data | |
| 387 | - | const newHash = JSON.stringify(users); | |
| 388 | + | // Convert all users to SafeUser and create a hash | |
| 389 | + | const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user); | |
| 390 | + | const newHash = JSON.stringify(safeUsers); | |
| 388 | 391 | ||
| 389 | 392 | // Only update if the data has changed | |
| 390 | 393 | if (newHash !== this.lastBuddyListHash) { | |
| 391 | - | const statusCounts = users.reduce((acc, user) => { | |
| 394 | + | const statusCounts = safeUsers.reduce((acc, user) => { | |
| 392 | 395 | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 393 | 396 | return acc; | |
| 394 | 397 | }, {} as Record<string, number>); | |
| 395 | 398 | ||
| 396 | 399 | console.debug('Updating online users (data changed):', { | |
| 397 | - | total: users.length, | |
| 400 | + | total: safeUsers.length, | |
| 398 | 401 | byStatus: statusCounts, | |
| 399 | 402 | timestamp: new Date(now).toISOString() | |
| 400 | 403 | }); | |
| 401 | 404 | ||
| 402 | - | this.users = users; | |
| 405 | + | this.users = safeUsers; | |
| 403 | 406 | // Also update cache | |
| 404 | - | users.forEach(user => { | |
| 407 | + | safeUsers.forEach(user => { | |
| 405 | 408 | this.userCache[user.id] = user; | |
| 406 | 409 | }); | |
| 407 | 410 | this.lastBuddyListHash = newHash; | |
| @@ -643,7 +646,7 @@ class ChatState { | |||
| 643 | 646 | // Modify buddy list update handler | |
| 644 | 647 | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | |
| 645 | 648 | try { | |
| 646 | - | const buddyList = JSON.parse(event.data) as User[]; | |
| 649 | + | const buddyList = JSON.parse(event.data) as SafeUser[]; | |
| 647 | 650 | this.updateOnlineUsers(buddyList); | |
| 648 | 651 | } catch (error) { | |
| 649 | 652 | console.debug('Error handling buddy list update via SSE:', error); | |
| @@ -706,8 +709,8 @@ class ChatState { | |||
| 706 | 709 | id: message.senderId, | |
| 707 | 710 | nickname: 'Unknown User', | |
| 708 | 711 | status: 'offline', | |
| 709 | - | password: '', | |
| 710 | - | createdAt: 0 | |
| 712 | + | avatarUrl: null, | |
| 713 | + | lastSeen: null | |
| 711 | 714 | } | |
| 712 | 715 | })); | |
| 713 | 716 | } | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | import type { User, Message, EnrichedMessage } from '../types/chat'; | 1 | + | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; |
| 2 | + | import { createSafeUser } from '../types/chat'; | |||
| 2 | import type { | 3 | import type { | ||
| 3 | SendMessageRequest, | 4 | SendMessageRequest, | ||
| 4 | SendMessageResponse, | 5 | SendMessageResponse, | ||
| @@ -11,16 +12,16 @@ import { env } from '$env/dynamic/public'; | |||||
| 11 | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | 12 | const DEFAULT_CHAT_ROOM_ID = env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | ||
| 12 | 13 | ||||
| 13 | class ChatState { | 14 | class ChatState { | ||
| 14 | - | private users = $state<User[]>([]); | 15 | + | private users = $state<SafeUser[]>([]); |
| 15 | private messages = $state<Message[]>([]); | 16 | private messages = $state<Message[]>([]); | ||
| 16 | - | private currentUser = $state<User | null>(null); | 17 | + | private currentUser = $state<SafeUser | null>(null); |
| 17 | private eventSource: EventSource | null = null; | 18 | private eventSource: EventSource | null = null; | ||
| 18 | private reconnectAttempts = 0; | 19 | private reconnectAttempts = 0; | ||
| 19 | private maxReconnectAttempts = 5; | 20 | private maxReconnectAttempts = 5; | ||
| 20 | private reconnectDelay = 1000; // Start with 1 second | 21 | private reconnectDelay = 1000; // Start with 1 second | ||
| 21 | private isInitializing = $state(false); | 22 | private isInitializing = $state(false); | ||
| 22 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | 23 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | ||
| 23 | - | private userCache = $state<Record<string, User>>({}); | 24 | + | private userCache = $state<Record<string, SafeUser>>({}); |
| 24 | private sseError = $state<string | null>(null); | 25 | private sseError = $state<string | null>(null); | ||
| 25 | private sseRetryAfter = $state<number | null>(null); | 26 | private sseRetryAfter = $state<number | null>(null); | ||
| 26 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | 27 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | ||
| @@ -80,8 +81,8 @@ class ChatState { | |||||
| 80 | return this.currentUser; | 81 | return this.currentUser; | ||
| 81 | } | 82 | } | ||
| 82 | 83 | ||||
| 83 | - | async setCurrentUser(user: User | null) { | 84 | + | async setCurrentUser(user: User | SafeUser | null) { |
| 84 | - | console.debug('setCurrentUser called with', user); | 85 | + | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); |
| 85 | 86 | ||||
| 86 | // Prevent concurrent setCurrentUser calls | 87 | // Prevent concurrent setCurrentUser calls | ||
| 87 | if (this.isSettingUser) { | 88 | if (this.isSettingUser) { | ||
| @@ -97,9 +98,12 @@ class ChatState { | |||||
| 97 | console.debug('No user to set and no current user, skipping'); | 98 | console.debug('No user to set and no current user, skipping'); | ||
| 98 | return; | 99 | return; | ||
| 99 | } | 100 | } | ||
| 101 | + | ||||
| 102 | + | // Convert to SafeUser if full User object is passed | |||
| 103 | + | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |||
| 100 | 104 | ||||
| 101 | // If same user is being set, check SSE connection | 105 | // If same user is being set, check SSE connection | ||
| 102 | - | if (user?.id === this.currentUser?.id) { | 106 | + | if (safeUser?.id === this.currentUser?.id) { |
| 103 | if (!this.eventSource) { | 107 | if (!this.eventSource) { | ||
| 104 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | 108 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | ||
| 105 | } else { | 109 | } else { | ||
| @@ -110,11 +114,11 @@ class ChatState { | |||||
| 110 | 114 | ||||
| 111 | // Clear existing messages before setting new user | 115 | // Clear existing messages before setting new user | ||
| 112 | this.messages = []; | 116 | this.messages = []; | ||
| 113 | - | this.currentUser = user; | 117 | + | this.currentUser = safeUser; |
| 114 | 118 | ||||
| 115 | - | if (user) { | 119 | + | if (safeUser) { |
| 116 | // Update user cache with current user | 120 | // Update user cache with current user | ||
| 117 | - | this.userCache[user.id] = user; | 121 | + | this.userCache[safeUser.id] = safeUser; |
| 118 | 122 | ||||
| 119 | // Only reinitialize if we have a valid user | 123 | // Only reinitialize if we have a valid user | ||
| 120 | await this.reinitialize(); | 124 | await this.reinitialize(); | ||
| @@ -178,8 +182,8 @@ class ChatState { | |||||
| 178 | 182 | ||||
| 179 | getOnlineUsers() { | 183 | getOnlineUsers() { | ||
| 180 | // Sort users by status: online first, then away, then busy, then offline | 184 | // Sort users by status: online first, then away, then busy, then offline | ||
| 181 | - | return [...this.users].sort((a: User, b: User) => { | 185 | + | return [...this.users].sort((a: SafeUser, b: SafeUser) => { |
| 182 | - | const statusOrder: Record<User['status'], number> = { | 186 | + | const statusOrder: Record<SafeUser['status'], number> = { |
| 183 | 'online': 0, | 187 | 'online': 0, | ||
| 184 | 'away': 1, | 188 | 'away': 1, | ||
| 185 | 'busy': 2, | 189 | 'busy': 2, | ||
| @@ -196,12 +200,10 @@ class ChatState { | |||||
| 196 | } | 200 | } | ||
| 197 | 201 | ||||
| 198 | // Return a fallback object and cache it to avoid repeated fetches | 202 | // Return a fallback object and cache it to avoid repeated fetches | ||
| 199 | - | const fallback = { | 203 | + | const fallback: SafeUser = { |
| 200 | id: userId, | 204 | id: userId, | ||
| 201 | nickname: 'Unknown User', | 205 | nickname: 'Unknown User', | ||
| 202 | status: 'offline', | 206 | status: 'offline', | ||
| 203 | - | password: '', // system user, no password needed | |||
| 204 | - | createdAt: Date.now(), | |||
| 205 | lastSeen: null, | 207 | lastSeen: null, | ||
| 206 | avatarUrl: null | 208 | avatarUrl: null | ||
| 207 | }; | 209 | }; | ||
| @@ -231,11 +233,11 @@ class ChatState { | |||||
| 231 | } | 233 | } | ||
| 232 | 234 | ||||
| 233 | // Try to get the user from cache first | 235 | // Try to get the user from cache first | ||
| 234 | - | let user: User | undefined = this.userCache[userId]; | 236 | + | let user: SafeUser | undefined = this.userCache[userId]; |
| 235 | 237 | ||||
| 236 | // If not in cache, try to find in users array | 238 | // If not in cache, try to find in users array | ||
| 237 | if (!user) { | 239 | if (!user) { | ||
| 238 | - | const foundUser = this.users.find((u: User) => u.id === userId); | 240 | + | const foundUser = this.users.find((u: SafeUser) => u.id === userId); |
| 239 | if (foundUser) { | 241 | if (foundUser) { | ||
| 240 | user = foundUser; | 242 | user = foundUser; | ||
| 241 | } | 243 | } | ||
| @@ -243,10 +245,9 @@ class ChatState { | |||||
| 243 | 245 | ||||
| 244 | const now = Date.now(); | 246 | const now = Date.now(); | ||
| 245 | if (user) { | 247 | if (user) { | ||
| 246 | - | const updatedUser: User = { | 248 | + | const updatedUser: SafeUser = { |
| 247 | ...user, | 249 | ...user, | ||
| 248 | status, | 250 | status, | ||
| 249 | - | // Update lastSeen based on status | |||
| 250 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | 251 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | ||
| 251 | }; | 252 | }; | ||
| 252 | 253 | ||||
| @@ -254,7 +255,7 @@ class ChatState { | |||||
| 254 | this.userCache[userId] = updatedUser; | 255 | this.userCache[userId] = updatedUser; | ||
| 255 | 256 | ||||
| 256 | // Then update or add to users array | 257 | // Then update or add to users array | ||
| 257 | - | const index = this.users.findIndex((u: User) => u.id === userId); | 258 | + | const index = this.users.findIndex((u: SafeUser) => u.id === userId); |
| 258 | if (index !== -1) { | 259 | if (index !== -1) { | ||
| 259 | // Create new array with updated user | 260 | // Create new array with updated user | ||
| 260 | this.users = [ | 261 | this.users = [ | ||
| @@ -280,7 +281,7 @@ class ChatState { | |||||
| 280 | .then(response => response.json()) | 281 | .then(response => response.json()) | ||
| 281 | .then(data => { | 282 | .then(data => { | ||
| 282 | if (data.success && data.user) { | 283 | if (data.success && data.user) { | ||
| 283 | - | const newUser: User = { | 284 | + | const newUser: SafeUser = { |
| 284 | ...data.user, | 285 | ...data.user, | ||
| 285 | status, | 286 | status, | ||
| 286 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | 287 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | ||
| @@ -359,20 +360,21 @@ class ChatState { | |||||
| 359 | } | 360 | } | ||
| 360 | 361 | ||||
| 361 | // Add method to update user cache for public access | 362 | // Add method to update user cache for public access | ||
| 362 | - | updateUserCache(users: User[]) { | 363 | + | updateUserCache(users: (User | SafeUser)[]) { |
| 363 | - | console.debug('Updating user cache with users:', users); | 364 | + | console.debug('Updating user cache with users:', users.map(u => ({ ...u, password: '[REDACTED]' }))); |
| 364 | - | // Merge incoming users with the existing cache | 365 | + | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects |
| 365 | users.forEach(user => { | 366 | users.forEach(user => { | ||
| 366 | - | if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) { | 367 | + | const safeUser = 'password' in user ? createSafeUser(user) : user; |
| 367 | - | this.userCache[user.id] = user; | 368 | + | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { |
| 369 | + | this.userCache[user.id] = safeUser; | |||
| 368 | // Update users array if not already present or if status changed | 370 | // Update users array if not already present or if status changed | ||
| 369 | const existingIndex = this.users.findIndex(u => u.id === user.id); | 371 | const existingIndex = this.users.findIndex(u => u.id === user.id); | ||
| 370 | if (existingIndex === -1) { | 372 | if (existingIndex === -1) { | ||
| 371 | - | this.users = [...this.users, user]; | 373 | + | this.users = [...this.users, safeUser]; |
| 372 | - | } else if (user.status !== this.users[existingIndex].status) { | 374 | + | } else if (safeUser.status !== this.users[existingIndex].status) { |
| 373 | this.users = [ | 375 | this.users = [ | ||
| 374 | ...this.users.slice(0, existingIndex), | 376 | ...this.users.slice(0, existingIndex), | ||
| 375 | - | user, | 377 | + | safeUser, |
| 376 | ...this.users.slice(existingIndex + 1) | 378 | ...this.users.slice(existingIndex + 1) | ||
| 377 | ]; | 379 | ]; | ||
| 378 | } | 380 | } | ||
| @@ -381,27 +383,28 @@ class ChatState { | |||||
| 381 | } | 383 | } | ||
| 382 | 384 | ||||
| 383 | // Add method to update online users for public access | 385 | // Add method to update online users for public access | ||
| 384 | - | updateOnlineUsers(users: User[]) { | 386 | + | updateOnlineUsers(users: (User | SafeUser)[]) { |
| 385 | const now = Date.now(); | 387 | const now = Date.now(); | ||
| 386 | - | // Create a hash of the new users data | 388 | + | // Convert all users to SafeUser and create a hash |
| 387 | - | const newHash = JSON.stringify(users); | 389 | + | const safeUsers = users.map(user => 'password' in user ? createSafeUser(user) : user); |
| 390 | + | const newHash = JSON.stringify(safeUsers); | |||
| 388 | 391 | ||||
| 389 | // Only update if the data has changed | 392 | // Only update if the data has changed | ||
| 390 | if (newHash !== this.lastBuddyListHash) { | 393 | if (newHash !== this.lastBuddyListHash) { | ||
| 391 | - | const statusCounts = users.reduce((acc, user) => { | 394 | + | const statusCounts = safeUsers.reduce((acc, user) => { |
| 392 | acc[user.status] = (acc[user.status] || 0) + 1; | 395 | acc[user.status] = (acc[user.status] || 0) + 1; | ||
| 393 | return acc; | 396 | return acc; | ||
| 394 | }, {} as Record<string, number>); | 397 | }, {} as Record<string, number>); | ||
| 395 | 398 | ||||
| 396 | console.debug('Updating online users (data changed):', { | 399 | console.debug('Updating online users (data changed):', { | ||
| 397 | - | total: users.length, | 400 | + | total: safeUsers.length, |
| 398 | byStatus: statusCounts, | 401 | byStatus: statusCounts, | ||
| 399 | timestamp: new Date(now).toISOString() | 402 | timestamp: new Date(now).toISOString() | ||
| 400 | }); | 403 | }); | ||
| 401 | 404 | ||||
| 402 | - | this.users = users; | 405 | + | this.users = safeUsers; |
| 403 | // Also update cache | 406 | // Also update cache | ||
| 404 | - | users.forEach(user => { | 407 | + | safeUsers.forEach(user => { |
| 405 | this.userCache[user.id] = user; | 408 | this.userCache[user.id] = user; | ||
| 406 | }); | 409 | }); | ||
| 407 | this.lastBuddyListHash = newHash; | 410 | this.lastBuddyListHash = newHash; | ||
| @@ -643,7 +646,7 @@ class ChatState { | |||||
| 643 | // Modify buddy list update handler | 646 | // Modify buddy list update handler | ||
| 644 | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | 647 | this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => { | ||
| 645 | try { | 648 | try { | ||
| 646 | - | const buddyList = JSON.parse(event.data) as User[]; | 649 | + | const buddyList = JSON.parse(event.data) as SafeUser[]; |
| 647 | this.updateOnlineUsers(buddyList); | 650 | this.updateOnlineUsers(buddyList); | ||
| 648 | } catch (error) { | 651 | } catch (error) { | ||
| 649 | console.debug('Error handling buddy list update via SSE:', error); | 652 | console.debug('Error handling buddy list update via SSE:', error); | ||
| @@ -706,8 +709,8 @@ class ChatState { | |||||
| 706 | id: message.senderId, | 709 | id: message.senderId, | ||
| 707 | nickname: 'Unknown User', | 710 | nickname: 'Unknown User', | ||
| 708 | status: 'offline', | 711 | status: 'offline', | ||
| 709 | - | password: '', | 712 | + | avatarUrl: null, |
| 710 | - | createdAt: 0 | 713 | + | lastSeen: null |
| 711 | } | 714 | } | ||
| 712 | })); | 715 | })); | ||
| 713 | } | 716 | } | ||