From e53465ce11768244a849c41a61af6077e660a0d6 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Sun, 15 Dec 2024 00:03:13 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F(y-provider)=20organize=20?= =?UTF-8?q?yjs=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many routes were in the server.ts file, now they are in their own files in the handlers folder. The server.ts file is now AppServer that handles the routes. We split as well the tests. --- CHANGELOG.md | 4 + .../collaborationResetConnections.test.ts | 76 ++++++ .../__tests__/convertMarkdown.test.ts | 66 +++++ .../y-provider/__tests__/hocusPocusWS.test.ts | 169 ++++++++++++ .../y-provider/__tests__/server.test.ts | 243 +----------------- src/frontend/servers/y-provider/nodemon.json | 2 +- src/frontend/servers/y-provider/package.json | 3 +- .../collaborationResetConnectionsHandler.ts | 55 ++++ .../src/handlers/collaborationWSHandler.ts | 16 ++ .../src/handlers/convertMarkdownHandler.ts | 55 ++++ .../servers/y-provider/src/handlers/index.ts | 3 + src/frontend/servers/y-provider/src/server.ts | 211 --------------- .../y-provider/src/servers/appServer.ts | 65 +++++ .../src/servers/hocusPocusServer.ts | 41 +++ .../servers/y-provider/src/servers/index.ts | 2 + .../servers/y-provider/src/start-server.ts | 2 +- src/frontend/servers/y-provider/src/utils.ts | 4 +- 17 files changed, 560 insertions(+), 457 deletions(-) create mode 100644 src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts create mode 100644 src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts create mode 100644 src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/index.ts delete mode 100644 src/frontend/servers/y-provider/src/server.ts create mode 100644 src/frontend/servers/y-provider/src/servers/appServer.ts create mode 100644 src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts create mode 100644 src/frontend/servers/y-provider/src/servers/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7a7c2e..a41b6efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to 🔧(helm) add option to disable default tls setting by @dominikkaminski #519 +## Changed + +- 🏗️(yjs-server) organize yjs server #528 + ## [1.10.0] - 2024-12-17 diff --git a/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts b/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts new file mode 100644 index 00000000..096bf587 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/collaborationResetConnections.test.ts @@ -0,0 +1,76 @@ +import request from 'supertest'; + +const port = 5555; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { initServer } from '../src/servers/appServer'; + +const { app, server } = initServer(); + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] invalid origin', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/?room=test-room') + .set('Origin', 'http://invalid-origin.com') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('CORS policy violation: Invalid Origin'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/?room=test-room') + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const response = await request(app as any) + .post('/collaboration/api/reset-connections/') + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Room name not provided'); + }); + + test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => { + // eslint-disable-next-line jest/unbound-method + const { closeConnections } = hocusPocusServer; + const mockHandleConnection = jest.fn(); + (hocusPocusServer.closeConnections as jest.Mock) = mockHandleConnection; + + const response = await request(app as any) + .post('/collaboration/api/reset-connections?room=test-room') + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Connections reset'); + + expect(mockHandleConnection).toHaveBeenCalled(); + mockHandleConnection.mockClear(); + hocusPocusServer.closeConnections = closeConnections; + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts b/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts new file mode 100644 index 00000000..ed4765b2 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/convertMarkdown.test.ts @@ -0,0 +1,66 @@ +import request from 'supertest'; + +const port = 5556; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + Y_PROVIDER_API_KEY: 'yprovider-api-key', + }; +}); + +import { initServer } from '../src/servers/appServer'; + +console.error = jest.fn(); +const { app, server } = initServer(); + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /api/convert-markdown with incorrect API key should return 403', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /api/convert-markdown with a Bearer token', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'Bearer test-secret-api-key'); + + // Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice. + expect(response.status).toBe(403); + }); + + test('POST /api/convert-markdown with missing body param content', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'yprovider-api-key'); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid request: missing content'); + }); + + test('POST /api/convert-markdown with body param content being an empty string', async () => { + const response = await request(app as any) + .post('/api/convert-markdown') + .set('Origin', origin) + .set('Authorization', 'yprovider-api-key') + .send({ + content: '', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Invalid request: missing content'); + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts new file mode 100644 index 00000000..6499c31b --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/hocusPocusWS.test.ts @@ -0,0 +1,169 @@ +import { + HocuspocusProvider, + HocuspocusProviderWebsocket, +} from '@hocuspocus/provider'; +import WebSocket from 'ws'; + +const port = 5559; +const portWS = 6666; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { promiseDone } from '../src/helpers'; +import { initServer } from '../src/servers/appServer'; + +const { server } = initServer(); + +describe('Server Tests', () => { + beforeAll(async () => { + await hocusPocusServer.configure({ port: portWS }).listen(); + }); + + afterAll(() => { + server.close(); + void hocusPocusServer.destroy(); + }); + + test('WebSocket connection with correct API key can connect', () => { + const { promise, done } = promiseDone(); + + // eslint-disable-next-line jest/unbound-method + const { handleConnection } = hocusPocusServer; + const mockHandleConnection = jest.fn(); + (hocusPocusServer.handleConnection as jest.Mock) = mockHandleConnection; + + const clientWS = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + authorization: 'test-secret-api-key', + Origin: origin, + }, + }, + ); + + clientWS.on('open', () => { + expect(mockHandleConnection).toHaveBeenCalled(); + clientWS.close(); + mockHandleConnection.mockClear(); + hocusPocusServer.handleConnection = handleConnection; + done(); + }); + + return promise; + }); + + test('WebSocket connection with bad origin should be closed', () => { + const { promise, done } = promiseDone(); + + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Origin: 'http://bad-origin.com', + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection with incorrect API key should be closed', () => { + const { promise, done } = promiseDone(); + const ws = new WebSocket( + `ws://localhost:${port}/collaboration/ws/?room=test-room`, + { + headers: { + Authorization: 'wrong-api-key', + Origin: origin, + }, + }, + ); + + ws.onclose = () => { + expect(ws.readyState).toBe(ws.CLOSED); + done(); + }; + + return promise; + }); + + test('WebSocket connection not allowed if room not matching provider name', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=my-test`, + WebSocketPolyfill: WebSocket, + maxAttempts: 1, + quiet: true, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + preserveConnection: false, + onClose: (data) => { + wsHocus.stopConnectionAttempt(); + expect(data.event.reason).toBe('Forbidden'); + wsHocus.webSocket?.close(); + wsHocus.disconnect(); + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); + + test('WebSocket connection read-only', () => { + const { promise, done } = promiseDone(); + + const wsHocus = new HocuspocusProviderWebsocket({ + url: `ws://localhost:${portWS}/?room=hocuspocus-test`, + WebSocketPolyfill: WebSocket, + }); + + const provider = new HocuspocusProvider({ + websocketProvider: wsHocus, + name: 'hocuspocus-test', + broadcast: false, + quiet: true, + onConnect: () => { + void hocusPocusServer + .openDirectConnection('hocuspocus-test') + .then((connection) => { + connection.document?.getConnections().forEach((connection) => { + expect(connection.readOnly).toBe(true); + }); + + void connection.disconnect(); + }); + + provider.destroy(); + wsHocus.destroy(); + done(); + }, + }); + + return promise; + }); +}); diff --git a/src/frontend/servers/y-provider/__tests__/server.test.ts b/src/frontend/servers/y-provider/__tests__/server.test.ts index c1ff4393..2f048bff 100644 --- a/src/frontend/servers/y-provider/__tests__/server.test.ts +++ b/src/frontend/servers/y-provider/__tests__/server.test.ts @@ -1,38 +1,24 @@ -import { - HocuspocusProvider, - HocuspocusProviderWebsocket, -} from '@hocuspocus/provider'; import request from 'supertest'; -import WebSocket from 'ws'; -const port = 5555; -const portWS = 6666; +const port = 5557; const origin = 'http://localhost:3000'; jest.mock('../src/env', () => { return { PORT: port, COLLABORATION_SERVER_ORIGIN: origin, - COLLABORATION_SERVER_SECRET: 'test-secret-api-key', - Y_PROVIDER_API_KEY: 'yprovider-api-key', }; }); console.error = jest.fn(); -import { promiseDone } from '../src/helpers'; -import { hocuspocusServer, initServer } from '../src/server'; // Adjust the path to your server file +import { initServer } from '../src/servers/appServer'; const { app, server } = initServer(); describe('Server Tests', () => { - beforeAll(async () => { - await hocuspocusServer.configure({ port: portWS }).listen(); - }); - afterAll(() => { server.close(); - void hocuspocusServer.destroy(); }); test('Ping Pong', async () => { @@ -42,99 +28,6 @@ describe('Server Tests', () => { expect(response.body.message).toBe('pong'); }); - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] invalid origin', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/?room=test-room') - .set('Origin', 'http://invalid-origin.com') - .send({ document_id: 'test-document' }); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('CORS policy violation: Invalid Origin'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/?room=test-room') - .set('Origin', origin) - .set('Authorization', 'wrong-api-key'); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('Forbidden: Invalid API Key'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => { - const response = await request(app as any) - .post('/collaboration/api/reset-connections/') - .set('Origin', origin) - .set('Authorization', 'test-secret-api-key') - .send({ document_id: 'test-document' }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Room name not provided'); - }); - - test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => { - // eslint-disable-next-line jest/unbound-method - const { closeConnections } = hocuspocusServer; - const mockHandleConnection = jest.fn(); - (hocuspocusServer.closeConnections as jest.Mock) = mockHandleConnection; - - const response = await request(app as any) - .post('/collaboration/api/reset-connections?room=test-room') - .set('Origin', origin) - .set('Authorization', 'test-secret-api-key'); - - expect(response.status).toBe(200); - expect(response.body.message).toBe('Connections reset'); - - expect(mockHandleConnection).toHaveBeenCalled(); - mockHandleConnection.mockClear(); - hocuspocusServer.closeConnections = closeConnections; - }); - - test('POST /api/convert-markdown with incorrect API key should return 403', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'wrong-api-key'); - - expect(response.status).toBe(403); - expect(response.body.error).toBe('Forbidden: Invalid API Key'); - }); - - test('POST /api/convert-markdown with a Bearer token', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'Bearer test-secret-api-key'); - - // Warning: Changing the authorization header to Bearer token format will break backend compatibility with this microservice. - expect(response.status).toBe(403); - }); - - test('POST /api/convert-markdown with missing body param content', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'yprovider-api-key'); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Invalid request: missing content'); - }); - - test('POST /api/convert-markdown with body param content being an empty string', async () => { - const response = await request(app as any) - .post('/api/convert-markdown') - .set('Origin', origin) - .set('Authorization', 'yprovider-api-key') - .send({ - content: '', - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('Invalid request: missing content'); - }); - ['/collaboration/api/anything/', '/', '/anything'].forEach((path) => { test(`"${path}" endpoint should be forbidden`, async () => { const response = await request(app as any).post(path); @@ -143,136 +36,4 @@ describe('Server Tests', () => { expect(response.body.error).toBe('Forbidden'); }); }); - - test('WebSocket connection with correct API key can connect', () => { - const { promise, done } = promiseDone(); - - // eslint-disable-next-line jest/unbound-method - const { handleConnection } = hocuspocusServer; - const mockHandleConnection = jest.fn(); - (hocuspocusServer.handleConnection as jest.Mock) = mockHandleConnection; - - const clientWS = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - authorization: 'test-secret-api-key', - Origin: origin, - }, - }, - ); - - clientWS.on('open', () => { - expect(mockHandleConnection).toHaveBeenCalled(); - clientWS.close(); - mockHandleConnection.mockClear(); - hocuspocusServer.handleConnection = handleConnection; - done(); - }); - - return promise; - }); - - test('WebSocket connection with bad origin should be closed', () => { - const { promise, done } = promiseDone(); - - const ws = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - Origin: 'http://bad-origin.com', - }, - }, - ); - - ws.onclose = () => { - expect(ws.readyState).toBe(ws.CLOSED); - done(); - }; - - return promise; - }); - - test('WebSocket connection with incorrect API key should be closed', () => { - const { promise, done } = promiseDone(); - const ws = new WebSocket( - `ws://localhost:${port}/collaboration/ws/?room=test-room`, - { - headers: { - Authorization: 'wrong-api-key', - Origin: origin, - }, - }, - ); - - ws.onclose = () => { - expect(ws.readyState).toBe(ws.CLOSED); - done(); - }; - - return promise; - }); - - test('WebSocket connection not allowed if room not matching provider name', () => { - const { promise, done } = promiseDone(); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=my-test`, - WebSocketPolyfill: WebSocket, - maxAttempts: 1, - quiet: true, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: 'hocuspocus-test', - broadcast: false, - quiet: true, - preserveConnection: false, - onClose: (data) => { - wsHocus.stopConnectionAttempt(); - expect(data.event.reason).toBe('Forbidden'); - wsHocus.webSocket?.close(); - wsHocus.disconnect(); - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - return promise; - }); - - test('WebSocket connection read-only', () => { - const { promise, done } = promiseDone(); - - const wsHocus = new HocuspocusProviderWebsocket({ - url: `ws://localhost:${portWS}/?room=hocuspocus-test`, - WebSocketPolyfill: WebSocket, - }); - - const provider = new HocuspocusProvider({ - websocketProvider: wsHocus, - name: 'hocuspocus-test', - broadcast: false, - quiet: true, - onConnect: () => { - void hocuspocusServer - .openDirectConnection('hocuspocus-test') - .then((connection) => { - connection.document?.getConnections().forEach((connection) => { - expect(connection.readOnly).toBe(true); - }); - - void connection.disconnect(); - }); - - provider.destroy(); - wsHocus.destroy(); - done(); - }, - }); - - return promise; - }); }); diff --git a/src/frontend/servers/y-provider/nodemon.json b/src/frontend/servers/y-provider/nodemon.json index e9329fd9..0c61bfb3 100644 --- a/src/frontend/servers/y-provider/nodemon.json +++ b/src/frontend/servers/y-provider/nodemon.json @@ -1,5 +1,5 @@ { "watch": ["src"], "ext": "ts", - "exec": "yarn build" + "exec": "yarn build && yarn start" } diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index dff606b7..3655fc75 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -7,7 +7,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.build.json && tsc-alias", - "dev": "nodemon --config nodemon.json", + "dev": "cross-env COLLABORATION_LOGGING=true && nodemon --config nodemon.json", "start": "node ./dist/start-server.js", "lint": "eslint . --ext .ts", "test": "jest" @@ -34,6 +34,7 @@ "@types/supertest": "6.0.2", "@types/ws": "8.5.13", "eslint-config-impress": "*", + "cross-env": "*", "jest": "29.7.0", "nodemon": "3.1.9", "supertest": "7.0.0", diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts new file mode 100644 index 00000000..4af44096 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +type ResetConnectionsRequestQuery = { + room?: string; +}; + +export const collaborationResetConnectionsHandler = ( + req: Request, + res: Response, +) => { + const room = req.query.room; + const userId = req.headers['x-user-id']; + + logger( + 'Resetting connections in room:', + room, + 'for user:', + userId, + 'room:', + room, + ); + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + /** + * If no user ID is provided, close all connections in the room + */ + if (!userId) { + hocusPocusServer.closeConnections(room); + } else { + /** + * Close connections for the user in the room + */ + hocusPocusServer.documents.forEach((doc) => { + if (doc.name !== room) { + return; + } + + doc.getConnections().forEach((connection) => { + const connectionUserId = connection.request.headers['x-user-id']; + if (connectionUserId === userId) { + connection.close(); + } + }); + }); + } + + res.status(200).json({ message: 'Connections reset' }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts new file mode 100644 index 00000000..f76ef88c --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts @@ -0,0 +1,16 @@ +import { Request } from 'express'; +import * as ws from 'ws'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +export const collaborationWSHandler = (ws: ws.WebSocket, req: Request) => { + logger('Incoming Origin:', req.headers['origin']); + + try { + hocusPocusServer.handleConnection(ws, req); + } catch (error) { + console.error('Failed to handle WebSocket connection:', error); + ws.close(); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts new file mode 100644 index 00000000..9fc09134 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/convertMarkdownHandler.ts @@ -0,0 +1,55 @@ +import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import { Request, Response } from 'express'; +import * as Y from 'yjs'; + +import { logger, toBase64 } from '@/utils'; + +interface ConversionRequest { + content: string; +} + +interface ConversionResponse { + content: string; +} + +interface ErrorResponse { + error: string; +} + +export const convertMarkdownHandler = async ( + req: Request< + object, + ConversionResponse | ErrorResponse, + ConversionRequest, + object + >, + res: Response, +) => { + const content = req.body?.content; + + if (!content) { + res.status(400).json({ error: 'Invalid request: missing content' }); + return; + } + + try { + const editor = ServerBlockNoteEditor.create(); + + // Perform the conversion from markdown to Blocknote.js blocks + const blocks = await editor.tryParseMarkdownToBlocks(content); + + if (!blocks || blocks.length === 0) { + res.status(500).json({ error: 'No valid blocks were generated' }); + return; + } + + // Create a Yjs Document from blocks, and encode it as a base64 string + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const documentContent = toBase64(Y.encodeStateAsUpdate(yDocument)); + + res.status(200).json({ content: documentContent }); + } catch (e) { + logger('conversion failed:', e); + res.status(500).json({ error: 'An error occurred' }); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts new file mode 100644 index 00000000..75bd7f7b --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -0,0 +1,3 @@ +export * from './collaborationResetConnectionsHandler'; +export * from './collaborationWSHandler'; +export * from './convertMarkdownHandler'; diff --git a/src/frontend/servers/y-provider/src/server.ts b/src/frontend/servers/y-provider/src/server.ts deleted file mode 100644 index 1b229950..00000000 --- a/src/frontend/servers/y-provider/src/server.ts +++ /dev/null @@ -1,211 +0,0 @@ -// eslint-disable-next-line import/order -import './services/sentry'; -import { ServerBlockNoteEditor } from '@blocknote/server-util'; -import { Server } from '@hocuspocus/server'; -import * as Sentry from '@sentry/node'; -import express, { Request, Response } from 'express'; -import expressWebsockets from 'express-ws'; -import * as Y from 'yjs'; - -import { PORT } from './env'; -import { httpSecurity, wsSecurity } from './middlewares'; -import { routes } from './routes'; -import { logger, toBase64 } from './utils'; - -export const hocuspocusServer = Server.configure({ - name: 'docs-y-server', - timeout: 30000, - quiet: true, - onConnect({ requestHeaders, connection, documentName, requestParameters }) { - const roomParam = requestParameters.get('room'); - const canEdit = requestHeaders['x-can-edit'] === 'True'; - - if (!canEdit) { - connection.readOnly = true; - } - - logger( - 'Connection established:', - documentName, - 'userId:', - requestHeaders['x-user-id'], - 'canEdit:', - canEdit, - 'room:', - requestParameters.get('room'), - ); - - if (documentName !== roomParam) { - console.error( - 'Invalid room name - Probable hacking attempt:', - documentName, - requestParameters.get('room'), - requestHeaders['x-user-id'], - ); - - return Promise.reject(new Error('Unauthorized')); - } - - return Promise.resolve(); - }, -}); - -/** - * init the collaboration server. - * - * @param port - The port on which the server listens. - * @param serverSecret - The secret key for API authentication. - * @returns An object containing the Express app, Hocuspocus server, and HTTP server instance. - */ -export const initServer = () => { - const { app } = expressWebsockets(express()); - app.use(express.json()); - - /** - * Route to handle WebSocket connections - */ - app.ws(routes.COLLABORATION_WS, wsSecurity, (ws, req) => { - logger('Incoming Origin:', req.headers['origin']); - - try { - hocuspocusServer.handleConnection(ws, req); - } catch (error) { - console.error('Failed to handle WebSocket connection:', error); - ws.close(); - } - }); - - type ResetConnectionsRequestQuery = { - room?: string; - }; - - /** - * Route to reset connections in a room: - * - If no user ID is provided, close all connections in the room - * - If a user ID is provided, close connections for the user in the room - */ - app.post( - routes.COLLABORATION_RESET_CONNECTIONS, - httpSecurity, - ( - req: Request, - res: Response, - ) => { - const room = req.query.room; - const userId = req.headers['x-user-id']; - - logger( - 'Resetting connections in room:', - room, - 'for user:', - userId, - 'room:', - room, - ); - - if (!room) { - res.status(400).json({ error: 'Room name not provided' }); - return; - } - - /** - * If no user ID is provided, close all connections in the room - */ - if (!userId) { - hocuspocusServer.closeConnections(room); - } else { - /** - * Close connections for the user in the room - */ - hocuspocusServer.documents.forEach((doc) => { - if (doc.name !== room) { - return; - } - - doc.getConnections().forEach((connection) => { - const connectionUserId = connection.request.headers['x-user-id']; - if (connectionUserId === userId) { - connection.close(); - } - }); - }); - } - - res.status(200).json({ message: 'Connections reset' }); - }, - ); - - interface ConversionRequest { - content: string; - } - - interface ConversionResponse { - content: string; - } - - interface ErrorResponse { - error: string; - } - - /** - * Route to convert markdown - */ - app.post( - routes.CONVERT_MARKDOWN, - httpSecurity, - async ( - req: Request< - object, - ConversionResponse | ErrorResponse, - ConversionRequest, - object - >, - res: Response, - ) => { - const content = req.body?.content; - - if (!content) { - res.status(400).json({ error: 'Invalid request: missing content' }); - return; - } - - try { - const editor = ServerBlockNoteEditor.create(); - - // Perform the conversion from markdown to Blocknote.js blocks - const blocks = await editor.tryParseMarkdownToBlocks(content); - - if (!blocks || blocks.length === 0) { - res.status(500).json({ error: 'No valid blocks were generated' }); - return; - } - - // Create a Yjs Document from blocks, and encode it as a base64 string - const yDocument = editor.blocksToYDoc(blocks, 'document-store'); - const documentContent = toBase64(Y.encodeStateAsUpdate(yDocument)); - - res.status(200).json({ content: documentContent }); - } catch (e) { - logger('conversion failed:', e); - res.status(500).json({ error: 'An error occurred' }); - } - }, - ); - - Sentry.setupExpressErrorHandler(app); - - app.get('/ping', (req, res) => { - res.status(200).json({ message: 'pong' }); - }); - - app.use((req, res) => { - logger('Invalid route:', req.url); - res.status(403).json({ error: 'Forbidden' }); - }); - - const server = app.listen(PORT, () => - console.log('Listening on port :', PORT), - ); - - return { app, server }; -}; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts new file mode 100644 index 00000000..6aefc4a4 --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -0,0 +1,65 @@ +// eslint-disable-next-line import/order +import '../services/sentry'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import expressWebsockets from 'express-ws'; + +import { PORT } from '../env'; +import { + collaborationResetConnectionsHandler, + collaborationWSHandler, + convertMarkdownHandler, +} from '../handlers'; +import { httpSecurity, wsSecurity } from '../middlewares'; +import { routes } from '../routes'; +import { logger } from '../utils'; + +/** + * init the collaboration server. + * + * @param port - The port on which the server listens. + * @param serverSecret - The secret key for API authentication. + * @returns An object containing the Express app, Hocuspocus server, and HTTP server instance. + */ +export const initServer = () => { + const { app } = expressWebsockets(express()); + app.use(express.json()); + + /** + * Route to handle WebSocket connections + */ + app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler); + + /** + * Route to reset connections in a room: + * - If no user ID is provided, close all connections in the room + * - If a user ID is provided, close connections for the user in the room + */ + app.post( + routes.COLLABORATION_RESET_CONNECTIONS, + httpSecurity, + collaborationResetConnectionsHandler, + ); + + /** + * Route to convert markdown + */ + app.post(routes.CONVERT_MARKDOWN, httpSecurity, convertMarkdownHandler); + + Sentry.setupExpressErrorHandler(app); + + app.get('/ping', (req, res) => { + res.status(200).json({ message: 'pong' }); + }); + + app.use((req, res) => { + logger('Invalid route:', req.url); + res.status(403).json({ error: 'Forbidden' }); + }); + + const server = app.listen(PORT, () => + console.log('App listening on port :', PORT), + ); + + return { app, server }; +}; diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts new file mode 100644 index 00000000..dc159a8e --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -0,0 +1,41 @@ +import { Server } from '@hocuspocus/server'; + +import { logger } from '@/utils'; + +export const hocusPocusServer = Server.configure({ + name: 'docs-collaboration', + timeout: 30000, + quiet: true, + onConnect({ requestHeaders, connection, documentName, requestParameters }) { + const roomParam = requestParameters.get('room'); + const canEdit = requestHeaders['x-can-edit'] === 'True'; + + if (!canEdit) { + connection.readOnly = true; + } + + logger( + 'Connection established:', + documentName, + 'userId:', + requestHeaders['x-user-id'], + 'canEdit:', + canEdit, + 'room:', + requestParameters.get('room'), + ); + + if (documentName !== roomParam) { + console.error( + 'Invalid room name - Probable hacking attempt:', + documentName, + requestParameters.get('room'), + requestHeaders['x-user-id'], + ); + + return Promise.reject(new Error('Unauthorized')); + } + + return Promise.resolve(); + }, +}); diff --git a/src/frontend/servers/y-provider/src/servers/index.ts b/src/frontend/servers/y-provider/src/servers/index.ts new file mode 100644 index 00000000..0dfe0852 --- /dev/null +++ b/src/frontend/servers/y-provider/src/servers/index.ts @@ -0,0 +1,2 @@ +export * from './appServer'; +export * from './hocusPocusServer'; diff --git a/src/frontend/servers/y-provider/src/start-server.ts b/src/frontend/servers/y-provider/src/start-server.ts index e3d30519..c1dbfa21 100644 --- a/src/frontend/servers/y-provider/src/start-server.ts +++ b/src/frontend/servers/y-provider/src/start-server.ts @@ -1,3 +1,3 @@ -import { initServer } from './server'; +import { initServer } from './servers/appServer'; initServer(); diff --git a/src/frontend/servers/y-provider/src/utils.ts b/src/frontend/servers/y-provider/src/utils.ts index 4eb9e30c..847c5568 100644 --- a/src/frontend/servers/y-provider/src/utils.ts +++ b/src/frontend/servers/y-provider/src/utils.ts @@ -1,9 +1,9 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { COLLABORATION_LOGGING } from './env'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function logger(...args: any[]) { if (COLLABORATION_LOGGING === 'true') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument console.log(...args); } }