Files
liveview/frontend/webSocketsCli.js
Andros Fenollosa f5b39e6233 feat: add auto-generated UUID room IDs for security (v2.1.4)
- Generate random UUIDs when data-room is not specified
- Use crypto.randomUUID() with fallback polyfill
- Persist room IDs in localStorage
- Add comprehensive security documentation
- Fix IDOR vulnerability with predictable room IDs
2025-12-06 09:36:32 +01:00

461 lines
13 KiB
JavaScript

/*
Imports
*/
import {
renderHTML,
moveScrollToAnchor,
moveScrollToTop
} from "./mixins/miscellaneous.js";
/*
Variables
*/
const connectionModal = document.querySelector("#no-connection");
const nameStyleHideNoConnection = "no-connection--hide";
const nameStyleShowNoConnection = "no-connection--show";
// Global configuration - can be modified from other files
window.webSocketConfig = window.webSocketConfig || {
host: location.host,
protocol: 'https:' == document.location.protocol ? 'wss' : 'ws'
};
// Reconnection configuration
const RECONNECT_INTERVAL = 3000; // 3 seconds
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_BACKOFF_MULTIPLIER = 1.5;
let reconnectAttempts = 0;
let reconnectTimeout = null;
let isReconnecting = false;
let isConnecting = false; // Prevent multiple simultaneous connections
// Message queue for handling messages before connection is ready
let messageQueue = [];
let isWebSocketReady = false;
/*
FUNCTIONS
*/
/**
* Generate a random UUID v4
* Uses the native crypto API if available (modern browsers, HTTPS),
* otherwise falls back to a polyfill implementation
* @return {string} UUID v4 string
*/
function generateUUID() {
// Use native crypto.randomUUID() if available (HTTPS required)
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
try {
return crypto.randomUUID();
} catch (e) {
console.warn('crypto.randomUUID() failed, falling back to polyfill:', e);
}
}
// Fallback polyfill for older browsers or HTTP contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Save the current room to localStorage if not already set
* If no room is defined in HTML data-room attribute, generates a random UUID
* @return {string} The room ID
*/
function saveRoomToLocalStorage() {
let room = localStorage.getItem('room');
// If room already exists in localStorage, use it
if (room !== null && room.trim() !== '') {
return room;
}
// Try to get room from HTML data-room attribute
const htmlElement = document.querySelector('html');
const dataRoomValue = htmlElement ? htmlElement.dataset.room : null;
// Determine room value: use data-room if exists and not empty, otherwise generate UUID
if (dataRoomValue && dataRoomValue.trim() !== '') {
room = dataRoomValue.trim();
console.log('Using room ID from HTML data-room attribute:', room);
} else {
// Generate a random UUID for security
room = generateUUID();
console.log('Generated new random UUID for room:', room);
// Update the HTML data-room attribute so it's available for other scripts
if (htmlElement) {
htmlElement.dataset.room = room;
}
}
// Save to localStorage for persistence
localStorage.setItem('room', room);
return room;
}
/**
* Show no connection modal when the connection is lost
* @return {void}
*/
function showNoConnectionModal() {
if (connectionModal) {
connectionModal.classList.remove(nameStyleHideNoConnection);
connectionModal.classList.add(nameStyleShowNoConnection);
}
}
/**
* Hide no connection modal when the connection is restored
* @return {void}
*/
function hideNoConnectionModal() {
if (connectionModal) {
connectionModal.classList.remove(nameStyleShowNoConnection);
connectionModal.classList.add(nameStyleHideNoConnection);
}
}
/**
* Calculate reconnection delay with exponential backoff
* @param {number} attempt - Current attempt number
* @return {number} Delay in milliseconds
*/
function getReconnectDelay(attempt) {
return Math.min(RECONNECT_INTERVAL * Math.pow(RECONNECT_BACKOFF_MULTIPLIER, attempt), 30000);
}
/**
* Reset reconnection state
* @return {void}
*/
function resetReconnectState() {
reconnectAttempts = 0;
isReconnecting = false;
isConnecting = false;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
}
/**
* Process queued messages when connection is ready
* @return {void}
*/
function processQueuedMessages() {
if (messageQueue.length > 0) {
console.log(`Processing ${messageQueue.length} queued messages...`);
const messages = [...messageQueue];
messageQueue = []; // Clear queue
messages.forEach(data => {
sendDataDirectly(data);
});
}
}
/**
* Send data directly without queuing
* @param {Object} data - Data object to send
* @param {WebSocket} webSocket - WebSocket instance to use
* @return {void}
*/
function sendDataDirectly(data, webSocket = window.myWebSocket) {
if (webSocket && webSocket.readyState === WebSocket.OPEN) {
// Add language
data.lang = document.querySelector("html").getAttribute("lang");
// Add room
data.room = localStorage.getItem("room");
// Send
webSocket.send(JSON.stringify(data));
console.debug("Data sent to WebSocket server:", data);
} else {
console.warn("WebSocket is not connected. Message not sent:", data);
}
}
/**
* Connect to WebSockets server (SocialNetworkConsumer)
* @param {string} url - WebSockets server url
* @return {WebSocket}
*/
export function connect(url = null) {
// Prevent multiple simultaneous connections
if (isConnecting) {
console.log("Connection already in progress, skipping duplicate connection attempt...");
return window.myWebSocket;
}
// Check if there's already an active connection
if (window.myWebSocket && window.myWebSocket.readyState === WebSocket.OPEN) {
console.log("WebSocket already connected, skipping duplicate connection attempt...");
return window.myWebSocket;
}
isConnecting = true;
console.log("Connecting to WebSockets server...");
// Ensure room is saved to localStorage before connecting (generates UUID if needed)
const room = saveRoomToLocalStorage();
// Build URL after ensuring room is saved
if (!url) {
url = `${window.webSocketConfig.protocol}://${window.webSocketConfig.host}/ws/liveview/${room}/`;
}
// Clean up existing connection if any
if (window.myWebSocket) {
console.log("Closing existing WebSocket connection...");
window.myWebSocket.close();
window.myWebSocket = null;
}
try {
window.myWebSocket = new WebSocket(url);
// Reset connecting flag when connection opens successfully
window.myWebSocket.addEventListener('open', () => {
isConnecting = false;
console.log("WebSocket connection established successfully");
});
// Reset connecting flag when connection fails
window.myWebSocket.addEventListener('error', () => {
isConnecting = false;
console.error("WebSocket connection failed");
});
// Reset connecting flag when connection closes
window.myWebSocket.addEventListener('close', () => {
isConnecting = false;
console.log("WebSocket connection closed");
});
startEvents(window.myWebSocket);
return window.myWebSocket;
} catch (error) {
isConnecting = false;
console.error("Error creating WebSocket connection:", error);
throw error;
}
}
/**
* Send data to WebSockets server with message queue support
* @param {Object} data - Data object to send
* @param {WebSocket} webSocket - WebSocket instance to use
* @return {void}
*/
export function sendData(data, webSocket = window.myWebSocket) {
if (isWebSocketReady && webSocket && webSocket.readyState === WebSocket.OPEN) {
sendDataDirectly(data, webSocket);
} else {
console.debug("WebSocket not ready. Queuing message:", data.function || 'unknown function');
messageQueue.push(data);
// Optionally attempt to reconnect if connection is lost
if (!isConnecting && (!webSocket || webSocket.readyState === WebSocket.CLOSED)) {
console.log("Attempting to reconnect due to queued message...");
attemptReconnect();
}
}
}
/**
* Attempt to reconnect to WebSockets server with exponential backoff
* @return {void}
*/
function attemptReconnect() {
if (isReconnecting || isConnecting || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error("Maximum reconnection attempts reached. Please refresh the page.");
}
return;
}
isReconnecting = true;
reconnectAttempts++;
const delay = getReconnectDelay(reconnectAttempts - 1);
console.log(`Attempting to reconnect (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}) in ${delay}ms...`);
reconnectTimeout = setTimeout(() => {
try {
connect();
} catch (error) {
console.error("Reconnection failed:", error);
isReconnecting = false;
// Try again if we haven't reached max attempts
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
attemptReconnect();
}
}
}, delay);
}
/*
EVENTS
*/
/**
* Set up WebSocket event listeners
* @param {WebSocket} webSocket - WebSocket instance
* @return {void}
*/
export function startEvents(webSocket = window.myWebSocket) {
if (!webSocket) {
console.error("WebSocket instance is required");
return;
}
// Prevent adding duplicate event listeners
if (webSocket._eventsConfigured) {
console.log("Events already configured for this WebSocket instance");
return;
}
// Event when a new message is received by WebSockets
webSocket.addEventListener("message", (event) => {
try {
// Parse the data received
const data = JSON.parse(event.data);
// Renders the HTML received from the Consumer (only if target is defined)
if (data.target) {
renderHTML(data);
}
moveScrollToAnchor(data);
moveScrollToTop(data);
} catch (error) {
console.error("Error processing WebSocket message:", error);
}
});
webSocket.addEventListener("open", () => {
resetReconnectState();
hideNoConnectionModal();
isWebSocketReady = true;
console.log("Connected to WebSockets server");
// Process any queued messages
processQueuedMessages();
});
function handleConnectionLoss() {
isWebSocketReady = false;
showNoConnectionModal();
console.log("Connection lost with WebSockets server");
attemptReconnect();
}
// Connection loss events
webSocket.addEventListener("error", (event) => {
console.error("WebSocket error:", event);
handleConnectionLoss();
});
webSocket.addEventListener("close", (event) => {
isWebSocketReady = false;
console.log("WebSocket closed:", event.code, event.reason);
// Only attempt reconnect if it wasn't a normal closure
if (event.code !== 1000) {
handleConnectionLoss();
}
});
// Mark this WebSocket instance as having events configured
webSocket._eventsConfigured = true;
// Network connectivity events (only add once)
if (!window._networkEventsConfigured) {
window.addEventListener('offline', () => {
isWebSocketReady = false;
showNoConnectionModal();
console.log("Network went offline");
});
window.addEventListener('online', () => {
console.log("Network came back online");
if (window.myWebSocket && window.myWebSocket.readyState !== WebSocket.OPEN) {
attemptReconnect();
} else {
hideNoConnectionModal();
}
});
window._networkEventsConfigured = true;
}
}
/**
* Disconnect and cleanup WebSocket connection
* @return {void}
*/
export function disconnect() {
console.log("Disconnecting WebSocket...");
resetReconnectState();
isWebSocketReady = false;
if (window.myWebSocket) {
window.myWebSocket.close(1000, "Manual disconnect");
window.myWebSocket = null;
}
hideNoConnectionModal();
// Clear any pending messages
if (messageQueue.length > 0) {
console.log(`Clearing ${messageQueue.length} queued messages due to disconnect`);
messageQueue = [];
}
}
/**
* Get current WebSocket connection status
* @return {string} Connection status
*/
export function getConnectionStatus() {
if (!window.myWebSocket) {
return "disconnected";
}
switch (window.myWebSocket.readyState) {
case WebSocket.CONNECTING:
return "connecting";
case WebSocket.OPEN:
return isWebSocketReady ? "connected" : "connecting";
case WebSocket.CLOSING:
return "closing";
case WebSocket.CLOSED:
return "disconnected";
default:
return "unknown";
}
}
/**
* Get number of queued messages (for debugging)
* @return {number} Number of messages in queue
*/
export function getQueueLength() {
return messageQueue.length;
}
/**
* Clear message queue (for debugging/testing)
* @return {void}
*/
export function clearQueue() {
const queueLength = messageQueue.length;
messageQueue = [];
console.log(`Cleared ${queueLength} messages from queue`);
}