(y-provider) endpoint POST /collaboration/api/reset-connections

We want to be able to reset the connections of a document.
To do this, we need to be able to send a
request to the collaboration server.
To do so, we added the endpoint
POST "/collaboration/api/reset-connections"
to the collaboration server thanks to "express".
This commit is contained in:
Anthony LC
2024-11-28 17:00:06 +01:00
committed by Anthony LC
parent 2cba228a67
commit ba1cfc3c27
11 changed files with 661 additions and 23 deletions

View File

@@ -6,7 +6,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"build": "tsc -p .",
"build": "tsc -p ./src",
"dev": "nodemon --config nodemon.json",
"start": "node ./dist/server.js",
"lint": "eslint . --ext .ts"
@@ -16,9 +16,13 @@
},
"dependencies": {
"@hocuspocus/server": "2.14.0",
"express": "4.21.1",
"express-ws": "5.0.2",
"y-protocols": "1.0.6"
},
"devDependencies": {
"@types/express": "5.0.0",
"@types/express-ws": "3.0.5",
"@types/node": "*",
"eslint-config-impress": "*",
"nodemon": "3.1.7",

View File

@@ -0,0 +1,7 @@
export const COLLABORATION_LOGGING =
process.env.COLLABORATION_LOGGING || 'false';
export const COLLABORATION_SERVER_ORIGIN =
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000';
export const COLLABORATION_SERVER_SECRET =
process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key';
export const PORT = Number(process.env.PORT || 4444);

View File

@@ -0,0 +1,59 @@
import { NextFunction, Request, Response } from 'express';
import * as ws from 'ws';
import {
COLLABORATION_SERVER_ORIGIN,
COLLABORATION_SERVER_SECRET,
} from '@/env';
import { logger } from './utils';
export const httpSecurity = (
req: Request,
res: Response,
next: NextFunction,
): void => {
// Origin check
const origin = req.headers['origin'];
if (origin && COLLABORATION_SERVER_ORIGIN !== origin) {
logger('CORS policy violation: Invalid Origin', origin);
res
.status(403)
.json({ error: 'CORS policy violation: Invalid Origin', origin });
return;
}
// Secret API Key check
const apiKey = req.headers['authorization'];
if (apiKey !== COLLABORATION_SERVER_SECRET) {
res.status(403).json({ error: 'Forbidden: Invalid API Key' });
return;
}
next();
};
export const wsSecurity = (
ws: ws.WebSocket,
req: Request,
next: NextFunction,
): void => {
// Origin check
const origin = req.headers['origin'];
if (COLLABORATION_SERVER_ORIGIN !== origin) {
console.error('CORS policy violation: Invalid Origin', origin);
ws.close();
return;
}
// Secret API Key check
const apiKey = req.headers['authorization'];
if (apiKey !== COLLABORATION_SERVER_SECRET) {
console.error('Forbidden: Invalid API Key');
ws.close();
return;
}
next();
};

View File

@@ -0,0 +1,4 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
};

View File

@@ -1,18 +1,147 @@
import { Server } from '@hocuspocus/server';
import express, { Request, Response } from 'express';
import expressWebsockets from 'express-ws';
const port = Number(process.env.PORT || 4444);
import { PORT } from './env';
import { httpSecurity, wsSecurity } from './middlewares';
import { routes } from './routes';
import { logger } from './utils';
const server = Server.configure({
name: 'docs-y-provider',
port: port,
export const hocuspocusServer = Server.configure({
name: 'docs-y-server',
timeout: 30000,
debounce: 2000,
maxDebounce: 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();
},
});
server.listen().catch((error) => {
console.error('Failed to start the server:', error);
});
/**
* 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());
console.log('Websocket server running on port :', port);
/**
* 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<object, object, object, ResetConnectionsRequestQuery>,
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' });
},
);
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 };
};

View File

@@ -0,0 +1,3 @@
import { initServer } from './server';
initServer();

View File

@@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { COLLABORATION_LOGGING } from './env';
export function logger(...args: any[]) {
if (COLLABORATION_LOGGING === 'true') {
console.log(...args);
}
}