From 6606f6df9c36c7ea5f75891eea16d0f9bd1e9e7b Mon Sep 17 00:00:00 2001 From: Hoang Pham Date: Tue, 18 Jun 2024 16:31:48 +0700 Subject: [PATCH] feat: read only handler Signed-off-by: Hoang Pham --- lib/Controller/WhiteboardController.php | 37 +++- websocket_server/index.js | 249 ------------------------ websocket_server/roomData.js | 38 ++-- websocket_server/server.js | 29 ++- websocket_server/socket.js | 9 +- 5 files changed, 78 insertions(+), 284 deletions(-) delete mode 100644 websocket_server/index.js diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 18b53c66..ad0a8235 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -5,6 +5,7 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Whiteboard\Controller; use Firebase\JWT\JWT; @@ -33,14 +34,42 @@ public function __construct( parent::__construct($appName, $request); } + /** + * @throws NotPermittedException + * @throws NoUserException + * @throws \JsonException + */ #[NoAdminRequired] #[NoCSRFRequired] #[PublicPage] public function update(int $fileId, array $data): DataResponse { - $user = $this->userSession->getUser(); - $userFolder = $this->rootFolder->getUserFolder($user?->getUID()); + $authHeader = $this->request->getHeader('Authorization'); + + if (!$authHeader) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + [$jwt] = sscanf($authHeader, 'Bearer %s'); + + if (!$jwt) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + try { + $key = $this->config->getSystemValueString('jwt_secret_key'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $userId = $decoded->userid; + } catch (\Exception $e) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $userFolder = $this->rootFolder->getUserFolder($userId); $file = $userFolder->getById($fileId)[0]; + if (empty($data)) { + $data = ['elements' => [], 'scrollToContent' => true]; + } + $file->putContent(json_encode($data, JSON_THROW_ON_ERROR)); return new DataResponse(['status' => 'success']); @@ -79,9 +108,11 @@ public function show(int $fileId): DataResponse { $file = $userFolder->getById($fileId)[0]; $fileContent = $file->getContent(); - if ($fileContent === '') { + + if (empty($fileContent)) { $fileContent = '{"elements":[],"scrollToContent":true}'; } + $data = json_decode($fileContent, true, 512, JSON_THROW_ON_ERROR); return new DataResponse([ diff --git a/websocket_server/index.js b/websocket_server/index.js deleted file mode 100644 index 410fdadb..00000000 --- a/websocket_server/index.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import express from 'express' -import http from 'http' -import https from 'https' -import { Server as SocketIO } from 'socket.io' -import fetch from 'node-fetch' -import * as fs from 'node:fs' - -const nextcloudUrl = process.env.NEXTCLOUD_URL || 'http://nextcloud.local' -const port = process.env.PORT || 3002 - -const tls = process.env.TLS || false -const key = process.env.TLS_KEY || undefined -const cert = process.env.TLS_CERT || undefined - -const app = express() - -app.get('/', (req, res) => { - res.send('Excalidraw collaboration server is up :)') -}) - -const server = (tls ? https : http).createServer({ - key: key ? fs.readFileSync(key) : undefined, - cert: cert ? fs.readFileSync(cert) : undefined, -}, app) - -let roomDataStore = {} - -const getRoomDataFromFile = async (roomID) => { - const response = await fetch(`${nextcloudUrl}/index.php/apps/whiteboard/${roomID}`, { - headers: { - Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), - }, - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() - const roomData = data.data - - return JSON.stringify(roomData.elements) -} - -const convertStringToArrayBuffer = (string) => { - return new TextEncoder().encode(string).buffer -} - -const convertArrayBufferToString = (arrayBuffer) => { - return new TextDecoder().decode(arrayBuffer) -} - -const saveRoomDataToFile = async (roomID, data) => { - console.info(`Saving room data to file: ${roomID}`) - - const body = JSON.stringify({ data: { elements: data } }) - - try { - await fetch(`${nextcloudUrl}/index.php/apps/whiteboard/${roomID}`, { - method: 'PUT', - headers: { - Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), - 'Content-Type': 'application/json', - }, - body, - }) - } catch (error) { - console.error(error) - } -} - -const saveAllRoomsData = async () => { - for (const roomID in roomDataStore) { - if (roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) - } - } -} - -const io = new SocketIO(server, { - transports: ['websocket', 'polling'], - cors: { - allowedHeaders: ['X-Requested-With', 'Content-Type', 'Authorization'], - origin: '*', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - }, - allowEIO3: true, -}) - -io.on('connection', async (socket) => { - io.to(`${socket.id}`).emit('init-room') - - socket.on('join-room', async (roomID) => { - console.debug(`${socket.id} has joined ${roomID}`) - await socket.join(roomID) - - if (!roomDataStore[roomID]) { - roomDataStore[roomID] = await getRoomDataFromFile(roomID) - } - - socket.emit('joined-data', convertStringToArrayBuffer(roomDataStore[roomID]), []) - - const sockets = await io.in(roomID).fetchSockets() - - if (sockets.length <= 1) { - io.to(`${socket.id}`).emit('first-in-room') - } else { - console.debug(`${socket.id} new-user emitted to room ${roomID}`) - socket.broadcast.to(roomID).emit('new-user', socket.id) - } - - io.in(roomID).emit('room-user-change', sockets.map((socket) => socket.id)) - }) - - socket.on('server-broadcast', (roomID, encryptedData, iv) => { - console.debug(`Broadcasting to room ${roomID}`) - - socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) - - const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) - - setTimeout(() => { - roomDataStore[roomID] = decryptedData.payload.elements - }) - }) - - socket.on('server-volatile-broadcast', (roomID, encryptedData, iv) => { - console.debug(`Volatile broadcasting to room ${roomID}`) - - socket.volatile.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) - - const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) - - console.debug(decryptedData.payload) - - // setTimeout(() => { - // roomDataStore[roomID] = decryptedData.payload.elements - // }) - }) - - socket.on('user-follow', async (payload) => { - console.debug(`User follow action: ${JSON.stringify(payload)}`) - const roomID = `follow@${payload.userToFollow.socketId}` - - switch (payload.action) { - case 'FOLLOW': { - await socket.join(roomID) - - const sockets = await io.in(roomID).fetchSockets() - const followedBy = sockets.map((socket) => socket.id) - - io.to(payload.userToFollow.socketId).emit('user-follow-room-change', followedBy) - - break - } - case 'UNFOLLOW': { - await socket.leave(roomID) - - const sockets = await io.in(roomID).fetchSockets() - const followedBy = sockets.map((socket) => socket.id) - - io.to(payload.userToFollow.socketId).emit('user-follow-room-change', followedBy) - - break - } - } - }) - - socket.on('disconnecting', async () => { - console.debug(`${socket.id} has disconnected`) - - for (const roomID of Array.from(socket.rooms)) { - if (roomID === socket.id) continue - - console.debug(`${socket.id} has left ${roomID}`) - - const otherClients = (await io.in(roomID).fetchSockets()).filter((_socket) => _socket.id !== socket.id) - - // Save room data if no one is in the room - if (otherClients.length === 0 && roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) - - // Flush room data if no one is in the room - delete roomDataStore[roomID] - } - - const isFollowRoom = roomID.startsWith('follow@') - - if (!isFollowRoom && otherClients.length > 0) { - socket.broadcast.to(roomID).emit('room-user-change', otherClients.map((socket) => socket.id)) - } - - if (isFollowRoom && otherClients.length === 0) { - const socketId = roomID.replace('follow@', '') - io.to(socketId).emit('broadcast-unfollow') - } - } - }) - - socket.on('disconnect', async () => { - socket.removeAllListeners() - socket.disconnect() - }) -}) - -// Save all rooms data every 1 hour -const interval = setInterval(saveAllRoomsData, 60 * 60 * 1000) - -// Graceful Shutdown -const gracefulShutdown = async () => { - console.debug('Received shutdown signal, saving all data...') - await saveAllRoomsData() - console.debug('All data saved, shutting down server...') - clearInterval(interval) - roomDataStore = {} - - // Close the server gracefully - server.close(() => { - console.debug('HTTP server closed.') - - // eslint-disable-next-line n/no-process-exit - process.exit(0) - }) - - // Force close the server if it doesn't close within 1 minute - setTimeout(() => { - console.error('Force closing server after 1 minute.') - - // eslint-disable-next-line n/no-process-exit - process.exit(1) - }, 60 * 1000) - - io.close(() => { - console.debug('Socket server closed.') - }) -} - -// Handle shutdown signals -process.on('SIGTERM', gracefulShutdown) -process.on('SIGINT', gracefulShutdown) - -server.listen(port, () => { - console.debug(`listening on port: ${port}`) -}) diff --git a/websocket_server/roomData.js b/websocket_server/roomData.js index c06f5303..cd88517e 100644 --- a/websocket_server/roomData.js +++ b/websocket_server/roomData.js @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -/* eslint-disable n/no-process-exit */ import fetch from 'node-fetch' import dotenv from 'dotenv' @@ -9,16 +8,15 @@ dotenv.config() const { NEXTCLOUD_URL = 'http://nextcloud.local', ADMIN_USER = 'admin', - ADMIN_PASS = 'admin', + ADMIN_PASS = 'admin' } = process.env -const FORCE_CLOSE_TIMEOUT = 60 * 1000 -export let roomDataStore = {} +export const roomDataStore = {} const fetchOptions = (method, token, body = null) => { const headers = { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${token}` } if (method === 'PUT') { @@ -28,7 +26,7 @@ const fetchOptions = (method, token, body = null) => { return { method, headers, - ...(body && { body: JSON.stringify(body) }), + ...(body && { body: JSON.stringify(body) }) } } @@ -58,6 +56,7 @@ export const getRoomDataFromFile = async (roomID, socket) => { return result ? result.data.elements : null } +// 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(`Saving room data to file: ${roomID}`) const url = `${NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` @@ -67,27 +66,16 @@ export const saveRoomDataToFile = async (roomID, data) => { 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 removeAllRoomData = async () => { for (const roomID in roomDataStore) { - if (Object.prototype.hasOwnProperty.call(roomDataStore, roomID) && roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) + if (Object.prototype.hasOwnProperty.call(roomDataStore, roomID)) { + delete roomDataStore[roomID] } } } - -export const gracefulShutdown = async (server) => { - console.log('Received shutdown signal, saving all data...') - await saveAllRoomsData() - console.log('All data saved, shutting down server...') - roomDataStore = {} - - server.close(() => { - console.log('HTTP server closed.') - process.exit(0) - }) - - setTimeout(() => { - console.error('Force closing server after 1 minute.') - process.exit(1) - }, FORCE_CLOSE_TIMEOUT) -} diff --git a/websocket_server/server.js b/websocket_server/server.js index a43342d0..4728163e 100644 --- a/websocket_server/server.js +++ b/websocket_server/server.js @@ -1,11 +1,12 @@ /* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ import http from 'http' import https from 'https' import fs from 'fs' import app from './app.js' import { initSocket } from './socket.js' -import { gracefulShutdown, saveAllRoomsData } from './roomData.js' +import { removeAllRoomData, saveAllRoomsData } from './roomData.js' import dotenv from 'dotenv' import { parseBooleanFromEnv } from './utils.js' @@ -15,12 +16,14 @@ const { PORT = 3002, TLS, TLS_KEY: keyPath, - TLS_CERT: certPath, + TLS_CERT: certPath } = process.env +const FORCE_CLOSE_TIMEOUT = 60 * 60 * 1000 + const readTlsCredentials = (keyPath, certPath) => ({ key: keyPath ? fs.readFileSync(keyPath) : undefined, - cert: certPath ? fs.readFileSync(certPath) : undefined, + cert: certPath ? fs.readFileSync(certPath) : undefined }) const createConfiguredServer = (app) => { @@ -39,10 +42,26 @@ server.listen(PORT, () => { console.log(`Listening on port: ${PORT}`) }) -const interval = setInterval(saveAllRoomsData, 60 * 60 * 1000) +export const gracefulShutdown = async (server) => { + console.log('Received shutdown signal, saving all data...') + await saveAllRoomsData() + + console.log('Clear all room data...') + await removeAllRoomData() + + console.log('Closing server...') + server.close(() => { + console.log('HTTP server closed.') + process.exit(0) + }) + + setTimeout(() => { + console.error('Force closing server after 1 hour') + process.exit(1) + }, FORCE_CLOSE_TIMEOUT) +} const shutdown = async () => { - clearInterval(interval) // Stop the regular saving of room data await gracefulShutdown(server) // Perform graceful shutdown tasks } diff --git a/websocket_server/socket.js b/websocket_server/socket.js index 9496c64c..5acdb242 100644 --- a/websocket_server/socket.js +++ b/websocket_server/socket.js @@ -63,7 +63,7 @@ const socketAuthenticateHandler = async (socket, next) => { console.log(`User ${socket.decodedData.user.name} with permission ${socket.decodedData.permissions} connected`) - if (socket.decodedData.permissions === 1) { + if (isSocketReadOnly(socket)) { socket.emit('read-only') } @@ -95,8 +95,11 @@ const joinRoomHandler = async (socket, io, roomID) => { } const serverBroadcastHandler = (socket, io, roomID, encryptedData, iv) => { + if (isSocketReadOnly(socket)) return + setTimeout(() => { const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) + roomDataStore[roomID] = decryptedData.payload.elements }) @@ -130,7 +133,7 @@ const disconnectingHandler = async (socket, io) => { if (otherClients.length === 0 && roomDataStore[roomID]) { await saveRoomDataToFile(roomID, roomDataStore[roomID]) - delete roomDataStore[roomID] + // delete roomDataStore[roomID] } if (otherClients.length > 0) { @@ -141,3 +144,5 @@ const disconnectingHandler = async (socket, io) => { } } } + +const isSocketReadOnly = (socket) => socket.decodedData.permissions === 1