| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131 |
- import type { IPage, IUserHasId } from '@growi/core';
- import { YJS_WEBSOCKET_BASE_PATH } from '@growi/core/dist/consts';
- import expressSession from 'express-session';
- import type { IncomingMessage, ServerResponse } from 'http';
- import mongoose from 'mongoose';
- import passport from 'passport';
- import type { Duplex } from 'stream';
- import type { SessionConfig } from '~/interfaces/session-config';
- import loggerFactory from '~/utils/logger';
- import type { PageModel } from '../../models/page';
- const logger = loggerFactory('growi:service:yjs:upgrade-handler');
- type AuthenticatedRequest = IncomingMessage & {
- user?: IUserHasId;
- };
- /**
- * Connect-style middleware that operates on raw Node.js HTTP types.
- * Express middleware (express-session, passport) is compatible because
- * express.Request extends IncomingMessage and express.Response extends ServerResponse.
- */
- type ConnectMiddleware = (
- req: IncomingMessage,
- res: ServerResponse,
- next: (err?: unknown) => void,
- ) => void;
- /**
- * Run a Connect-style middleware against a raw IncomingMessage.
- * Safe for express-session, passport.initialize(), and passport.session() which
- * only read/write `req` properties and call `next()` — they never write to `res`.
- */
- const runMiddleware = (
- middleware: ConnectMiddleware,
- req: IncomingMessage,
- ): Promise<void> =>
- new Promise((resolve, reject) => {
- const stubRes = {} as ServerResponse;
- middleware(req, stubRes, (err?: unknown) => {
- if (err) return reject(err);
- resolve();
- });
- });
- /**
- * Extracts pageId from upgrade request URL.
- * Expected format: /yjs/{pageId}
- */
- const pageIdPattern = new RegExp(`^${YJS_WEBSOCKET_BASE_PATH}/([a-f0-9]{24})`);
- const extractPageId = (url: string | undefined): string | null => {
- if (url == null) return null;
- const match = url.match(pageIdPattern);
- return match?.[1] ?? null;
- };
- /**
- * Writes an HTTP error response to the socket.
- * Does NOT close the socket — the caller (yjs.ts) manages socket lifecycle
- * so that guardSocket can safely intercept end/destroy during async auth.
- */
- const writeErrorResponse = (
- socket: Duplex,
- statusCode: number,
- message: string,
- ): void => {
- socket.write(`HTTP/1.1 ${statusCode} ${message}\r\n\r\n`);
- };
- export type UpgradeResult =
- | { authorized: true; request: AuthenticatedRequest; pageId: string }
- | { authorized: false; statusCode: number };
- /**
- * Creates an upgrade handler that authenticates WebSocket connections
- * using the existing express-session + passport mechanism.
- */
- export const createUpgradeHandler = (sessionConfig: SessionConfig) => {
- const sessionMiddleware = expressSession(sessionConfig as any);
- const passportInit = passport.initialize();
- const passportSession = passport.session();
- return async (
- request: IncomingMessage,
- socket: Duplex,
- _head: Buffer,
- ): Promise<UpgradeResult> => {
- const pageId = extractPageId(request.url);
- if (pageId == null) {
- logger.warn('Invalid URL path for Yjs upgrade', { url: request.url });
- writeErrorResponse(socket, 400, 'Bad Request');
- return { authorized: false, statusCode: 400 };
- }
- try {
- // Run session + passport middleware chain
- await runMiddleware(sessionMiddleware as ConnectMiddleware, request);
- await runMiddleware(passportInit as ConnectMiddleware, request);
- await runMiddleware(passportSession as ConnectMiddleware, request);
- } catch (err) {
- logger.warn('Session/passport middleware failed on upgrade', { err });
- writeErrorResponse(socket, 401, 'Unauthorized');
- return { authorized: false, statusCode: 401 };
- }
- const user = (request as AuthenticatedRequest).user ?? null;
- // Check page access
- const Page = mongoose.model<IPage, PageModel>('Page');
- const isAccessible = await Page.isAccessiblePageByViewer(pageId, user);
- if (!isAccessible) {
- const statusCode = user == null ? 401 : 403;
- const message = user == null ? 'Unauthorized' : 'Forbidden';
- logger.warn(`Yjs upgrade rejected: ${message}`, {
- pageId,
- userId: user?._id,
- });
- writeErrorResponse(socket, statusCode, message);
- return { authorized: false, statusCode };
- }
- return {
- authorized: true,
- request: request as AuthenticatedRequest,
- pageId,
- };
- };
- };
|