From da4d323144da000edba4a1d37d01974500fc4980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Thu, 5 Mar 2026 12:11:26 +0100 Subject: [PATCH] wip provider server command endpoints --- .../getDocumentConnectionInfoHandler.test.ts | 37 ++++++++++++ .../collaborationResetConnectionsHandler.ts | 57 +++++++++---------- .../src/handlers/collaborationWSHandler.ts | 2 +- .../getDocumentConnectionInfoHandler.ts | 54 +++++++++++------- .../y-provider/src/servers/relayServer.ts | 34 +++++++++++ 5 files changed, 134 insertions(+), 50 deletions(-) diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts index 7efe46c3..670cba15 100644 --- a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts +++ b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts @@ -15,6 +15,7 @@ console.error = vi.fn(); import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env'; import { hocuspocusServer, initApp } from '@/servers'; +import { handleRelayServerConnection, getRelayRoom } from '@/servers/relayServer'; const apiEndpoint = '/collaboration/api/get-connections/'; @@ -205,6 +206,42 @@ describe('Server Tests', () => { }); }); + test('GET /collaboration/api/get-connections?room=[ROOM_ID] returns connection info for encrypted relay room', async () => { + const roomId = 'relay-test-room'; + + // Create a mock WebSocket that the relay server can register + const mockWs = { + binaryType: 'nodebuffer', + readyState: 1, // OPEN + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + close: vi.fn(), + ping: vi.fn(), + removeAllListeners: vi.fn(), + }; + + // Register the mock connection in the relay server + await handleRelayServerConnection(mockWs as any, roomId); + + const room = getRelayRoom(roomId); + expect(room).toBeDefined(); + expect(room!.size).toBe(1); + + const app = initApp(); + + const response = await request(app) + .get(`${apiEndpoint}?room=${roomId}&sessionKey=any-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 1, + 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.hocuspocus.createDocument( 'test-room', diff --git a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts index dc5dffe0..edd8a30d 100644 --- a/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/collaborationResetConnectionsHandler.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { hocuspocusServer } from '@/servers'; +import { closeRelayConnections } from '@/servers/relayServer'; import { logger } from '@/utils'; type ResetConnectionsRequestQuery = { @@ -18,46 +19,42 @@ export const collaborationResetConnectionsHandler = ( if (!room) { res.status(400).json({ error: 'Room name not provided' }); + return; } - res.status(500).json({ error: 'not implemented yet' }); - - hocuspocusServer; - /** * If no user ID is provided, close all connections in the room + * + * Below we avoid database call to check if the room if for encrypted server or not (could be switching to the other), so looking in both lists */ if (!userId) { - // hocuspocusServer.hocuspocus.closeConnections(room); - // const doc = getYDoc(room); - // if (doc) { - // doc.conns.forEach((_, conn) => closeConn(doc, conn)); - // } + hocuspocusServer.hocuspocus.closeConnections(room); + closeRelayConnections(room); } else { + const targetUserId = Array.isArray(userId) ? userId[0] : userId; + /** - * Close connections for the user in the room + * Close connections for the user in the room (hocuspocus) */ - // hocuspocusServer.hocuspocus.documents.forEach((doc) => { - // if (doc.name !== room) { - // return; - // } - // doc.getConnections().forEach((connection) => { - // // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - // if (connection.context.userId === userId) { - // connection.close(); - // } - // }); - // }); - // const doc = getYDoc(room); - // if (doc) { - // doc.conns.forEach((clientIds, conn) => { - // // TODO: with this current implementation there is no logic about user ID but only also "clientID" - // // ... it should be adapted first as for hocuspocus before having this metadata - // // closeConn(doc, conn) - // }); - // } + hocuspocusServer.hocuspocus.documents.forEach((doc) => { + if (doc.name !== room) { + return; + } + + doc.getConnections().forEach((connection) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (connection.context.userId === targetUserId) { + connection.close(); + } + }); + }); + + /** + * Close connections for the user in the room (relay) + */ + closeRelayConnections(room, targetUserId); } - // res.status(200).json({ message: 'Connections reset' }); + 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 index db60d61b..5c0487aa 100644 --- a/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/collaborationWSHandler.ts @@ -87,7 +87,7 @@ export const collaborationWSHandler = async ( // mimick the Hocuspocus protocol to properly hide the frontend loader ws.send('system:authenticated'); - await handleRelayServerConnection(ws, roomId); + await handleRelayServerConnection(ws, roomId, userId); } else { hocuspocusServer.hocuspocus.handleConnection(ws, req, { roomId: roomId, diff --git a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts index 26793285..afe8568f 100644 --- a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { hocuspocusServer } from '@/servers'; +import { getRelayRoom } from '@/servers/relayServer'; import { logger } from '@/utils'; type getDocumentConnectionInfoRequestQuery = { @@ -17,37 +18,52 @@ export const getDocumentConnectionInfoHandler = ( 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); - res.status(500).json({ error: 'not implemented yet' }); + // we avoid database call since a document could be being encrypted or not, so looking in both lists + // so check hocuspocus first (non-encrypted documents) + const hocuspocusRoom = hocuspocusServer.hocuspocus.documents.get(room); - hocuspocusServer; - sessionKey; + if (hocuspocusRoom) { + const connections = hocuspocusRoom + .getConnections() + .filter((connection) => connection.readOnly === false); - // const roomInfo = hocuspocusServer.hocuspocus.documents.get(room); + 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, + ), + }); - // 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); + return; + } - // 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, - // ), - // }); + const relayRoom = getRelayRoom(room); + + if (relayRoom) { + // the relay server is a blind passthrough, there is no readOnly distinction + // or session key tracking, so we report all connections and cannot confirm session existence + res.status(200).json({ + count: relayRoom.size, + exists: false, + }); + + return; + } + + logger('Room not found:', room); + + res.status(404).json({ error: 'Room not found' }); }; diff --git a/src/frontend/servers/y-provider/src/servers/relayServer.ts b/src/frontend/servers/y-provider/src/servers/relayServer.ts index 9d63af3a..25e02d91 100644 --- a/src/frontend/servers/y-provider/src/servers/relayServer.ts +++ b/src/frontend/servers/y-provider/src/servers/relayServer.ts @@ -3,6 +3,7 @@ import * as ws from 'ws'; const rooms = new Map>(); const roomsMutex = new Mutex(); +const connectionMeta = new Map(); function sendMessage(ws: ws.WebSocket, data: ws.RawData) { if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) { @@ -16,9 +17,39 @@ function sendMessage(ws: ws.WebSocket, data: ws.RawData) { } } +export function getRelayRoom(roomId: string): Set | undefined { + return rooms.get(roomId); +} + +export function closeRelayConnections( + roomId: string, + userId?: string, +): void { + const room = rooms.get(roomId); + + if (!room) { + return; + } + + if (!userId) { + for (const peer of Array.from(room)) { + peer.close(); + } + } else { + for (const peer of Array.from(room)) { + const meta = connectionMeta.get(peer); + + if (meta?.userId === userId) { + peer.close(); + } + } + } +} + export async function handleRelayServerConnection( ws: ws.WebSocket, roomId: string, + userId?: string | null, ) { ws.binaryType = 'arraybuffer'; // Same configuration in the client provider @@ -38,6 +69,8 @@ export async function handleRelayServerConnection( roomsMutexRelease(); } + connectionMeta.set(ws, { userId: userId ?? null }); + ws.on('error', () => { ws.close(); }); @@ -80,6 +113,7 @@ export async function handleRelayServerConnection( ws.on('close', async () => { clearInterval(pingInterval); ws.removeAllListeners(); + connectionMeta.delete(ws); const roomsMutexRelease = await roomsMutex.acquire();