UNPKG

open-collaboration-server

Version:

Open Collaboration Server implementation, part of the Open Collaboration Tools project

452 lines 18.6 kB
// ****************************************************************************** // Copyright 2024 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { inject, injectable, multiInject } from 'inversify'; import * as http from 'node:http'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Server } from 'socket.io'; import express from 'express'; import { SocketIoChannel } from './channel.js'; import { PeerFactory } from './peer.js'; import { RoomManager, isRoomClaim } from './room-manager.js'; import { UserManager } from './user-manager.js'; import { CredentialsManager } from './credentials-manager.js'; import { JoinRoomPollResponse } from 'open-collaboration-protocol'; import { AuthEndpoint } from './auth-endpoints/auth-endpoint.js'; import { Logger } from './utils/logging.js'; import { VERSION } from 'open-collaboration-protocol'; import { Configuration } from './utils/configuration.js'; import { PeerManager } from './peer-manager.js'; import cookieParser from 'cookie-parser'; // resolves __filename export const getLocalFilename = (referenceUrl) => { return fileURLToPath(referenceUrl); }; // resolves __dirname export const getLocalDirectory = (referenceUrl) => { return path.dirname(getLocalFilename(referenceUrl)); }; let CollaborationServer = class CollaborationServer { roomManager; userManager; credentials; peerFactory; peerManager; logger; configuration; authEndpoints; startServer(opts) { this.logger.debug('Starting Open Collaboration Server ...'); const app = this.setupApiRoute(); const httpServer = http.createServer(app); // const wsServer = new ws.Server({ // path: '/websocket', // server: httpServer // }); // wsServer.on('connection', async (socket, req) => { // try { // const query = req.url?.split('?')[1] ?? ''; // const headers = query.split('&').reduce((acc, cur) => { // const [key, value] = cur.split('='); // if (typeof key === 'string' && typeof value === 'string') { // acc[decodeURIComponent(key.trim())] = decodeURIComponent(value.trim()); // } // return acc; // }, {} as Record<string, string>); // await this.connectChannel(headers, new WebSocketChannel(socket)); // } catch (error) { // socket.close(undefined, 'Failed to join room'); // this.logger.error('Web socket connection failed', error); // } // }); const io = new Server(httpServer, { cors: { origin: '*', methods: ['GET', 'POST'] } }); io.on('connection', async (socket) => { const headers = socket.request.headers; try { await this.connectChannel(headers, new SocketIoChannel(socket)); } catch (error) { socket.disconnect(true); this.logger.error('Socket IO connection failed', error); } }); httpServer.listen(Number(opts.port), String(opts.hostname)); for (const authEndpoint of this.authEndpoints) { if (authEndpoint.shouldActivate()) { authEndpoint.onStart(app, String(opts.hostname), Number(opts.port)); authEndpoint.onDidAuthenticate(async (event) => { try { await this.credentials.confirmUser(event.token, event.userInfo); } catch (err) { this.logger.error('Failed to confirm user', err); throw new Error('Failed to confirm user'); } }); } } this.logger.info(`Open Collaboration Server listening on ${opts.hostname}:${opts.port}`); } async connectChannel(headers, channel) { const jwt = headers['x-oct-jwt']; if (!jwt) { throw this.logger.createErrorAndLog('No JWT auth token set'); } const publicKey = headers['x-oct-public-key']; if (!publicKey) { throw this.logger.createErrorAndLog('No encryption key set'); } let compression = headers['x-oct-compression']?.split(','); if (compression === undefined || compression.length === 0) { compression = ['none']; } const client = headers['x-oct-client'] ?? 'unknown'; const roomClaim = await this.credentials.verifyJwt(jwt, isRoomClaim); const existingPeer = this.peerManager.getPeer(jwt); if (existingPeer) { // If a peer with the same JWT already exists, we just update the channel // This indicates that a client has reconnected existingPeer.channel.transport = channel; } else { const peer = this.peerFactory({ jwt, user: roomClaim.user, host: roomClaim.host ?? false, channel, client, publicKey, supportedCompression: compression }); this.peerManager.register(peer); await this.roomManager.join(peer, roomClaim.room); } } async getUserFromAuth(req) { const auth = (req.headers['x-oct-jwt'] ?? req.cookies?.['oct-jwt']); try { const user = await this.credentials.getUser(auth); return user; } catch { return undefined; } } setupApiRoute() { const app = express(); app.use(express.json()); app.use(cookieParser()); const allowedOrigins = this.configuration.getValue('oct-cors-allowed-origins')?.split(',') ?? '*'; app.use((req, res, next) => { if (req.headers?.origin && allowedOrigins !== '*' && !allowedOrigins.includes(req.headers.origin)) { res.status(403); res.send('Origin not allowed'); return; } res.header('Access-Control-Allow-Origin', req.headers.origin ?? '*'); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Headers', '*'); next(); }); app.use(async (req, res, next) => { if (req.method === 'POST' && req.url.startsWith('/api/session/')) { const user = await this.getUserFromAuth(req); if (!user) { res.status(403); res.send('Forbidden resource'); } else { next(); } } else { next(); } }); app.use(express.static(path.resolve(getLocalDirectory(import.meta.url), '../src/static'))); const loginPageUrlConfig = this.configuration.getValue('oct-login-page-url') ?? ''; app.post('/api/login/initial', async (req, res) => { try { const token = await this.credentials.startAuth(); let loginPage; try { const loginPageURL = new URL(loginPageUrlConfig); loginPageURL.searchParams.set('token', token); loginPage = loginPageURL.toString(); } catch (_error) { loginPage = `/login.html?token=${encodeURIComponent(token)}`; } // Ensure that we don't send inactive auth providers to the client const activeAuthProviders = this.authEndpoints.filter(e => e.shouldActivate()); const result = { pollToken: token, auth: { loginPageUrl: loginPage, providers: activeAuthProviders.map(endpoint => endpoint.getProtocolProvider()), defaultSuccessUrl: this.configuration.getValue('oct-login-success-url') ?? '' } }; res.status(200); res.send(result); } catch (error) { this.logger.error('Error occurred during login', error); res.status(400); res.send('Failed to login'); } }); app.post('/api/login/validate', async (req, res) => { const user = await this.getUserFromAuth(req); const result = { valid: !!user }; res.status(200); res.send(result); }); // for preflight requests app.options('/api/login/poll/:token', (req, res) => { res.header('Access-Control-Allow-Headers', 'content-type'); res.header('Access-Control-Allow-Methods', 'POST'); res.send(); }); app.post('/api/login/poll/:token', async (req, res) => { try { const authTimeoutResponse = { code: 'AuthTimeout', params: [], message: 'Authentication timed out' }; const token = req.params.token; const delayedAuth = await this.credentials.getAuth(token); if (!delayedAuth) { res.status(400); res.send(authTimeoutResponse); return; } const sendToken = (token) => { const result = { loginToken: token }; res.status(200); res.send(result); }; const addCookieHeader = (token) => { res.cookie('oct-jwt', token, { maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year httpOnly: true, secure: true, sameSite: 'none' }); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Origin', req.headers.origin ?? '*'); }; if (delayedAuth.jwt) { req.query?.useCookie && addCookieHeader(delayedAuth.jwt); sendToken(delayedAuth.jwt); delayedAuth.dispose(); } else { const end = async (value) => { clearTimeout(timeout); update.dispose(); failure.dispose(); if (value === undefined) { // No content res.status(204); res.send({}); } else if (typeof value === 'string') { req.query?.useCookie && addCookieHeader(value); sendToken(value); } else { res.status(400); res.send(authTimeoutResponse); delayedAuth.dispose(); } }; const timeout = setTimeout(() => { end(undefined); }, 30_000); const update = delayedAuth.onUpdate(jwt => end(jwt)); const failure = delayedAuth.onFail(err => end(err)); } } catch (error) { this.logger.error('Error occurred during login token confirmation', error); res.status(500); res.send({ code: 'AuthInternalError', params: [], message: 'Internal authentication server error' }); } }); app.get('/api/meta', async (_, res) => { const data = { owner: this.configuration.getValue('oct-server-owner') ?? 'Unknown', version: VERSION, transports: [ // 'websocket', 'socket.io' ], }; res.send(data); }); // only required for when using cookie based authentication app.get('/api/logout', async (req, res) => { const user = await this.getUserFromAuth(req); if (user) { res.clearCookie('oct-jwt', { sameSite: 'none', secure: true, httpOnly: true }); res.status(200); res.send('Logged out'); } else { res.status(400); res.send('no auth token cookie set or user for token not found'); } }); app.post('/api/session/join/:room', async (req, res) => { try { const roomId = req.params.room; const user = await this.getUserFromAuth(req); const room = this.roomManager.getRoomById(roomId); if (!room) { this.logger.warn(`User tried joining non-existing room with id '${roomId}'`); res.status(404); const roomNotFound = { code: 'RoomNotFound', params: [], message: 'Room not found' }; res.send(roomNotFound); return; } const result = await this.roomManager.requestJoin(room, user); res.status(200); const response = { pollToken: result, roomId: roomId }; res.send(response); } catch (error) { this.logger.error('Error occurred while joining a room', error); res.status(500); res.send('An internal server error occurred'); } }); app.post('/api/session/poll/:token', async (req, res) => { try { const joinToken = req.params.token; const poll = this.roomManager.pollJoin(joinToken); if (!poll) { res.status(404); const joinNotFound = { code: 'JoinRequestNotFound', params: [], message: 'Join request not found' }; res.send(joinNotFound); return; } if (poll.result) { res.status(200); res.send(poll.result); if (JoinRoomPollResponse.is(poll.result)) { poll.result = undefined; } // Don't dispose the result here, as it might be used for polling // It will be disposed after 5 minutes anyway return; } const end = async (value) => { clearTimeout(timeout); update.dispose(); if (value === undefined) { // No content res.status(204); res.send({}); } else { res.status(200); res.send(value); if ('failure' in value) { poll.result = undefined; } } }; const timeout = setTimeout(() => { end(undefined); }, 30_000); const update = poll.onUpdate(response => end(response)); } catch (error) { this.logger.error('Error occurred while joining a room', error); res.status(500); res.send('An internal server error occurred'); } }); app.post('/api/session/create', async (req, res) => { try { const user = await this.getUserFromAuth(req); const room = await this.roomManager.prepareRoom(user); const response = { roomId: room.id, roomToken: room.jwt }; res.send(response); } catch (error) { this.logger.error('Error occurred when creating a room', error); res.status(400); res.send('Failed to create room'); } }); return app; } }; __decorate([ inject(RoomManager) ], CollaborationServer.prototype, "roomManager", void 0); __decorate([ inject(UserManager) ], CollaborationServer.prototype, "userManager", void 0); __decorate([ inject(CredentialsManager) ], CollaborationServer.prototype, "credentials", void 0); __decorate([ inject(PeerFactory) ], CollaborationServer.prototype, "peerFactory", void 0); __decorate([ inject(PeerManager) ], CollaborationServer.prototype, "peerManager", void 0); __decorate([ inject(Logger) ], CollaborationServer.prototype, "logger", void 0); __decorate([ inject(Configuration) ], CollaborationServer.prototype, "configuration", void 0); __decorate([ multiInject(AuthEndpoint) ], CollaborationServer.prototype, "authEndpoints", void 0); CollaborationServer = __decorate([ injectable() ], CollaborationServer); export { CollaborationServer }; //# sourceMappingURL=collaboration-server.js.map