Some checks failed
Build and Push / build-all (push) Failing after 1m50s
- Add Nakama Dockerfile to build custom image with chess modules - Update docker-compose.prod.yml to use custom Nakama image with --runtime.js_entrypoint - Fix chat WebSocket to use wss:// on HTTPS pages (was hardcoded ws://) - Add SSL configuration to nginx port 8088 for HLS/LLHLS streaming - Add Nakama build step to CI workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
427 lines
11 KiB
JavaScript
427 lines
11 KiB
JavaScript
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();
|