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); } }