import { connectionStatus, chatUserInfo, addMessage, removeMessage, setMessageHistory, currentRealmId, participants } from './chatStore'; import { get } from 'svelte/store'; import { getGuestFingerprint } from '$lib/fingerprint'; class ChatWebSocket { constructor() { this.ws = null; this.realmId = null; this.token = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.maxReconnectDelay = 30000; // Cap delay at 30 seconds this.messageHandlers = []; this.reconnectResetTimer = null; } async connect(realmId, token = null) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { // Already connected, just switch realm if (this.realmId !== realmId) { this.joinRealm(realmId); } return; } this.realmId = realmId; this.token = token; // SECURITY FIX #9: Don't include token in URL query params // Token will be sent as first message after connection to avoid logging/exposure // Use wss:// for HTTPS, ws:// for HTTP (dynamic protocol detection) const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = `${wsProtocol}//${window.location.host}/chat/ws?realmId=${encodeURIComponent(realmId)}`; // Guest connection - generate fingerprint for ban enforcement // Only guests are fingerprinted, registered users are NOT fingerprinted for privacy if (!token) { try { const fingerprint = await getGuestFingerprint(); if (fingerprint) { wsUrl += `&fp=${encodeURIComponent(fingerprint)}`; } } catch (e) { console.warn('Failed to generate fingerprint:', e); } } console.log('[ChatWebSocket] Connecting to:', wsUrl, 'Token present:', !!token); connectionStatus.set('connecting'); try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('Chat WebSocket connected'); connectionStatus.set('connected'); this.reconnectAttempts = 0; currentRealmId.set(realmId); // SECURITY FIX #9: Send auth token as first message (not in URL) if (this.token) { this.ws.send(JSON.stringify({ type: 'auth', token: this.token })); } // Send join message to subscribe to the realm this.joinRealm(realmId); }; this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data); this.handleMessage(data); } catch (error) { console.error('Failed to parse WebSocket message:', error); } }; this.ws.onerror = (error) => { console.error('Chat WebSocket error:', error); }; this.ws.onclose = () => { console.log('Chat WebSocket closed'); connectionStatus.set('disconnected'); this.attemptReconnect(); }; } catch (error) { console.error('Failed to create WebSocket:', error); connectionStatus.set('disconnected'); } } handleMessage(data) { switch (data.type) { case 'welcome': // If we have a token, we're about to authenticate - don't set guest info // The auth_success message will set the correct user info if (!this.token) { chatUserInfo.set({ username: data.username, userId: data.userId, isGuest: data.isGuest, isModerator: data.isModerator, isSiteModerator: data.isSiteModerator || false, isStreamer: data.isStreamer || false, isAdmin: data.isAdmin || false, avatarUrl: data.avatarUrl || '' }); // If guest and has saved name in localStorage, rename immediately if (data.isGuest && typeof localStorage !== 'undefined') { const savedGuestName = localStorage.getItem('guestName'); if (savedGuestName && savedGuestName !== data.username) { console.log('Restoring saved guest name:', savedGuestName); // Delay slightly to ensure connection is fully established setTimeout(() => { this.sendRename(savedGuestName); }, 100); } } } break; case 'history': setMessageHistory(data.messages || []); break; case 'new_message': addMessage(data); break; case 'message_deleted': removeMessage(data.messageId); break; case 'message_sent': // Confirmation that message was sent break; case 'rename_success': chatUserInfo.update((info) => ({ ...info, username: data.newName })); console.log('Rename successful:', data.newName); break; case 'auth_success': // Update user info after successful authentication chatUserInfo.set({ username: data.username, userId: data.userId, isGuest: false, isModerator: data.isModerator || false, isSiteModerator: data.isSiteModerator || false, isStreamer: data.isStreamer || false, isAdmin: data.isAdmin || false, avatarUrl: data.avatarUrl || '', userColor: data.userColor || '#FFFFFF' }); console.log('Authentication successful:', data.username); break; case 'error': console.error('Chat error:', data.error); // Optionally show error to user break; case 'mod_action_success': console.log('Moderation action successful:', data.action); break; case 'participants_list': participants.set(data.participants || []); console.log(`Participants in realm: ${data.count}`); break; case 'participant_joined': // Add new participant to the list participants.update((list) => { // Check if already in list (avoid duplicates) const exists = list.some((p) => p.userId === data.participant.userId); if (!exists) { return [...list, data.participant]; } return list; }); console.log(`Participant joined: ${data.participant.username} (${data.participantCount} total)`); break; case 'participant_left': // Remove participant from the list participants.update((list) => list.filter((p) => p.userId !== data.userId)); console.log(`Participant left: ${data.username} (${data.participantCount} total)`); break; default: console.log('Unknown message type:', data.type); } // Call custom handlers this.messageHandlers.forEach((handler) => handler(data)); } sendMessage(content, userColor = '#FFFFFF', selfDestructSeconds = 0) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'message', content, userColor }; // Only include selfDestructSeconds if set (> 0) if (selfDestructSeconds > 0) { message.selfDestructSeconds = selfDestructSeconds; } this.ws.send(JSON.stringify(message)); return true; } joinRealm(realmId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'join', realmId }; this.ws.send(JSON.stringify(message)); this.realmId = realmId; currentRealmId.set(realmId); return true; } deleteMessage(messageId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'mod_action', action: 'delete', messageId }; this.ws.send(JSON.stringify(message)); return true; } // Site-wide fingerprint ban (admins + site moderators only) uberbanUser(targetUserId, fingerprint = '', reason = '') { return this.sendModAction('uberban', targetUserId, reason, 0, fingerprint); } // Remove site-wide fingerprint ban unUberbanUser(fingerprint, reason = '') { return this.sendModAction('ununberban', '', reason, 0, fingerprint); } // Per-realm ban banUser(targetUserId, reason = '') { return this.sendModAction('ban', targetUserId, reason); } // Remove per-realm ban unbanUser(targetUserId, fingerprint = '') { return this.sendModAction('unban', targetUserId, '', 0, fingerprint); } // Kick (disconnect + 1 min rejoin block) kickUser(targetUserId, duration = 60, reason = '') { return this.sendModAction('kick', targetUserId, reason, duration); } // duration: 0 = permanent (default), >0 = temporary with auto-expire muteUser(targetUserId, duration = 0, reason = '') { return this.sendModAction('mute', targetUserId, reason, duration); } sendModAction(action, targetUserId, reason = '', duration = 0, fingerprint = '') { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'mod_action', action, targetUserId, reason, duration }; // Include fingerprint if provided (for uberban/unban) if (fingerprint) { message.fingerprint = fingerprint; } this.ws.send(JSON.stringify(message)); return true; } getParticipants() { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'get_participants' }; this.ws.send(JSON.stringify(message)); return true; } sendRename(newName) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { console.error('WebSocket not connected'); return false; } const message = { type: 'rename', newName }; this.ws.send(JSON.stringify(message)); return true; } onMessage(handler) { this.messageHandlers.push(handler); return () => { this.messageHandlers = this.messageHandlers.filter((h) => h !== handler); }; } attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('Max reconnect attempts reached. Use manualReconnect() to try again.'); connectionStatus.set('failed'); // Schedule automatic reset of reconnect attempts after 60 seconds // This allows the system to try again later without user intervention if (this.reconnectResetTimer) { clearTimeout(this.reconnectResetTimer); } this.reconnectResetTimer = setTimeout(() => { console.log('Resetting reconnect attempts after timeout'); this.reconnectAttempts = 0; if (this.realmId) { this.connect(this.realmId, this.token); } }, 60000); // Try again after 1 minute return; } this.reconnectAttempts++; // Cap the delay at maxReconnectDelay const delay = Math.min( this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay ); console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); setTimeout(() => { if (this.realmId) { this.connect(this.realmId, this.token); } }, delay); } /** * Manually trigger reconnection - resets attempt counter and tries immediately */ manualReconnect() { console.log('Manual reconnect triggered'); if (this.reconnectResetTimer) { clearTimeout(this.reconnectResetTimer); this.reconnectResetTimer = null; } this.reconnectAttempts = 0; if (this.realmId) { this.connect(this.realmId, this.token); } } disconnect() { // Clear any pending reconnect timers if (this.reconnectResetTimer) { clearTimeout(this.reconnectResetTimer); this.reconnectResetTimer = null; } if (this.ws) { this.ws.close(); this.ws = null; } this.realmId = null; this.token = null; this.reconnectAttempts = 0; connectionStatus.set('disconnected'); } } // Singleton instance export const chatWebSocket = new ChatWebSocket();