diff --git a/.gitignore b/.gitignore index 4416880..1379b05 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /css/ /vendor/ /node_modules/ +/backup/ .php-cs-fixer.cache .phpunit.result.cache diff --git a/websocket_server/ApiService.js b/websocket_server/ApiService.js index 6f38287..383e781 100644 --- a/websocket_server/ApiService.js +++ b/websocket_server/ApiService.js @@ -59,8 +59,18 @@ export default class ApiService { console.log(`[${roomID}] Saving room data to server: ${roomData.length} elements, ${Object.keys(files).length} files`) const url = `${this.NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` - const body = { data: { type: 'excalidraw', elements: roomData, files: this.cleanupFiles(roomData, files) } } + + const body = { + data: { + type: 'excalidraw', + elements: roomData, + files: this.cleanupFiles(roomData, files), + savedAt: Date.now(), + }, + } + const options = this.fetchOptions('PUT', null, body, roomID, lastEditedUser) + return this.fetchData(url, options) } diff --git a/websocket_server/BackupManager.js b/websocket_server/BackupManager.js new file mode 100644 index 0000000..95ffb98 --- /dev/null +++ b/websocket_server/BackupManager.js @@ -0,0 +1,329 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable no-console */ + +import fs from 'fs/promises' +import path from 'path' +import crypto from 'crypto' +import zlib from 'zlib' +import { promisify } from 'util' + +const gzip = promisify(zlib.gzip) +const gunzip = promisify(zlib.gunzip) + +/** + * @typedef {object} BackupOptions + * @property {string} [backupDir='./backup'] - Directory to store backups + * @property {number} [maxBackupsPerRoom=5] - Maximum number of backups to keep per room + * @property {number} [lockTimeout=5000] - Maximum time in ms to wait for a lock + * @property {number} [lockRetryInterval=50] - Time in ms between lock retry attempts + */ + +/** + * @typedef {object} BackupData + * @property {string} id - Unique identifier for the backup + * @property {number} timestamp - Timestamp when backup was created + * @property {number} roomId - ID of the room + * @property {string} checksum - SHA-256 hash of the data + * @property {object} data - The actual backup data + * @property {number} savedAt - Timestamp when the data was last saved + */ + +/** + * Manages backup operations for whiteboard rooms + */ +export default class BackupManager { + + /** + * Creates a new BackupManager instance + * @param {BackupOptions} [options] - Configuration options + */ + constructor(options = {}) { + const { backupDir = './backup', maxBackupsPerRoom = 5 } = options + this.backupDir = backupDir + this.maxBackupsPerRoom = maxBackupsPerRoom + this.locks = new Map() + this.lockTimeout = options.lockTimeout || 5000 // 5 seconds + this.lockRetryInterval = options.lockRetryInterval || 50 // 50ms + this.init() + } + + /** + * Initializes the backup directory and cleans up temporary files + * @throws {Error} If initialization fails + */ + async init() { + try { + await fs.mkdir(this.backupDir, { recursive: true }) + await this.cleanupTemporaryFiles() + } catch (error) { + console.error('Failed to initialize BackupManager:', error) + throw error + } + } + + /** + * Removes temporary files from the backup directory + */ + async cleanupTemporaryFiles() { + try { + const files = await fs.readdir(this.backupDir) + const tmpFiles = files.filter((f) => f.endsWith('.tmp')) + await Promise.all( + tmpFiles.map((file) => + fs + .unlink(path.join(this.backupDir, file)) + .catch(console.error), + ), + ) + } catch (error) { + console.error('Failed to cleanup temporary files:', error) + } + } + + /** + * Acquires a lock for a specific room + * @param {number} roomId - The room ID to lock + * @throws {Error} If lock cannot be acquired within timeout period + */ + async acquireLock(roomId) { + const startTime = Date.now() + while (this.locks.get(roomId)) { + if (Date.now() - startTime > this.lockTimeout) { + throw new Error(`Lock acquisition timeout for room ${roomId}`) + } + await new Promise((resolve) => + setTimeout(resolve, this.lockRetryInterval), + ) + } + this.locks.set(roomId, Date.now()) + } + + /** + * Releases a lock for a specific room + * @param {number} roomId - The room ID to unlock + */ + async releaseLock(roomId) { + this.locks.delete(roomId) + } + + /** + * Ensures roomId is a valid number + * @param {number|string} roomId - The room ID to validate + * @return {number} The validated room ID + * @throws {Error} If roomId is invalid + */ + sanitizeRoomId(roomId) { + const numericRoomId = Number(roomId) + if (isNaN(numericRoomId) || numericRoomId <= 0) { + throw new Error('Invalid room ID: must be a positive number') + } + return numericRoomId + } + + /** + * Calculates SHA-256 checksum of data + * @param {string | object} data - Data to calculate checksum for + * @return {string} Hex string of SHA-256 hash + */ + calculateChecksum(data) { + return crypto + .createHash('sha256') + .update(typeof data === 'string' ? data : JSON.stringify(data)) + .digest('hex') + } + + /** + * Creates a new backup for a room + * @param {number} roomId - The room ID + * @param {object} data - The data to backup + * @return {Promise} The backup ID + * @throws {Error} If backup creation fails + */ + async createBackup(roomId, data) { + if (!roomId || !data) { + throw new Error('Invalid backup parameters') + } + + const sanitizedRoomId = this.sanitizeRoomId(roomId) + + try { + await this.acquireLock(sanitizedRoomId) + + const backupData = this.prepareBackupData(sanitizedRoomId, data) + await this.writeBackupFile(sanitizedRoomId, backupData) + await this.cleanupOldBackups(sanitizedRoomId) + + return backupData.id + } finally { + await this.releaseLock(sanitizedRoomId) + } + } + + /** + * Prepares backup data structure + * @param {number} roomId - The room ID + * @param {object} data - The data to backup + * @return {BackupData} Prepared backup data + */ + prepareBackupData(roomId, data) { + return { + id: crypto.randomUUID(), + timestamp: Date.now(), + roomId, + checksum: this.calculateChecksum(data), + data, + savedAt: data.savedAt || Date.now(), + } + } + + /** + * Writes backup data to file + * @param {number} roomId - The room ID + * @param {BackupData} backupData - The data to write + */ + async writeBackupFile(roomId, backupData) { + const backupFile = path.join( + this.backupDir, + `${roomId}_${backupData.timestamp}.bak`, + ) + const tempFile = `${backupFile}.tmp` + + const compressed = await gzip(JSON.stringify(backupData)) + await fs.writeFile(tempFile, compressed) + await fs.rename(tempFile, backupFile) + } + + /** + * Retrieves the latest backup for a room + * @param {number} roomId - The room ID + * @return {Promise} The latest backup or null if none exists + * @throws {Error} If backup retrieval fails + */ + async getLatestBackup(roomId) { + const sanitizedRoomId = this.sanitizeRoomId(roomId) + const files = await fs.readdir(this.backupDir) + const roomBackups = files + .filter( + (f) => + f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'), + ) + .sort() + .reverse() + + if (roomBackups.length === 0) return null + + try { + const compressed = await fs.readFile( + path.join(this.backupDir, roomBackups[0]), + ) + const decompressed = await gunzip(compressed) + const backup = JSON.parse(decompressed.toString()) + + const calculatedChecksum = this.calculateChecksum(backup.data) + if (calculatedChecksum !== backup.checksum) { + throw new Error('Backup data corruption detected') + } + + return backup + } catch (error) { + console.error( + `Failed to read latest backup for room ${sanitizedRoomId}:`, + error, + ) + throw error + } + } + + /** + * Removes old backups exceeding maxBackupsPerRoom + * @param {number} roomId - The room ID + */ + async cleanupOldBackups(roomId) { + const sanitizedRoomId = this.sanitizeRoomId(roomId) + + try { + const files = await fs.readdir(this.backupDir) + const roomBackups = files + .filter( + (f) => + f.startsWith(`${sanitizedRoomId}_`) + && f.endsWith('.bak'), + ) + .sort() + .reverse() + + if (roomBackups.length <= this.maxBackupsPerRoom) { + return + } + + const filesToDelete = roomBackups.slice(this.maxBackupsPerRoom) + await Promise.all( + filesToDelete.map((file) => + fs + .unlink(path.join(this.backupDir, file)) + .catch((error) => { + console.error( + `Failed to delete backup ${file}:`, + error, + ) + }), + ), + ) + } catch (error) { + console.error(`Failed to cleanup old backups for ${roomId}:`, error) + } + } + + /** + * Gets all backup files for a room + * @param {number} roomId - The room ID + * @return {Promise} Array of backup filenames + */ + async getAllBackups(roomId) { + const sanitizedRoomId = this.sanitizeRoomId(roomId) + const files = await fs.readdir(this.backupDir) + return files + .filter( + (f) => + f.startsWith(`${sanitizedRoomId}_`) && f.endsWith('.bak'), + ) + .sort() + .reverse() + } + + /** + * Recovers data from the latest backup + * @param {number} roomId - The room ID + * @return {Promise} Recovered data or null if no backup exists + */ + async recoverFromBackup(roomId) { + const backup = await this.getLatestBackup(roomId) + if (!backup) { + console.log(`No backup found for room ${roomId}`) + return null + } + return backup.data + } + + /** + * Checks if server data is newer than the latest backup + * @param {number} roomId - The room ID + * @param {object} serverData - Current server data + * @return {Promise} True if server data is newer + */ + async isDataFresher(roomId, serverData) { + const latestBackup = await this.getLatestBackup(roomId) + + if (!latestBackup) return true + + const serverTimestamp = serverData?.savedAt || 0 + const backupTimestamp = latestBackup.savedAt || 0 + + return serverTimestamp >= backupTimestamp + } + +} diff --git a/websocket_server/LRUCacheStrategy.js b/websocket_server/LRUCacheStrategy.js index 324d8a9..73d6610 100644 --- a/websocket_server/LRUCacheStrategy.js +++ b/websocket_server/LRUCacheStrategy.js @@ -20,6 +20,7 @@ export default class LRUCacheStrategy extends StorageStrategy { ttlAutopurge: true, dispose: async (value, key) => { console.log(`[${key}] Disposing room`) + if (value?.data && value?.lastEditedUser) { try { await this.apiService.saveRoomDataToServer( @@ -53,7 +54,9 @@ export default class LRUCacheStrategy extends StorageStrategy { } getRooms() { - const rooms = Array.from(this.cache.values()).filter((room) => room instanceof Room) + const rooms = Array.from(this.cache.values()).filter( + (room) => room instanceof Room, + ) return rooms } diff --git a/websocket_server/RoomDataManager.js b/websocket_server/RoomDataManager.js index ff602c9..96bbf99 100644 --- a/websocket_server/RoomDataManager.js +++ b/websocket_server/RoomDataManager.js @@ -1,67 +1,322 @@ -/* eslint-disable no-console */ - /** * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ import Room from './Room.js' +import Utils from './Utils.js' +import ApiService from './ApiService.js' +import BackupManager from './BackupManager.js' +import StorageManager from './StorageManager.js' + +/** + * @typedef {object} RoomData + * @property {Array} [elements] - Array of room elements + * @property {object} [files] - Object containing file information + * @property {number} [savedAt] - Timestamp of when the data was saved + */ + +/** + * @typedef {{ + * inputData: RoomData, + * currentData: RoomData, + * jwtToken: string, + * }} SyncOptions + */ +/** + * Manages room data synchronization, backup, and storage operations + * @class + */ export default class RoomDataManager { - constructor(storageManager, apiService) { + /** + * Default configuration for room data + * @static + * @readonly + */ + static CONFIG = Object.freeze({ + defaultData: { + elements: [], + files: {}, + }, + }) + + /** + * @param {StorageManager} storageManager - Manager for room storage operations + * @param {ApiService} apiService - Service for API communications + * @param {BackupManager} backupManager - Manager for backup operations + */ + constructor(storageManager, apiService, backupManager) { this.storageManager = storageManager this.apiService = apiService + this.backupManager = backupManager } + /** + * Synchronizes room data across different sources + * @param {string} roomId - Unique identifier for the room + * @param {RoomData} data - Room data to synchronize + * @param {Array} users - Array of users in the room + * @param {object} lastEditedUser - User who last edited the room + * @param {string} jwtToken - JWT token for authentication + * @return {Promise} Updated room instance or null if empty + * @throws {Error} When synchronization fails + */ async syncRoomData(roomId, data, users, lastEditedUser, jwtToken) { - console.log(`[${roomId}] Syncing room data`) + Utils.logOperation(roomId, 'Starting sync', { + hasInputData: !!data, + hasToken: !!jwtToken, + }) + + try { + const room = await this.getOrCreateRoom(roomId) + + const updatedData = await this.determineDataToUpdate(roomId, { + inputData: data, + currentData: room.data, + jwtToken, + }) - let room = await this.storageManager.get(roomId) + if (updatedData) { + await this.updateRoomWithData( + room, + updatedData, + users, + lastEditedUser, + ) - if (!room) { - room = new Room(roomId) - await this.storageManager.set(roomId, room) + this.createRoomBackup(room.id, room) + } + + return room + } catch (error) { + Utils.logError(roomId, 'Room sync failed', error) + throw error } + } + + /** + * Determines the most recent data from available sources + * @param {string} roomId - Room identifier + * @param {SyncOptions} options - Sync options containing input, current data and token + * @return {Promise} Most recent room data + */ + async determineDataToUpdate(roomId, { inputData, currentData, jwtToken }) { + Utils.logOperation(roomId, 'Determining data to update', { + hasInputData: !!inputData, + hasCurrentData: !!currentData, + hasToken: !!jwtToken, + }) - if (!data && !room.data) { - data = await this.fetchRoomDataFromServer(roomId, jwtToken) + let data = null + + if (inputData) { + Utils.logOperation(roomId, 'Using input data') + data = this.normalizeRoomData(inputData) + } else if (jwtToken) { + data = await this.fetchRoomData(roomId, jwtToken) + } else if (currentData) { + Utils.logOperation(roomId, 'Using current room data') + data = this.normalizeRoomData(currentData) } - const files = data?.files - const elements = data?.elements ?? data - if (elements) room.setData(elements) - if (lastEditedUser) room.updateLastEditedUser(lastEditedUser) - if (users) room.setUsers(users) - if (files) room.setFiles(files) + // Always return normalized data, even if null + return this.normalizeRoomData(data) + } - await this.storageManager.set(roomId, room) + /** + * Normalizes room data to ensure consistent format + * @param {*} data - Raw room data to normalize + * @return {RoomData} Normalized room data + */ + normalizeRoomData(data) { + // Always return default data structure if input is null/undefined + if (!data) { + return RoomDataManager.CONFIG.defaultData + } - console.log(`[${roomId}] Room data synced. Users: ${room.users.size}, Last edited by: ${room.lastEditedUser}, files: ${Object.keys(room.files).length}`) + const normalized = { + elements: [], + files: {}, + savedAt: Date.now(), + } - if (room.isEmpty()) { - await this.storageManager.delete(roomId) - console.log(`[${roomId}] Room is empty, removed from cache`) - return null + if (Array.isArray(data)) { + normalized.elements = [...data] + } else if (typeof data === 'object') { + normalized.elements = Array.isArray(data.elements) + ? [...data.elements] + : data.elements + ? Object.values(data.elements) + : [] + normalized.files = { ...(data.files || {}) } + normalized.savedAt = data.savedAt || Date.now() } - return room + return normalized } - async fetchRoomDataFromServer(roomId, jwtToken) { - console.log(`[${roomId}] No data provided or existing, fetching from server...`) + /** + * Updates room with new data and creates backup + * @param {Room} room - Room instance to update + * @param {RoomData} data - New room data + * @param {Array} users - Updated user list + * @param {object} lastEditedUser - User who last edited + * @return {Promise} + */ + async updateRoomWithData(room, data, users, lastEditedUser) { + await this.updateRoom(room, data, users, lastEditedUser) + await this.storageManager.set(room.id, room) + } + + /** + * Updates room properties with new data + * @param {Room} room - Room instance to update + * @param {RoomData} data - New room data + * @param {Array} users - Updated user list + * @param {object} lastEditedUser - User who last edited + * @return {Promise} + */ + async updateRoom(room, data, users, lastEditedUser) { + if (data.elements) room.setData(data.elements) + if (data.files) room.setFiles(data.files) + if (users) room.setUsers(users) + if (lastEditedUser) room.updateLastEditedUser(lastEditedUser) + + Utils.logOperation(room.id, 'Room updated', { + elementsCount: room.data?.length || 0, + filesCount: Object.keys(room.files || {}).length, + }) + } + + /** + * Creates a backup of room data + * @param {string} roomId - Room identifier + * @param {Room} room - Room instance to backup + * @return {Promise} + */ + async createRoomBackup(roomId, room) { + const backupData = { + elements: Array.isArray(room.data) ? [...room.data] : [], + files: { ...room.files }, + savedAt: Date.now(), + } + try { - const result = await this.apiService.getRoomDataFromServer(roomId, jwtToken) - console.log(`[${roomId}] Fetched data from server: \n`, result) - return result?.data || { elements: [], files: {} } + await this.backupManager.createBackup(roomId, backupData) + Utils.logOperation(roomId, 'Backup created', { + elementsCount: backupData.elements.length, + filesCount: Object.keys(backupData.files).length, + }) } catch (error) { - console.error(`[${roomId}] Failed to fetch data from server:`, error) - return { elements: [], files: {} } + Utils.logError(roomId, 'Backup creation failed', error) } } - async removeAllRoomData() { - await this.storageManager.clear() + /** + * Fetches and validates room data from server + * @param {string} roomId - Room identifier + * @param {string} jwtToken - JWT token for authentication + * @return {Promise} Room data or null if fetch fails + */ + async fetchRoomData(roomId, jwtToken) { + Utils.logOperation(roomId, 'Fetching server data') + + try { + const result = await this.apiService.getRoomDataFromServer( + roomId, + jwtToken, + ) + + if (!this.isValidServerData(result)) { + Utils.logOperation( + roomId, + 'Server data is invalid, recovering from backup', + ) + return await this.tryRecoverFromBackup(roomId) + } + + const serverData = result.data + const backupData = await this.tryRecoverFromBackup(roomId) + + if ( + backupData + && (await this.backupManager.isDataFresher(roomId, serverData)) + ) { + Utils.logOperation( + roomId, + 'Server data is fresher than backup, using server data', + ) + return this.normalizeRoomData(serverData) + } + + Utils.logOperation( + roomId, + 'Server data is older than backup, using backup data', + ) + return backupData + ? this.normalizeRoomData(backupData) + : this.normalizeRoomData(serverData) + } catch (error) { + Utils.logError(roomId, 'Server fetch failed, using backup', error) + return await this.tryRecoverFromBackup(roomId) + } + } + + /** + * Retrieves existing room or creates new one + * @param {string} roomId - Room identifier + * @return {Promise} Room instance + */ + async getOrCreateRoom(roomId) { + return (await this.storageManager.get(roomId)) || new Room(roomId) + } + + /** + * Validates server response data structure + * @param {object} result - Server response + * @return {boolean} Whether data is valid + */ + isValidServerData(result) { + return ( + result?.data + && (Array.isArray(result.data.elements) + || typeof result.data.elements === 'object') + ) + } + + /** + * Attempts to recover room data from backup + * @param {string} roomId - Room identifier + * @return {Promise} Recovered data or null + */ + async tryRecoverFromBackup(roomId) { + const backupData = await this.backupManager.recoverFromBackup(roomId) + if (backupData) { + Utils.logOperation(roomId, 'Recovered from backup') + } + return backupData + } + + /** + * Handles empty room cleanup + * @param {string} roomId - Room identifier + * @return {Promise} + */ + async handleEmptyRoom(roomId) { + await this.cleanupEmptyRoom(roomId) + return null + } + + /** + * Removes empty room from storage + * @param {string} roomId - Room identifier + * @return {Promise} + */ + async cleanupEmptyRoom(roomId) { + await this.storageManager.delete(roomId) + Utils.logOperation(roomId, 'Empty room removed from cache') } } diff --git a/websocket_server/ServerManager.js b/websocket_server/ServerManager.js index 4068bbb..20c008f 100644 --- a/websocket_server/ServerManager.js +++ b/websocket_server/ServerManager.js @@ -16,6 +16,7 @@ import RoomDataManager from './RoomDataManager.js' import AppManager from './AppManager.js' import SocketManager from './SocketManager.js' import Utils from './Utils.js' +import BackupManager from './BackupManager.js' export default class ServerManager { @@ -24,8 +25,9 @@ export default class ServerManager { this.closing = false this.tokenGenerator = new SharedTokenGenerator() this.apiService = new ApiService(this.tokenGenerator) + this.backupManager = new BackupManager({}) this.storageManager = StorageManager.create(this.config.storageStrategy, this.apiService) - this.roomDataManager = new RoomDataManager(this.storageManager, this.apiService) + this.roomDataManager = new RoomDataManager(this.storageManager, this.apiService, this.backupManager) this.appManager = new AppManager(this.storageManager) this.server = this.createConfiguredServer(this.appManager.getApp()) this.socketManager = new SocketManager(this.server, this.roomDataManager, this.storageManager) diff --git a/websocket_server/SocketManager.js b/websocket_server/SocketManager.js index de45e32..7ede85e 100644 --- a/websocket_server/SocketManager.js +++ b/websocket_server/SocketManager.js @@ -202,13 +202,27 @@ export default class SocketManager { socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) const decryptedData = JSON.parse(Utils.convertArrayBufferToString(encryptedData)) - const socketData = await this.socketDataManager.getSocketData(socket.id) + + this.queueRoomUpdate(roomID, { + elements: decryptedData.payload.elements, + }, socket.id) + } + + async processRoomDataUpdate(roomID, updateData, socketId) { + const socketData = await this.socketDataManager.getSocketData(socketId) if (!socketData) return + const userSocketsAndIds = await this.getUserSocketsAndIds(roomID) + const currentRoom = await this.storageManager.get(roomID) + + const roomData = { + elements: updateData.elements || currentRoom?.data || [], + files: updateData.files || currentRoom?.files || {}, + } await this.roomDataManager.syncRoomData( roomID, - decryptedData.payload.elements, + roomData, userSocketsAndIds.map(u => u.userId), socketData.user.id, ) @@ -246,11 +260,14 @@ export default class SocketManager { if (!socket.rooms.has(roomID) || isReadOnly) return socket.broadcast.to(roomID).emit('image-data', data) + const room = await this.storageManager.get(roomID) + const currentFiles = { ...room.files, [id]: data } - console.log(`[${roomID}] ${socket.id} added image ${id}`) - room.addFile(id, data) - this.storageManager.set(roomID, room) + this.queueRoomUpdate(roomID, { + elements: room.data, + files: currentFiles, + }, socket.id) } async imageRemoveHandler(socket, roomID, id) { @@ -258,8 +275,15 @@ export default class SocketManager { if (!socket.rooms.has(roomID) || isReadOnly) return socket.broadcast.to(roomID).emit('image-remove', id) + const room = await this.storageManager.get(roomID) - room.removeFile(id) + const currentFiles = { ...room.files } + delete currentFiles[id] + + this.queueRoomUpdate(roomID, { + elements: room.data, + files: currentFiles, + }, socket.id) } async imageGetHandler(socket, roomId, id) { @@ -286,16 +310,23 @@ export default class SocketManager { for (const roomID of rooms) { console.log(`[${roomID}] ${socketData.user.name} has left ${roomID}`) - const userSocketsAndIds = await this.getUserSocketsAndIds(roomID) const otherUserSockets = userSocketsAndIds.filter(u => u.socketId !== socket.id) if (otherUserSockets.length > 0) { - this.io.to(roomID).emit('room-user-change', userSocketsAndIds) + this.io.to(roomID).emit('room-user-change', otherUserSockets) + } else { + this.roomDataManager.cleanupEmptyRoom(roomID) } - await this.roomDataManager.syncRoomData(roomID, null, userSocketsAndIds.map(u => u.userId)) + this.queueRoomUpdate(roomID, {}, socket.id) } } + async queueRoomUpdate(roomID, updateData, socketId) { + this.processRoomDataUpdate(roomID, updateData, socketId).catch(error => { + console.error(`Failed to process room update for ${roomID}:`, error) + }) + } + } diff --git a/websocket_server/Utils.js b/websocket_server/Utils.js index ac0d606..d230806 100644 --- a/websocket_server/Utils.js +++ b/websocket_server/Utils.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* eslint-disable no-console */ + export default class Utils { static convertStringToArrayBuffer(string) { @@ -17,4 +19,24 @@ export default class Utils { return value === 'true' } + /** + * Logs operation details + * @param {string} roomId - Room identifier + * @param {string} message - Log message + * @param {object} [data] - Additional data to log + */ + static logOperation(roomId, message, data = {}) { + console.log(`[${roomId}] ${message}:`, data) + } + + /** + * Logs error details + * @param {string} roomId - Room identifier + * @param {string} message - Error message + * @param {Error} error - Error object + */ + static logError(roomId, message, error) { + console.error(`[${roomId}] ${message}:`, error) + } + }