changemaker.lite/api/src/modules/docs/docs-collab.service.ts
bunker-admin 647efffdc4 Security hardening: JWT algorithm pinning, key separation, injection fixes
- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign()
  calls (3 sites) — prevents algorithm confusion attacks
- Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a
  dedicated key separate from access/refresh secrets
- Remove req.query.secret fallback from Listmonk webhook route — secrets
  must not appear in nginx access logs
- Replace child_process.spawn in email template seed endpoint with direct
  function import; add require.main guard to seed script
- Add sanitizeCsvField() to location CSV export to prevent formula
  injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix)
- Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads
- Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput
  in meeting-planner service; add sso field to UpdateResourcePayload

Bunker Admin
2026-03-22 12:35:04 -06:00

352 lines
11 KiB
TypeScript

import { IncomingMessage } from 'http';
import crypto from 'crypto';
import type WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import { UserRole } from '@prisma/client';
import * as Y from 'yjs';
import { Hocuspocus } from '@hocuspocus/server';
import type { Extension } from '@hocuspocus/server';
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
// --- Metrics ---
import { Gauge } from 'prom-client';
const collabConnections = new Gauge({
name: 'cm_docs_collab_connections',
help: 'Number of active docs collaboration WebSocket connections',
});
const collabDocuments = new Gauge({
name: 'cm_docs_collab_documents',
help: 'Number of active collaboratively-edited documents',
});
// --- Connection tracking ---
const connectionsPerUser = new Map<string, number>();
const MAX_CONNECTIONS_PER_USER = 5;
const MAX_CONCURRENT_DOCUMENTS = 50;
const MAX_DOC_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
// --- JWT token payload ---
interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
];
function getUserColor(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
}
// --- Redis cache key helper ---
function fileCacheKey(relativePath: string): string {
const hash = crypto.createHash('sha256').update(relativePath).digest('hex').substring(0, 16);
return `DOCS_CACHE:file:${hash}`;
}
// --- Hocuspocus extension with hooks ---
const docsExtension: Extension = {
priority: 1,
async onAuthenticate(data) {
const { token, documentName } = data;
if (!token) {
throw new Error('Authentication required');
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
// Allow read-only for any authenticated non-TEMP user
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Validate document path (prevent path traversal)
try {
docsFilesService.safeResolve(documentName);
} catch {
throw new Error('Invalid document path');
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
// Only block if this is a NEW document (not joining existing)
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
// Set context for use in other hooks
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
},
async onLoadDocument(data) {
const { document, documentName } = data;
// Try loading persisted Y.Doc state from DB
try {
const stored = await prisma.docCollabState.findUnique({
where: { documentId: documentName },
});
if (stored) {
const stateVector = new Uint8Array(stored.state);
Y.applyUpdate(document, stateVector);
logger.debug(`Docs collab: loaded persisted state for ${documentName} (${stateVector.length} bytes)`);
return;
}
} catch (err) {
logger.warn(`Docs collab: failed to load persisted state for ${documentName}`, err);
}
// No persisted state — seed from disk
try {
const content = await docsFilesService.readFileContent(documentName);
const yText = document.getText('content');
// Only seed if the Y.Text is empty (first load)
if (yText.length === 0) {
yText.insert(0, content);
}
logger.debug(`Docs collab: seeded from disk for ${documentName} (${content.length} chars)`);
} catch (err) {
logger.warn(`Docs collab: failed to read file from disk for ${documentName}`, err);
// File might not exist yet — that's OK, start with empty doc
}
},
async onChange(data) {
const { document, documentName } = data;
// Size guard
const yText = document.getText('content');
const textLength = yText.length;
if (textLength > MAX_DOC_SIZE_BYTES) {
logger.warn(`Docs collab: document ${documentName} exceeds size limit (${textLength} chars)`);
return;
}
// Write plaintext to disk (debounced by Hocuspocus's built-in debounce)
const content = yText.toString();
try {
await docsFilesService.writeFileContent(documentName, content);
// Invalidate Redis file cache
try {
await redis.del(fileCacheKey(documentName));
} catch { /* ignore */ }
logger.debug(`Docs collab: wrote ${documentName} to disk (${content.length} chars)`);
} catch (err) {
logger.error(`Docs collab: failed to write ${documentName} to disk`, err);
}
},
async onStoreDocument(data) {
const { document, documentName } = data;
// Persist Y.Doc binary state to PostgreSQL
try {
const state = Y.encodeStateAsUpdate(document);
await prisma.docCollabState.upsert({
where: { documentId: documentName },
update: {
state: Buffer.from(state),
},
create: {
documentId: documentName,
state: Buffer.from(state),
},
});
logger.debug(`Docs collab: persisted state for ${documentName}`);
} catch (err) {
logger.error(`Docs collab: failed to persist state for ${documentName}`, err);
}
},
async onDisconnect(data) {
const { documentName, context } = data;
const userId = context?.user?.id;
if (userId) {
const count = connectionsPerUser.get(userId) || 0;
if (count <= 1) {
connectionsPerUser.delete(userId);
} else {
connectionsPerUser.set(userId, count - 1);
}
}
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} disconnected from ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
async connected(data) {
const { documentName, context } = data;
// Awareness is set client-side; we just log here
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} fully synced on ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
};
// --- Create Hocuspocus instance ---
const hocuspocus = new Hocuspocus({
name: 'changemaker-docs',
quiet: true,
debounce: 1000, // Debounce disk writes by 1s
maxDebounce: 5000, // Force write every 5s at most
timeout: 30000, // 30s ping timeout
extensions: [docsExtension],
});
/**
* Handle an incoming WebSocket connection for docs collaboration.
* Called from server.ts after the HTTP → WS upgrade.
*/
function handleConnection(
ws: WebSocket,
request: IncomingMessage,
context: { documentName: string; token: string },
): void {
// Hocuspocus expects the token in the URL search params for onAuthenticate
// We manually set it via the context's requestParameters
const url = new URL(request.url || '', `http://${request.headers.host}`);
url.searchParams.set('token', context.token);
request.url = url.pathname + url.search;
hocuspocus.handleConnection(ws, request, {
// Pass initial context
documentName: context.documentName,
});
}
/**
* Invalidate a document's collaboration state.
* Called when files are modified externally (rename, delete, PUT endpoint).
*/
async function invalidateDocument(documentName: string): Promise<void> {
try {
// Delete persisted state
await prisma.docCollabState.deleteMany({
where: { documentId: documentName },
});
// Close active connections for this document so clients reload
hocuspocus.closeConnections(documentName);
logger.debug(`Docs collab: invalidated document ${documentName}`);
} catch (err) {
logger.warn(`Docs collab: failed to invalidate document ${documentName}`, err);
}
}
/**
* Clean up stale DocCollabState records (older than 7 days with no corresponding file).
*/
async function cleanupStaleStates(): Promise<void> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const staleRecords = await prisma.docCollabState.findMany({
where: { updatedAt: { lt: sevenDaysAgo } },
select: { id: true, documentId: true },
});
let cleaned = 0;
for (const record of staleRecords) {
try {
// Check if file still exists on disk
docsFilesService.safeResolve(record.documentId);
await docsFilesService.readFileContent(record.documentId);
} catch {
// File doesn't exist — delete the state
await prisma.docCollabState.delete({ where: { id: record.id } });
cleaned++;
}
}
if (cleaned > 0) {
logger.info(`Docs collab: cleaned up ${cleaned} stale collaboration states`);
}
} catch (err) {
logger.warn('Docs collab: cleanup failed', err);
}
}
/**
* Gracefully close all connections and stop Hocuspocus.
*/
async function shutdown(): Promise<void> {
hocuspocus.closeConnections();
collabConnections.set(0);
collabDocuments.set(0);
}
export const docsCollabService = {
handleConnection,
invalidateDocument,
cleanupStaleStates,
shutdown,
getConnectionsCount: () => hocuspocus.getConnectionsCount(),
getDocumentsCount: () => hocuspocus.getDocumentsCount(),
};