From 408f158b3b4d257a95179acbeba28bf80161bfdc Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Tue, 16 Jul 2024 18:41:13 +0700 Subject: [PATCH] feat: Saving file Signed-off-by: Hoang Pham feat: Saving file Signed-off-by: Hoang Pham feat: Saving file Signed-off-by: Hoang Pham feat: Saving file Signed-off-by: Hoang Pham --- .env.example | 3 + composer.json | 4 +- lib/Consts/JWTConsts.php | 15 +++ lib/Controller/JWTController.php | 92 +++-------------- lib/Controller/WhiteboardController.php | 93 +++++------------- lib/Service/AuthenticationService.php | 110 +++++++++++++++++++++ lib/Service/ConfigService.php | 10 +- lib/Service/ExceptionService.php | 49 +++++++++ lib/Service/FileService.php | 66 +++++++++++++ lib/Service/JWTService.php | 47 +++++++++ lib/Service/WhiteboardContentService.php | 47 +++++++++ src/collaboration/Portal.ts | 35 +++++-- websocket_server/.gitignore | 4 + websocket_server/auth.js | 20 ++++ websocket_server/roomData.js | 120 ++++++++++++++--------- websocket_server/socket.js | 91 ++++++++--------- 16 files changed, 559 insertions(+), 247 deletions(-) create mode 100644 lib/Consts/JWTConsts.php create mode 100644 lib/Service/AuthenticationService.php create mode 100644 lib/Service/ExceptionService.php create mode 100644 lib/Service/FileService.php create mode 100644 lib/Service/JWTService.php create mode 100644 lib/Service/WhiteboardContentService.php create mode 100644 websocket_server/.gitignore create mode 100644 websocket_server/auth.js diff --git a/.env.example b/.env.example index d73e138..5ca8482 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ TLS=false TLS_KEY= TLS_CERT= +# Turn off SSL certificate validation in development mode for easier testing +IS_DEV=false + # Prometheus metrics endpoint # Set this to access the monitoring endpoint at /metrics # either providing it as Bearer token or as ?token= query parameter diff --git a/composer.json b/composer.json index 8cc74a9..e09b1a3 100644 --- a/composer.json +++ b/composer.json @@ -4,13 +4,13 @@ "autoloader-suffix": "Whiteboard", "optimize-autoloader": true, "platform": { - "php": "8.1" + "php": "8.0" }, "sort-packages": true }, "license": "AGPL", "require": { - "php": "^8.1", + "php": "^8.0", "firebase/php-jwt": "^6.10" }, "require-dev": { diff --git a/lib/Consts/JWTConsts.php b/lib/Consts/JWTConsts.php new file mode 100644 index 0000000..f93587a --- /dev/null +++ b/lib/Consts/JWTConsts.php @@ -0,0 +1,15 @@ +userSession->isLoggedIn()) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - - if ($user === null) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - - $userId = $user->getUID(); - try { - $folder = $this->rootFolder->getUserFolder($userId); - } catch (NotPermittedException $e) { - return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); - } catch (NoUserException $e) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - - $file = $folder->getById($fileId)[0] ?? null; - - if ($file === null) { - return new DataResponse(['message' => 'File not found or access denied'], Http::STATUS_FORBIDDEN); - } - try { - $readable = $file->isReadable(); - } catch (InvalidPathException|NotFoundException $e) { - return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); + $user = $this->authService->getAuthenticatedUser(); + $file = $this->fileService->getUserFileById($user->getUID(), $fileId); + $jwt = $this->jwtService->generateJWT($user, $file, $fileId); + return new DataResponse(['token' => $jwt]); + } catch (\Exception $e) { + return $this->exceptionService->handleException($e); } - - if (!$readable) { - return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); - } - - try { - $permissions = $file->getPermissions(); - } catch (InvalidPathException $e) { - return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); - } catch (NotFoundException $e) { - return new DataResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND); - } - - $key = $this->configService->getJwtSecretKey(); - $issuedAt = time(); - $expirationTime = $issuedAt + self::EXPIRATION_TIME; - $payload = [ - 'userid' => $userId, - 'fileId' => $fileId, - 'permissions' => $permissions, - 'user' => [ - 'id' => $userId, - 'name' => $user->getDisplayName() - ], - 'iat' => $issuedAt, - 'exp' => $expirationTime - ]; - - $jwt = JWT::encode($payload, $key, self::JWT_ALGORITHM); - - return new DataResponse(['token' => $jwt]); } } diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index a133f13..99b6568 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -1,6 +1,7 @@ userSession->getUser(); - $userFolder = $this->rootFolder->getUserFolder($user?->getUID()); - $file = $userFolder->getById($fileId)[0]; - - if (empty($data)) { - $data = ['elements' => [], 'scrollToContent' => true]; + public function show(int $fileId): DataResponse { + try { + $userId = $this->authService->authenticateJWT($this->request); + $file = $this->fileService->getUserFileById($userId, $fileId); + $data = $this->contentService->getContent($file); + return new DataResponse(['data' => $data]); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); } - - $file->putContent(json_encode($data, JSON_THROW_ON_ERROR)); - - return new DataResponse(['status' => 'success']); } - /** - * @throws NotPermittedException - * @throws NoUserException - * @throws \JsonException - */ #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] - public function show(int $fileId): DataResponse { - $authHeader = $this->request->getHeader('Authorization'); - - if (!$authHeader) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - - $assignedValues = sscanf($authHeader, 'Bearer %s', $jwt); - - if (!$assignedValues) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - - if (!$jwt || !is_string($jwt)) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); - } - + public function update(int $fileId, array $data): DataResponse { try { - $key = $this->configService->getJwtSecretKey(); - $decoded = JWT::decode($jwt, new Key($key, JWTController::JWT_ALGORITHM)); - $userId = $decoded->userid; - } catch (\Exception $e) { - return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + $this->authService->authenticateSharedToken($this->request, $fileId); + $user = $this->authService->getAndSetUser($this->request); + $file = $this->fileService->getUserFileById($user->getUID(), $fileId); + $this->contentService->updateContent($file, $data); + return new DataResponse(['status' => 'success']); + } catch (Exception $e) { + return $this->exceptionService->handleException($e); } - - $userFolder = $this->rootFolder->getUserFolder($userId); - $file = $userFolder->getById($fileId)[0]; - - $fileContent = $file->getContent(); - if ($fileContent === '') { - $fileContent = '{"elements":[],"scrollToContent":true}'; - } - $data = json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); - - return new DataResponse([ - 'data' => $data, - ]); } } diff --git a/lib/Service/AuthenticationService.php b/lib/Service/AuthenticationService.php new file mode 100644 index 0000000..2f0d56d --- /dev/null +++ b/lib/Service/AuthenticationService.php @@ -0,0 +1,110 @@ +getHeader('Authorization'); + if (!$authHeader || sscanf($authHeader, 'Bearer %s', $jwt) !== 1) { + throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); + } + + if (!is_string($jwt)) { + throw new RuntimeException('JWT token must be a string', Http::STATUS_BAD_REQUEST); + } + + try { + $key = $this->configService->getJwtSecretKey(); + + return JWT::decode($jwt, new Key($key, JWTConsts::JWT_ALGORITHM))->userid; + } catch (Exception) { + throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); + } + } + + /** + * @throws Exception + */ + public function getAuthenticatedUser(): IUser { + if (!$this->userSession->isLoggedIn()) { + throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); + } + + return $user; + } + + /** + * @throws Exception + */ + public function authenticateSharedToken(IRequest $request, int $fileId): void { + $whiteboardAuth = $request->getHeader('X-Whiteboard-Auth'); + if (!$whiteboardAuth || !$this->verifySharedToken($whiteboardAuth, $fileId)) { + throw new RuntimeException('Unauthorized', Http::STATUS_UNAUTHORIZED); + } + } + + private function verifySharedToken(string $token, int $fileId): bool { + [$roomId, $timestamp, $signature] = explode(':', $token); + + if ($roomId !== (string)$fileId) { + return false; + } + + $sharedSecret = $this->configService->getWhiteboardSharedSecret(); + $payload = "$roomId:$timestamp"; + $expectedSignature = hash_hmac('sha256', $payload, $sharedSecret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * @throws Exception + */ + public function getAndSetUser(IRequest $request): IUser { + $whiteboardUser = $request->getHeader('X-Whiteboard-User'); + $user = $this->userManager->get($whiteboardUser); + if (!$user) { + throw new RuntimeException('Invalid user', Http::STATUS_BAD_REQUEST); + } + + $this->userSession->setVolatileActiveUser($user); + + return $user; + } +} diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 60f9c57..d326eae 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -11,17 +11,21 @@ use OCP\AppFramework\Services\IAppConfig; -class ConfigService { +final class ConfigService { public function __construct( private IAppConfig $appConfig, ) { } public function getJwtSecretKey(): string { - return $this->appConfig->getAppValueString('jwt_secret_key', ''); + return $this->appConfig->getAppValueString('jwt_secret_key'); } public function getCollabBackendUrl(): string { - return $this->appConfig->getAppValueString('collabBackendUrl', ''); + return $this->appConfig->getAppValueString('collabBackendUrl'); + } + + public function getWhiteboardSharedSecret(): string { + return $this->appConfig->getAppValueString('jwt_secret_key'); } } diff --git a/lib/Service/ExceptionService.php b/lib/Service/ExceptionService.php new file mode 100644 index 0000000..a2050cf --- /dev/null +++ b/lib/Service/ExceptionService.php @@ -0,0 +1,49 @@ +getStatusCode($e); + $message = $this->getMessage($e); + + return new DataResponse(['message' => $message], $statusCode); + } + + private function getStatusCode(Exception $e): int { + if ($e instanceof NotFoundException) { + return Http::STATUS_NOT_FOUND; + } + if ($e instanceof NotPermittedException) { + return Http::STATUS_FORBIDDEN; + } + + return (int)($e->getCode() ?: Http::STATUS_INTERNAL_SERVER_ERROR); + } + + private function getMessage(Exception $e): string { + if ($e instanceof NotFoundException) { + return 'File not found'; + } + if ($e instanceof NotPermittedException) { + return 'Permission denied'; + } + return $e->getMessage() ?: 'An error occurred'; + } +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php new file mode 100644 index 0000000..e8252e3 --- /dev/null +++ b/lib/Service/FileService.php @@ -0,0 +1,66 @@ +rootFolder->getUserFolder($userId); + + $file = $userFolder->getFirstNodeById($fileId); + if ($file instanceof File && $file->getPermissions() & Constants::PERMISSION_UPDATE) { + return $file; + } + + $files = $userFolder->getById($fileId); + if (empty($files)) { + throw new NotFoundException('File not found'); + } + + usort($files, static function (Node $a, Node $b) { + return ($b->getPermissions() & Constants::PERMISSION_UPDATE) <=> ($a->getPermissions() & Constants::PERMISSION_UPDATE); + }); + + $file = $files[0]; + if (!$file instanceof File) { + throw new NotFoundException('Not a file'); + } + + if (!($file->getPermissions() & Constants::PERMISSION_READ)) { + throw new NotPermittedException('No read permission'); + } + + return $file; + } +} diff --git a/lib/Service/JWTService.php b/lib/Service/JWTService.php new file mode 100644 index 0000000..8fecbfb --- /dev/null +++ b/lib/Service/JWTService.php @@ -0,0 +1,47 @@ +configService->getJwtSecretKey(); + $issuedAt = time(); + $expirationTime = $issuedAt + JWTConsts::EXPIRATION_TIME; + $payload = [ + 'userid' => $user->getUID(), + 'fileId' => $fileId, + 'permissions' => $file->getPermissions(), + 'user' => [ + 'id' => $user->getUID(), + 'name' => $user->getDisplayName() + ], + 'iat' => $issuedAt, + 'exp' => $expirationTime + ]; + + return JWT::encode($payload, $key, JWTConsts::JWT_ALGORITHM); + } +} diff --git a/lib/Service/WhiteboardContentService.php b/lib/Service/WhiteboardContentService.php new file mode 100644 index 0000000..741f163 --- /dev/null +++ b/lib/Service/WhiteboardContentService.php @@ -0,0 +1,47 @@ +getContent(); + if ($fileContent === '') { + $fileContent = '{"elements":[],"scrollToContent":true}'; + } + + return json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException + * @throws JsonException + */ + public function updateContent(File $file, array $data): void { + if (empty($data)) { + $data = ['elements' => [], 'scrollToContent' => true]; + } + + $file->putContent(json_encode($data, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index bf60405..02be2e2 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -31,36 +31,55 @@ export class Portal { connectSocket = () => { const collabBackendUrl = loadState('whiteboard', 'collabBackendUrl', '') - const token = localStorage.getItem(`jwt-${this.roomId}`) || '' const url = new URL(collabBackendUrl) const path = url.pathname.replace(/\/$/, '') + '/socket.io' + const socket = io(url.origin, { path, withCredentials: true, auth: { token, }, + transports: ['websocket', 'polling'], + timeout: 10000, + }).connect() + + socket.on('connect_error', (error) => { + if (error && error.message && !error.message.includes('Authentication error')) { + this.handleConnectionError() + } + }) + + socket.on('connect_timeout', () => { + this.handleConnectionError() }) this.open(socket) } + handleConnectionError = () => { + alert('Failed to connect to the whiteboard server. Redirecting to Files app.') + window.location.href = '/index.php/apps/files/files' + } + disconnectSocket = () => { - this.socket?.disconnect() + if (this.socket) { + this.socket.disconnect() + localStorage.removeItem(`jwt-${this.roomId}`) + console.log(`Disconnected from room ${this.roomId} and cleared JWT token`) + } } open(socket: Socket) { this.socket = socket - const eventsNeedingTokenRefresh = ['connect_error'] - eventsNeedingTokenRefresh.forEach((event) => - this.socket?.on(event, async () => { + this.socket?.on('connect_error', async (error) => { + if (error && error.message && error.message.includes('Authentication error')) { await this.handleTokenRefresh() - }), - ) - + } + }) this.socket.on('read-only', () => this.handleReadOnlySocket()) this.socket.on('init-room', () => this.handleInitRoom()) this.socket.on('room-user-change', (users: { diff --git a/websocket_server/.gitignore b/websocket_server/.gitignore new file mode 100644 index 0000000..b5ef181 --- /dev/null +++ b/websocket_server/.gitignore @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +*.pem diff --git a/websocket_server/auth.js b/websocket_server/auth.js new file mode 100644 index 0000000..a2aa0fd --- /dev/null +++ b/websocket_server/auth.js @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import crypto from 'crypto' +import dotenv from 'dotenv' + +dotenv.config() + +const SHARED_SECRET = process.env.JWT_SECRET_KEY + +export function generateSharedToken(roomId) { + const timestamp = Date.now() + const payload = `${roomId}:${timestamp}` + const hmac = crypto.createHmac('sha256', SHARED_SECRET) + hmac.update(payload) + const signature = hmac.digest('hex') + return `${payload}:${signature}` +} diff --git a/websocket_server/roomData.js b/websocket_server/roomData.js index d3683af..c5f6e70 100644 --- a/websocket_server/roomData.js +++ b/websocket_server/roomData.js @@ -7,80 +7,106 @@ import fetch from 'node-fetch' import dotenv from 'dotenv' +import { generateSharedToken } from './auth.js' +import https from 'https' +import { parseBooleanFromEnv } from './utils.js' dotenv.config() const { - NEXTCLOUD_URL = 'http://nextcloud.local', - ADMIN_USER = 'admin', - ADMIN_PASS = 'admin', + NEXTCLOUD_URL, + IS_DEV, } = process.env +const agent = parseBooleanFromEnv(IS_DEV) ? new https.Agent({ rejectUnauthorized: false }) : null + export const roomDataStore = {} +export const roomUsers = new Map() +export const lastEditedUser = new Map() -const fetchOptions = (method, token, body = null) => { - const headers = { +const fetchOptions = (method, token, body = null, roomId = null) => ({ + method, + headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - } - - if (method === 'PUT') { - headers.Authorization = 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64') - } - - return { - method, - headers, - ...(body && { body: JSON.stringify(body) }), - } -} - -const fetchData = async (url, options, socket = null, roomID = '') => { + ...(method === 'GET' && { Authorization: `Bearer ${token}` }), + ...(method === 'PUT' && { + 'X-Whiteboard-Auth': generateSharedToken(roomId), + 'X-Whiteboard-User': getLastEditedUser(roomId), + }), + }, + ...(body && { body: JSON.stringify(body) }), + ...(agent && { agent }), +}) + +const fetchData = async (url, options) => { try { const response = await fetch(url, options) - - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP error! status: ${response.status}: ${errorText}`) + } return response.json() } catch (error) { console.error(error) - if (socket) { - socket.emit('error', { message: 'Failed to get room data' }) - socket.leave(roomID) - } return null } } -export const getRoomDataFromFile = async (roomID, socket) => { - const token = socket.handshake.auth.token +export const getRoomDataFromFile = async (roomID, jwtToken) => { const url = `${NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` - const options = fetchOptions('GET', token) - - const result = await fetchData(url, options, socket, roomID) - return result ? result.data.elements : null + const options = fetchOptions('GET', jwtToken) + const result = await fetchData(url, options) + console.log(result) + return result?.data.elements } -// Called when there's nobody in the room (No one keeping the latest data), BE to BE communication -export const saveRoomDataToFile = async (roomID, data) => { - console.log(`[${roomID}] Saving room data to file: ${roomID}`) +export const saveRoomDataToFile = async (roomID) => { + console.log(`[${roomID}] Saving room data to file: ${roomID} with:`) const url = `${NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` - const body = { data: { elements: data } } - const options = fetchOptions('PUT', '', body) - + const body = { data: { elements: roomDataStore[roomID] } } + const options = fetchOptions('PUT', null, body, roomID) + console.log(options) await fetchData(url, options) } -// TODO: Should be called when the server is shutting down and a should be a BE to BE (or OS) communication -// in batch operation, run in background and check if it's necessary to save for each room. -// Should be called periodically and saved somewhere else for preventing data loss (memory loss, server crash, electricity cut, etc.) -export const saveAllRoomsData = async () => { +export const handleEmptyRoom = async (roomID) => { + if (roomDataStore[roomID]) { + await saveRoomDataToFile(roomID) + console.log('Removing data for room', roomID) + delete roomDataStore[roomID] + lastEditedUser.delete(roomID) + } +} + +export const saveAllRoomsData = () => + Promise.all(Object.entries(roomDataStore).map(([roomId, roomData]) => + saveRoomDataToFile(roomId, roomData))) + +export const removeAllRoomData = () => { + Object.keys(roomDataStore).forEach(key => delete roomDataStore[key]) + roomUsers.clear() +} + +export const addUserToRoom = (roomId, userId) => { + if (!roomUsers.has(roomId)) { + roomUsers.set(roomId, new Set()) + } + roomUsers.get(roomId).add(userId) } -export const removeAllRoomData = async () => { - for (const roomID in roomDataStore) { - if (Object.prototype.hasOwnProperty.call(roomDataStore, roomID)) { - delete roomDataStore[roomID] +export const removeUserFromRoom = (roomId, userId) => { + const room = roomUsers.get(roomId) + if (room) { + room.delete(userId) + if (room.size === 0) { + roomUsers.delete(roomId) } } } + +export const updateLastEditedUser = (roomId, userId) => { + lastEditedUser.set(roomId, userId) +} + +export const getLastEditedUser = (roomId) => + lastEditedUser.get(roomId) || (roomUsers.has(roomId) ? Array.from(roomUsers.get(roomId)).pop() : null) diff --git a/websocket_server/socket.js b/websocket_server/socket.js index 8c831e0..130fedd 100644 --- a/websocket_server/socket.js +++ b/websocket_server/socket.js @@ -8,7 +8,14 @@ import { Server as SocketIO } from 'socket.io' import prometheusMetrics from 'socket.io-prometheus' import jwt from 'jsonwebtoken' -import { getRoomDataFromFile, roomDataStore, saveRoomDataToFile } from './roomData.js' +import { + addUserToRoom, + getRoomDataFromFile, + handleEmptyRoom, + removeUserFromRoom, + roomDataStore, + updateLastEditedUser, +} from './roomData.js' import { convertArrayBufferToString, convertStringToArrayBuffer } from './utils.js' import dotenv from 'dotenv' @@ -19,17 +26,16 @@ const { JWT_SECRET_KEY, } = process.env -const verifyToken = (token) => new Promise((resolve, reject) => { - jwt.verify(token, JWT_SECRET_KEY, (err, decoded) => { - if (err) { - console.log(err.name === 'TokenExpiredError' ? 'Token expired' : 'Token verification failed') - - return reject(new Error('Authentication error')) - } - - resolve(decoded) +const verifyToken = (token) => + new Promise((resolve, reject) => { + jwt.verify(token, JWT_SECRET_KEY, (err, decoded) => { + if (err) { + console.log(err.name === 'TokenExpiredError' ? 'Token expired' : 'Token verification failed') + return reject(new Error('Authentication error')) + } + resolve(decoded) + }) }) -}) export const initSocket = (server) => { const io = new SocketIO(server, { @@ -42,12 +48,8 @@ export const initSocket = (server) => { }) io.use(socketAuthenticateHandler) - prometheusMetrics(io) - - io.on('connection', (socket) => { - setupSocketEvents(socket, io) - }) + io.on('connection', (socket) => setupSocketEvents(socket, io)) } const setupSocketEvents = (socket, io) => { @@ -61,24 +63,16 @@ const setupSocketEvents = (socket, io) => { const socketAuthenticateHandler = async (socket, next) => { try { - const token = socket.handshake.auth.token || null - if (!token) { - console.error('No token provided') - next(new Error('Authentication error')) - } + const token = socket.handshake.auth.token + if (!token) throw new Error('No token provided') socket.decodedData = await verifyToken(token) - console.log(`[${socket.decodedData.fileId}] User ${socket.decodedData.user.id} with permission ${socket.decodedData.permissions} connected`) - if (isSocketReadOnly(socket)) { - socket.emit('read-only') - } - + if (isSocketReadOnly(socket)) socket.emit('read-only') next() } catch (error) { console.error(error.message) - next(new Error('Authentication error')) } } @@ -86,32 +80,44 @@ const socketAuthenticateHandler = async (socket, next) => { const joinRoomHandler = async (socket, io, roomID) => { console.log(`[${roomID}] ${socket.decodedData.user.id} has joined ${roomID}`) await socket.join(roomID) + addUserToRoom(roomID, socket.decodedData.user.id) if (!roomDataStore[roomID]) { console.log(`[${roomID}] Data for room ${roomID} is not available, fetching from file ...`) - roomDataStore[roomID] = await getRoomDataFromFile(roomID, socket) + roomDataStore[roomID] = await getRoomDataFromFile(roomID, socket.handshake.auth.token) } socket.emit('joined-data', convertStringToArrayBuffer(JSON.stringify(roomDataStore[roomID])), []) const sockets = await io.in(roomID).fetchSockets() - - io.in(roomID).emit('room-user-change', sockets.map((s) => ({ + io.in(roomID).emit('room-user-change', sockets.map(s => ({ socketId: s.id, user: s.decodedData.user, }))) } const serverBroadcastHandler = (socket, io, roomID, encryptedData, iv) => { + // Check if the socket is part of the room, to avoid broadcasting with old socket + if (!socket.rooms.has(roomID)) { + console.log(`Socket ${socket.id} is not part of room ${roomID}, ignoring broadcast.`) + return + } + + // Check if roomDataStore[roomID] is populated + if (!roomDataStore[roomID]) { + console.log(`Data for room ${roomID} is not available, ignoring broadcast.`) + return + } + if (isSocketReadOnly(socket)) return + socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) + setTimeout(() => { const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) - roomDataStore[roomID] = decryptedData.payload.elements + updateLastEditedUser(roomID, socket.decodedData.user.id) }) - - socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) } const serverVolatileBroadcastHandler = (socket, roomID, encryptedData) => { @@ -125,10 +131,7 @@ const serverVolatileBroadcastHandler = (socket, roomID, encryptedData) => { user: socket.decodedData.user, }, } - - const encodedEventData = convertStringToArrayBuffer(JSON.stringify(eventData)) - - socket.volatile.broadcast.to(roomID).emit('client-broadcast', encodedEventData) + socket.volatile.broadcast.to(roomID).emit('client-broadcast', convertStringToArrayBuffer(JSON.stringify(eventData))) } } @@ -137,19 +140,19 @@ const disconnectingHandler = async (socket, io) => { for (const roomID of Array.from(socket.rooms)) { if (roomID === socket.id) continue console.log(`[${roomID}] ${socket.decodedData.user.name} has left ${roomID}`) - const otherClients = (await io.in(roomID).fetchSockets()).filter((s) => s.id !== socket.id) - if (otherClients.length === 0 && roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) - // delete roomDataStore[roomID] - } + const otherClients = (await io.in(roomID).fetchSockets()).filter(s => s.id !== socket.id) - if (otherClients.length > 0) { - socket.broadcast.to(roomID).emit('room-user-change', otherClients.map((s) => ({ + if (otherClients.length === 0) { + await handleEmptyRoom(roomID) + } else { + socket.broadcast.to(roomID).emit('room-user-change', otherClients.map(s => ({ socketId: s.id, user: s.decodedData.user, }))) } + + removeUserFromRoom(roomID, socket.decodedData.user.id) } }