From b96de36382ded987b0b89bc5bdfdac8a5f07df17 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 25 Jun 2025 17:25:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(y-provider)=20add=20endpoint=20return?= =?UTF-8?q?ing=20document=20connection=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need a new endpoint in the y-provider server allowing the backend to retrieve the number of active connections on a document and if a session key exists. --- .../getDocumentConnectionInfoHandler.test.ts | 275 ++++++++++++++++++ .../getDocumentConnectionInfoHandler.ts | 48 +++ .../servers/y-provider/src/handlers/index.ts | 1 + src/frontend/servers/y-provider/src/routes.ts | 1 + .../y-provider/src/servers/appServer.ts | 7 + .../src/servers/hocuspocusServer.ts | 8 + 6 files changed, 340 insertions(+) create mode 100644 src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts new file mode 100644 index 00000000..c17d9be1 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts @@ -0,0 +1,275 @@ +import request from 'supertest'; +import { v4 as uuid } from 'uuid'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('../src/env', async (importOriginal) => { + return { + ...(await importOriginal()), + PORT: 5556, + COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000', + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = vi.fn(); + +import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env'; +import { hocuspocusServer, initApp } from '@/servers'; + +const apiEndpoint = '/collaboration/api/get-connections/'; + +describe('Server Tests', () => { + test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Unauthorized: Invalid API Key'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}`) + .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/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Session key not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => { + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Room not found'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: true, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => { + const document = await hocuspocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + readOnly: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 4, + context: { sessionKey: 'session-read-only' }, + document: document, + pongReceived: false, + readOnly: true, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); +}); diff --git a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts new file mode 100644 index 00000000..dcf33be5 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts @@ -0,0 +1,48 @@ +import { Request, Response } from 'express'; + +import { hocuspocusServer } from '@/servers'; +import { logger } from '@/utils'; + +type getDocumentConnectionInfoRequestQuery = { + room?: string; + sessionKey?: string; +}; + +export const getDocumentConnectionInfoHandler = ( + req: Request, + res: Response, +) => { + const room = req.query.room; + const sessionKey = req.query.sessionKey; + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + if (!req.query.sessionKey) { + res.status(400).json({ error: 'Session key not provided' }); + return; + } + + logger('Getting document connection info for room:', room); + + const roomInfo = hocuspocusServer.documents.get(room); + + if (!roomInfo) { + logger('Room not found:', room); + res.status(404).json({ error: 'Room not found' }); + return; + } + const connections = roomInfo + .getConnections() + .filter((connection) => connection.readOnly === false); + + res.status(200).json({ + count: connections.length, + exists: connections.some( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (connection) => connection.context.sessionKey === sessionKey, + ), + }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 296fefa2..26b0ebed 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertHandler'; +export * from './getDocumentConnectionInfoHandler'; diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 7d219e2e..5bb73365 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -2,4 +2,5 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', CONVERT: '/api/convert/', + COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 2dbfba99..0c355fee 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertHandler, + getDocumentConnectionInfoHandler, } from '@/handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '@/middlewares'; import { routes } from '@/routes'; @@ -41,6 +42,12 @@ export const initApp = () => { collaborationResetConnectionsHandler, ); + app.get( + routes.COLLABORATION_GET_CONNECTIONS, + httpSecurity, + getDocumentConnectionInfoHandler, + ); + /** * Route to convert Markdown or BlockNote blocks */ diff --git a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts index 484d055a..376e8ae0 100644 --- a/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocuspocusServer.ts @@ -60,6 +60,14 @@ export const hocuspocusServer = Server.configure({ connection.readOnly = !canEdit; + const session = requestHeaders['cookie'] + ?.split('; ') + .find((cookie) => cookie.startsWith('docs_sessionid=')); + if (session) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.sessionKey = session.split('=')[1]; + } + /* * Unauthenticated users can be allowed to connect * so we flag only authenticated users