import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify, TextEncoder } from 'node:util'; import { DatabaseSync } from 'node:sqlite'; import cors from '@fastify/cors'; import jwt from '@fastify/jwt'; import fastifyStatic from '@fastify/static'; import websocket from '@fastify/websocket'; import dotenv from 'dotenv'; import libreOffice from 'libreoffice-convert'; import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; import Fastify from 'fastify'; import { Redis } from 'ioredis'; import { z } from 'zod'; import { SpeechTranscriber } from './speech-transcriber.js'; dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) }); const projectRootPath = fileURLToPath(new URL('../../', import.meta.url)); const registerSchema = z.object({ username: z.string().trim().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/), displayName: z.string().trim().min(2).max(48).optional(), password: z.string().min(8).max(128), }); const loginSchema = z.object({ username: z.string().trim().min(3).max(32), password: z.string().min(8).max(128), }); const accessKeyLabelSchema = z.object({ label: z.string().trim().min(1).max(64).optional(), }); const verifyAccessKeySchema = z.object({ credential: z.custom(), }); const accessKeyAuthenticationSchema = z.object({ username: z.string().trim().min(3).max(32).optional(), }); const verifyAccessKeyAuthenticationSchema = z.object({ attemptId: z.string().min(1), credential: z.custom(), }); const approvePendingUserParamsSchema = z.object({ userId: z.string().min(1), }); const adminDeleteUserParamsSchema = z.object({ userId: z.string().min(1), }); const webBundleFileParamsSchema = z.object({ '*': z.string().min(1), }); const officePreviewSchema = z.object({ fileName: z.string().trim().min(1).max(256), mimeType: z.string().trim().min(1).max(256), fileBase64: z.string().min(1).max(96_000_000), }); const wsQuerySchema = z.object({ token: z.string().min(1), }); const signalMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('signal'), to: z.string().min(1), signal: z.discriminatedUnion('type', [ z.object({ type: z.literal('sdp'), description: z.object({ type: z.enum(['offer', 'answer', 'pranswer', 'rollback']), sdp: z.string().optional(), }), }), z.object({ type: z.literal('ice-candidate'), candidate: z.object({ candidate: z.string().optional(), sdpMid: z.string().nullable().optional(), sdpMLineIndex: z.number().nullable().optional(), usernameFragment: z.string().nullable().optional(), }), }), ]), }), z.object({ type: z.literal('image-generation'), requestId: z.string().uuid(), peerId: z.string().min(1), prompt: z.string().trim().min(1).max(4000), }), z.object({ type: z.literal('ping'), }), z.object({ type: z.literal('speech-transcription'), requestId: z.string().uuid(), mimeType: z.string().trim().min(1).max(128), audioBase64: z.string().min(1).max(32_000_000), }), ]); const app = Fastify({ logger: true, trustProxy: true }); const approvalAdminUsername = 'ladparis'; const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data'); const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite')); const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key')); const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser'); const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, ''); const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest'; const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024'; const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080'; const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto'; const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000); const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12); const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60); const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN); const corsAllowedHeaders = ['Authorization', 'Content-Type']; const corsMethods = ['GET', 'POST', 'OPTIONS']; const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200'; const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat'; const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION); const frontendIndexPath = path.join(frontendDistPath, 'index.html'); const hasFrontendBuild = fs.existsSync(frontendIndexPath); const convertOfficeDocument = promisify(libreOffice.convertWithOptions); const speechTranscriber = new SpeechTranscriber({ serviceUrl: speechTranscriptionServiceUrl, language: speechTranscriptionLanguage, requestTimeoutMs: speechTranscriptionTimeoutMs, }, app.log); fs.mkdirSync(path.dirname(sqlitePath), { recursive: true }); fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true }); const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath)); const database = new DatabaseSync(sqlitePath); database.exec(` PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, encrypted_credentials TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, approved_at TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS app_secrets ( name TEXT PRIMARY KEY, encrypted_value TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS webauthn_credentials ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, credential_id TEXT NOT NULL UNIQUE, label TEXT NOT NULL, encrypted_registration TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); ensureUserApprovalColumns(); const createUserStatement = database.prepare(` INSERT INTO users (id, username, display_name, encrypted_credentials, is_active, approved_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) `); const selectUserByUsernameStatement = database.prepare(` SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at FROM users WHERE username = ? `); const selectUserByIdStatement = database.prepare(` SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at FROM users WHERE id = ? `); const selectPendingUsersStatement = database.prepare(` SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at FROM users WHERE is_active = 0 ORDER BY created_at ASC `); const selectAllUsersStatement = database.prepare(` SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at FROM users ORDER BY created_at DESC `); const approveUserStatement = database.prepare(` UPDATE users SET is_active = 1, approved_at = ? WHERE id = ? AND is_active = 0 `); const updateUserCredentialsStatement = database.prepare(` UPDATE users SET encrypted_credentials = ? WHERE id = ? `); const insertSecretStatement = database.prepare(` INSERT INTO app_secrets (name, encrypted_value, created_at) VALUES (?, ?, ?) `); const selectSecretStatement = database.prepare(` SELECT encrypted_value FROM app_secrets WHERE name = ? `); const createAccessKeyStatement = database.prepare(` INSERT INTO webauthn_credentials (id, user_id, credential_id, label, encrypted_registration, created_at) VALUES (?, ?, ?, ?, ?, ?) `); const selectAccessKeysByUserStatement = database.prepare(` SELECT id, user_id, credential_id, label, encrypted_registration, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC `); const selectAccessKeyByCredentialIdStatement = database.prepare(` SELECT id, user_id, credential_id, label, encrypted_registration, created_at FROM webauthn_credentials WHERE credential_id = ? `); const deleteAccessKeysByUserStatement = database.prepare(` DELETE FROM webauthn_credentials WHERE user_id = ? `); const updateAccessKeyStatement = database.prepare(` UPDATE webauthn_credentials SET encrypted_registration = ? WHERE credential_id = ? `); const deleteUserStatement = database.prepare(` DELETE FROM users WHERE id = ? `); const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex')); const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0'); const socketsByUserId = new Map(); await redis.ping(); await app.register(cors, { origin(origin, callback) { callback(null, isAllowedRequestOrigin(origin)); }, credentials: false, allowedHeaders: corsAllowedHeaders, methods: corsMethods, }); await app.register(jwt, { secret: jwtSecret, }); await app.register(websocket); if (hasFrontendBuild) { await app.register(fastifyStatic, { root: frontendDistPath, prefix: '/', }); app.setNotFoundHandler((request, reply) => { const requestPath = request.raw.url?.split('?')[0] ?? '/'; if (request.method !== 'GET' || requestPath === '/ws' || requestPath === '/api' || requestPath.startsWith('/api/')) { return reply.code(404).send({ message: 'Not found.' }); } return reply.type('text/html; charset=utf-8').send(fs.createReadStream(frontendIndexPath)); }); } else { app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.'); } app.get('/api/health', async () => ({ ok: true })); app.get('/api/web-app/manifest', async (request, reply) => { const manifest = getFrontendBundleManifest(); if (!manifest) { return reply.code(404).send({ message: 'Angular frontend build not found.', frontendDistPath, }); } const etag = `"${manifest.bundleId}"`; reply.header('Cache-Control', 'no-cache'); reply.header('ETag', etag); if (requestMatchesEtag(request.headers['if-none-match'], etag)) { return reply.code(304).send(); } return manifest; }); app.get('/api/web-app/files/*', async (request, reply) => { const parsed = webBundleFileParamsSchema.safeParse(request.params); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid web bundle asset path.', issues: parsed.error.flatten(), }); } const asset = resolveFrontendBundleAsset(parsed.data['*']); if (!asset) { return reply.code(404).send({ message: 'Frontend asset not found.' }); } const etag = `W/"${asset.etag}"`; reply.header('Cache-Control', 'public, max-age=300'); reply.header('ETag', etag); reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString()); if (requestMatchesEtag(request.headers['if-none-match'], etag)) { return reply.code(304).send(); } reply.header('Content-Length', String(asset.size)); reply.type(asset.contentType); return reply.send(fs.createReadStream(asset.absolutePath)); }); app.post('/api/auth/register', async (request, reply) => { const parsed = registerSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid registration payload.', issues: parsed.error.flatten(), }); } const username = parsed.data.username.toLowerCase(); if (findUserByUsername(username)) { return reply.code(409).send({ message: 'Username is already taken.' }); } const user = createUser({ username, displayName: parsed.data.displayName?.trim() || parsed.data.username, password: parsed.data.password, isActive: username === approvalAdminUsername, }); if (!user.isActive) { return reply.code(202).send({ pendingApproval: true, message: `Account created. It must be approved by ${approvalAdminUsername} before you can sign in.`, }); } const session = await createSession(user.id); return createAuthReply(user, session.sessionId); }); app.post('/api/auth/login', async (request, reply) => { const parsed = loginSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid login payload.', issues: parsed.error.flatten(), }); } const user = findUserByUsername(parsed.data.username.toLowerCase()); if (!user || !verifyPassword(parsed.data.password, user.passwordHash)) { return reply.code(401).send({ message: 'Invalid credentials.' }); } if (!user.isActive) { return reply.code(403).send({ message: `Your account is awaiting approval from ${approvalAdminUsername}.`, }); } const session = await createSession(user.id); return createAuthReply(user, session.sessionId); }); app.post('/api/webauthn/authenticate/options', async (request, reply) => { const parsed = accessKeyAuthenticationSchema.safeParse(request.body ?? {}); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid access key sign-in payload.', issues: parsed.error.flatten(), }); } const expectedOrigin = resolveWebAuthnOrigin(request); const expectedRpId = process.env.WEBAUTHN_RP_ID ?? new URL(expectedOrigin).hostname; const username = parsed.data.username?.trim().toLowerCase(); const user = username ? findUserByUsername(username) : null; if (username && !user) { return reply.code(400).send({ message: 'No access key is registered for that username.' }); } if (user && !user.isActive) { return reply.code(403).send({ message: `Your account is awaiting approval from ${approvalAdminUsername}.`, }); } const storedCredentials = user ? listStoredAccessKeys(user.id) : []; if (username && storedCredentials.length === 0) { return reply.code(400).send({ message: 'No access key is registered for that username.' }); } const attemptId = crypto.randomUUID(); const options = await generateAuthenticationOptions({ rpID: expectedRpId, userVerification: webAuthnUserVerification, allowCredentials: storedCredentials.length > 0 ? storedCredentials.map((credential) => ({ id: credential.credentialId, transports: credential.transports, })) : undefined, }); await redis.set(webAuthnAuthenticationKey(attemptId), JSON.stringify({ challenge: options.challenge, expectedOrigin, expectedRpId, expectedUserId: user?.id ?? null, }), 'EX', webAuthnChallengeTtlSeconds); return { attemptId, expectedOrigin, ...options, }; }); app.post('/api/webauthn/authenticate/verify', async (request, reply) => { const parsed = verifyAccessKeyAuthenticationSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid access key sign-in verification payload.', issues: parsed.error.flatten(), }); } const pending = await getPendingAuthentication(parsed.data.attemptId); if (!pending) { return reply.code(400).send({ message: 'Access key sign-in challenge expired.' }); } try { const storedAccessKey = findStoredAccessKeyByCredentialId(parsed.data.credential.id); if (!storedAccessKey) { return reply.code(401).send({ message: 'This access key is not registered.' }); } if (!storedAccessKey.user.isActive) { return reply.code(403).send({ message: `Your account is awaiting approval from ${approvalAdminUsername}.`, }); } if (pending.expectedUserId && storedAccessKey.user.id !== pending.expectedUserId) { return reply.code(401).send({ message: 'This access key does not match the requested username.' }); } const verification = await verifyAuthenticationResponse({ response: parsed.data.credential, expectedChallenge: pending.challenge, expectedOrigin: pending.expectedOrigin, expectedRPID: pending.expectedRpId, credential: { id: storedAccessKey.registration.credentialId, publicKey: Buffer.from(storedAccessKey.registration.publicKey, 'base64url'), counter: storedAccessKey.registration.counter, transports: storedAccessKey.registration.transports, }, requireUserVerification: webAuthnUserVerification === 'required', }); if (!verification.verified) { return reply.code(401).send({ message: 'Access key sign-in could not be verified.' }); } updateStoredAccessKey(storedAccessKey, { counter: verification.authenticationInfo.newCounter, deviceType: verification.authenticationInfo.credentialDeviceType, backedUp: verification.authenticationInfo.credentialBackedUp, }); const session = await createSession(storedAccessKey.user.id); return createAuthReply(storedAccessKey.user, session.sessionId); } catch (error) { app.log.warn({ err: error }, 'WebAuthn authentication verification failed'); return reply.code(400).send({ message: error instanceof Error ? `Access key sign-in could not be verified: ${error.message}` : 'Access key sign-in could not be verified.', }); } finally { await clearPendingAuthentication(parsed.data.attemptId); } }); app.get('/api/auth/session', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } return { user: toPublicUser(authContext.user), messageEncryptionKey: authContext.user.messageEncryptionKey, }; }); app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } const parsed = officePreviewSchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid office preview payload.', issues: parsed.error.flatten(), }); } if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) { return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' }); } try { const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64); return { mimeType: 'application/pdf', pdfBase64: pdfBuffer.toString('base64'), }; } catch (error) { app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed'); return reply.code(422).send({ message: describeOfficePreviewFailure(error), }); } }); app.get('/api/admin/pending-users', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } if (!isApprovalAdmin(authContext.user)) { return reply.code(403).send({ message: 'Only ladparis can approve accounts.' }); } return { users: listPendingApprovalUsers(), }; }); app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } if (!isApprovalAdmin(authContext.user)) { return reply.code(403).send({ message: 'Only ladparis can approve accounts.' }); } const parsed = approvePendingUserParamsSchema.safeParse(request.params); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid user approval request.', issues: parsed.error.flatten(), }); } const approvedUser = approveUser(parsed.data.userId); if (!approvedUser) { return reply.code(404).send({ message: 'Pending user not found.' }); } return { user: toPublicUser(approvedUser), }; }); app.get('/api/admin/users', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } if (!isApprovalAdmin(authContext.user)) { return reply.code(403).send({ message: 'Only ladparis can delete users.' }); } return { users: listAdminUsers(), }; }); app.delete('/api/admin/users/:userId', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } if (!isApprovalAdmin(authContext.user)) { return reply.code(403).send({ message: 'Only ladparis can delete users.' }); } const parsed = adminDeleteUserParamsSchema.safeParse(request.params); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid user deletion request.', issues: parsed.error.flatten(), }); } const deletedUser = await deleteUserAccount(parsed.data.userId); if (!deletedUser) { return reply.code(404).send({ message: 'User not found.' }); } return { user: toPublicUser(deletedUser), }; }); app.post('/api/auth/logout', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } await destroySession(authContext.session.sessionId); await clearPendingRegistration(authContext.session.sessionId); closeSocketSession(authContext.user.id, authContext.session.sessionId); return { ok: true }; }); app.get('/api/webauthn/credentials', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } return { credentials: listAccessKeys(authContext.user.id), }; }); app.post('/api/webauthn/register/options', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } const parsed = accessKeyLabelSchema.safeParse(request.body ?? {}); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid access key request payload.', issues: parsed.error.flatten(), }); } const label = parsed.data.label?.trim() || defaultAccessKeyLabel(); const storedCredentials = listStoredAccessKeys(authContext.user.id); const expectedOrigin = resolveWebAuthnOrigin(request); const expectedRpId = process.env.WEBAUTHN_RP_ID ?? new URL(expectedOrigin).hostname; const options = await generateRegistrationOptions({ rpName: webAuthnRpName, rpID: expectedRpId, userName: authContext.user.username, userDisplayName: authContext.user.displayName, userID: new TextEncoder().encode(authContext.user.id), attestationType: 'none', authenticatorSelection: { residentKey: 'preferred', userVerification: webAuthnUserVerification, }, excludeCredentials: storedCredentials.map((credential) => ({ id: credential.credentialId, transports: credential.transports, })), }); await redis.set(webAuthnRegistrationKey(authContext.session.sessionId), JSON.stringify({ challenge: options.challenge, label, expectedOrigin, expectedRpId, }), 'EX', webAuthnChallengeTtlSeconds); return { expectedOrigin, ...options, }; }); app.post('/api/webauthn/register/verify', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { return; } const parsed = verifyAccessKeySchema.safeParse(request.body); if (!parsed.success) { return reply.code(400).send({ message: 'Invalid access key verification payload.', issues: parsed.error.flatten(), }); } const pending = await getPendingRegistration(authContext.session.sessionId); if (!pending) { return reply.code(400).send({ message: 'Access key registration challenge expired.' }); } try { const verification = await verifyRegistrationResponse({ response: parsed.data.credential, expectedChallenge: pending.challenge, expectedOrigin: pending.expectedOrigin, expectedRPID: pending.expectedRpId, requireUserVerification: webAuthnUserVerification === 'required', }); if (!verification.verified || !verification.registrationInfo) { return reply.code(400).send({ message: 'Access key registration could not be verified.' }); } const registrationInfo = verification.registrationInfo; persistAccessKey(authContext.user.id, { credentialId: registrationInfo.credential.id, label: pending.label, publicKey: bytesToBase64Url(registrationInfo.credential.publicKey), counter: registrationInfo.credential.counter, transports: registrationInfo.credential.transports ?? [], deviceType: registrationInfo.credentialDeviceType, backedUp: registrationInfo.credentialBackedUp, aaguid: registrationInfo.aaguid, }); await clearPendingRegistration(authContext.session.sessionId); return { credential: listAccessKeys(authContext.user.id)[0], }; } catch (error) { app.log.warn({ err: error }, 'WebAuthn registration verification failed'); return reply.code(400).send({ message: error instanceof Error ? `Access key registration could not be verified: ${error.message}` : 'Access key registration could not be verified.', }); } }); app.get('/ws', { websocket: true }, (socket, request) => { void openSocket(socket, request); }); const port = Number(process.env.PORT ?? 16990); await app.listen({ port, host: '0.0.0.0' }); app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`); async function openSocket(socket, request) { if (!isAllowedRequestOrigin(request.headers.origin)) { send(socket, { type: 'error', message: 'Origin not allowed.' }); socket.close(); return; } const query = wsQuerySchema.safeParse(request.query); if (!query.success) { send(socket, { type: 'error', message: 'Missing token.' }); socket.close(); return; } const authContext = await authenticateToken(query.data.token); if (!authContext) { send(socket, { type: 'error', message: 'Authentication required.' }); socket.close(); return; } const userSockets = socketsByUserId.get(authContext.user.id) ?? new Map(); const isFirstConnection = userSockets.size === 0; userSockets.set(authContext.session.sessionId, socket); socketsByUserId.set(authContext.user.id, userSockets); send(socket, { type: 'presence', self: toPublicUser(authContext.user), peers: listOnlinePeers(authContext.user.id), }); if (isFirstConnection) { broadcastExcept(authContext.user.id, { type: 'peer-joined', peer: toPublicUser(authContext.user), }); } socket.on('message', (rawMessage) => { void handleSocketMessage(authContext.user.id, authContext.session.sessionId, socket, rawMessage.toString()); }); socket.on('close', () => { if (detachSocket(authContext.user.id, authContext.session.sessionId)) { broadcastExcept(authContext.user.id, { type: 'peer-left', peerId: authContext.user.id }); } }); } async function handleSocketMessage(userId, sessionId, socket, rawMessage) { const authContext = await authenticateTokenFromSession(userId, sessionId); if (!authContext) { send(socket, { type: 'error', message: 'Session expired.' }); socket.close(); return; } const parsed = parseClientMessage(rawMessage); if (!parsed) { send(socket, { type: 'error', message: 'Unsupported signaling message.' }); return; } if (parsed.type === 'ping') { send(socket, { type: 'pong' }); return; } if (parsed.type === 'image-generation') { try { const generatedImage = await generateImageFromPrompt(parsed.prompt); send(socket, { type: 'image-generated', requestId: parsed.requestId, peerId: parsed.peerId, prompt: parsed.prompt, createdAt: Date.now(), mimeType: generatedImage.mimeType, imageBase64: generatedImage.imageBase64, }); } catch (error) { app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed'); send(socket, { type: 'image-generation-error', requestId: parsed.requestId, peerId: parsed.peerId, message: error instanceof Error ? error.message : 'Image generation failed.', }); } return; } if (parsed.type === 'speech-transcription') { try { const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType); send(socket, { type: 'speech-transcribed', requestId: parsed.requestId, text, }); } catch (error) { app.log.warn({ err: error, userId }, 'Speech transcription failed'); send(socket, { type: 'speech-transcription-error', requestId: parsed.requestId, message: error instanceof Error ? error.message : 'Speech transcription failed.', }); } return; } let delivered = 0; const recipientSockets = socketsByUserId.get(parsed.to); if (recipientSockets) { for (const [recipientSessionId, recipientSocket] of recipientSockets.entries()) { const recipientContext = await authenticateTokenFromSession(parsed.to, recipientSessionId); if (!recipientContext) { recipientSocket.close(); continue; } send(recipientSocket, { type: 'signal', from: authContext.user.id, signal: parsed.signal, }); delivered += 1; } } if (delivered === 0) { send(socket, { type: 'error', message: 'Peer is offline or not authenticated.' }); } } function createAuthReply(user, sessionId) { return { token: app.jwt.sign({ sub: user.id, username: user.username, displayName: user.displayName, sid: sessionId, }), user: toPublicUser(user), messageEncryptionKey: user.messageEncryptionKey, }; } async function authenticateRequest(request, reply) { const token = extractBearerToken(request.headers.authorization); if (!token) { reply.code(401).send({ message: 'Authentication required.' }); return null; } const authContext = await authenticateToken(token); if (!authContext) { reply.code(401).send({ message: 'Invalid or expired session.' }); return null; } return authContext; } async function authenticateToken(token) { let decoded; try { decoded = app.jwt.verify(token); } catch { return null; } return authenticateTokenFromSession(decoded.sub, decoded.sid, decoded); } async function authenticateTokenFromSession(userId, sessionId, decoded) { const session = await getSession(sessionId); if (!session || session.userId !== userId) { return null; } const user = findUserById(userId); if (!user) { await destroySession(sessionId); return null; } if (!user.isActive) { await destroySession(sessionId); return null; } await redis.expire(sessionKey(sessionId), sessionTtlSeconds); return { user, session, token: decoded ?? { sub: user.id, username: user.username, displayName: user.displayName, sid: sessionId, }, }; } async function convertOfficeDocumentToPdf(fileName, fileBase64) { const inputBuffer = Buffer.from(fileBase64, 'base64'); if (inputBuffer.byteLength === 0) { throw new Error('The uploaded office document is empty.'); } const normalizedFileName = normalizeOfficeDocumentFileName(fileName); return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName }); } function isSupportedOfficeDocument(fileName, mimeType) { const normalizedFileName = fileName.trim().toLowerCase(); const normalizedMimeType = mimeType.trim().toLowerCase(); if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') { return true; } return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName); } function normalizeOfficeDocumentFileName(fileName) { return fileName.trim().replace(/\.xslx$/i, '.xlsx'); } function describeOfficePreviewFailure(error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return 'Office preview generation failed because LibreOffice is not installed on the server.'; } if (error instanceof Error && error.message.trim()) { return `Office preview generation failed: ${error.message}`; } return 'Office preview generation failed.'; } function createUser(input) { const createdAt = new Date().toISOString(); const user = { id: crypto.randomUUID(), username: input.username, displayName: input.displayName, passwordHash: hashPassword(input.password), messageEncryptionKey: generateUserMessageEncryptionKey(), isActive: input.isActive, createdAt, approvedAt: input.isActive ? createdAt : null, }; createUserStatement.run(user.id, user.username, user.displayName, encryptJson({ passwordHash: user.passwordHash, messageEncryptionKey: user.messageEncryptionKey, }), user.isActive ? 1 : 0, user.approvedAt, user.createdAt); return user; } function listPendingApprovalUsers() { const rows = selectPendingUsersStatement.all(); return rows.map((row) => ({ id: row.id, username: row.username, displayName: row.display_name, createdAt: row.created_at, })); } function listAdminUsers() { const rows = selectAllUsersStatement.all(); return rows.map((row) => ({ id: row.id, username: row.username, displayName: row.display_name, isActive: row.is_active === 1, createdAt: row.created_at, approvedAt: row.approved_at, })); } function approveUser(userId) { const approvedAt = new Date().toISOString(); const result = approveUserStatement.run(approvedAt, userId); if (result.changes === 0) { return null; } return findUserById(userId); } async function deleteUserAccount(userId) { const user = findUserById(userId); if (!user) { return null; } deleteAccessKeysByUserStatement.run(userId); const result = deleteUserStatement.run(userId); if (result.changes === 0) { return null; } await destroyUserSessions(userId); return user; } function persistAccessKey(userId, input) { createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({ credentialId: input.credentialId, publicKey: input.publicKey, counter: input.counter, transports: input.transports, deviceType: input.deviceType, backedUp: input.backedUp, aaguid: input.aaguid, }), new Date().toISOString()); } function updateStoredAccessKey(storedAccessKey, changes) { const nextRegistration = { ...storedAccessKey.registration, counter: changes.counter, deviceType: changes.deviceType, backedUp: changes.backedUp, }; updateAccessKeyStatement.run(encryptJson(nextRegistration), storedAccessKey.row.credential_id); } function findUserByUsername(username) { const row = selectUserByUsernameStatement.get(username); return row ? hydrateUser(row) : null; } function findUserById(userId) { const row = selectUserByIdStatement.get(userId); return row ? hydrateUser(row) : null; } function hydrateUser(row) { const credentials = decryptJson(row.encrypted_credentials); const messageEncryptionKey = credentials.messageEncryptionKey ?? generateUserMessageEncryptionKey(); if (!credentials.messageEncryptionKey) { updateUserCredentialsStatement.run(encryptJson({ passwordHash: credentials.passwordHash, messageEncryptionKey, }), row.id); } return { id: row.id, username: row.username, displayName: row.display_name, passwordHash: credentials.passwordHash, messageEncryptionKey, isActive: row.is_active === 1, createdAt: row.created_at, approvedAt: row.approved_at, }; } function listStoredAccessKeys(userId) { const rows = selectAccessKeysByUserStatement.all(userId); return rows.map((row) => decryptJson(row.encrypted_registration)); } function findStoredAccessKeyByCredentialId(credentialId) { const row = selectAccessKeyByCredentialIdStatement.get(credentialId); if (!row) { return null; } const user = findUserById(row.user_id); if (!user) { return null; } return { row, user, registration: decryptJson(row.encrypted_registration), }; } function listAccessKeys(userId) { const rows = selectAccessKeysByUserStatement.all(userId); return rows.map((row) => { const registration = decryptJson(row.encrypted_registration); return { id: row.id, credentialId: row.credential_id, label: row.label, transports: registration.transports, deviceType: registration.deviceType, backedUp: registration.backedUp, aaguid: registration.aaguid, createdAt: row.created_at, }; }); } function loadOrCreateSecret(name, createValue) { const row = selectSecretStatement.get(name); if (row) { return decryptText(row.encrypted_value); } const value = createValue(); insertSecretStatement.run(name, encryptText(value), new Date().toISOString()); return value; } async function createSession(userId) { const session = { sessionId: crypto.randomUUID(), userId, createdAt: new Date().toISOString(), }; await redis.set(sessionKey(session.sessionId), JSON.stringify(session), 'EX', sessionTtlSeconds); return session; } async function getSession(sessionId) { const payload = await redis.get(sessionKey(sessionId)); if (!payload) { return null; } return JSON.parse(payload); } async function destroySession(sessionId) { await redis.del(sessionKey(sessionId)); } async function destroyUserSessions(userId) { let cursor = '0'; do { const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100); cursor = nextCursor; for (const key of keys) { const payload = await redis.get(key); if (!payload) { continue; } let session = null; try { session = JSON.parse(payload); } catch { session = null; } if (!session || session.userId !== userId) { continue; } await destroySession(session.sessionId); await clearPendingRegistration(session.sessionId); closeSocketSession(userId, session.sessionId); } } while (cursor !== '0'); } function sessionKey(sessionId) { return `privatechat:session:${sessionId}`; } function webAuthnRegistrationKey(sessionId) { return `privatechat:webauthn-registration:${sessionId}`; } function webAuthnAuthenticationKey(attemptId) { return `privatechat:webauthn-authentication:${attemptId}`; } async function getPendingRegistration(sessionId) { const payload = await redis.get(webAuthnRegistrationKey(sessionId)); if (!payload) { return null; } return JSON.parse(payload); } async function clearPendingRegistration(sessionId) { await redis.del(webAuthnRegistrationKey(sessionId)); } async function getPendingAuthentication(attemptId) { const payload = await redis.get(webAuthnAuthenticationKey(attemptId)); if (!payload) { return null; } return JSON.parse(payload); } async function clearPendingAuthentication(attemptId) { await redis.del(webAuthnAuthenticationKey(attemptId)); } function closeSocketSession(userId, sessionId) { const socket = socketsByUserId.get(userId)?.get(sessionId); if (socket && socket.readyState < 2) { socket.close(); } } function detachSocket(userId, sessionId) { const userSockets = socketsByUserId.get(userId); if (!userSockets) { return false; } userSockets.delete(sessionId); if (userSockets.size === 0) { socketsByUserId.delete(userId); return true; } return false; } function listOnlinePeers(currentUserId) { const peers = []; for (const [userId, sockets] of socketsByUserId.entries()) { if (userId === currentUserId || sockets.size === 0) { continue; } const user = findUserById(userId); if (user) { peers.push(toPublicUser(user)); } } return peers.sort((left, right) => left.displayName.localeCompare(right.displayName)); } function broadcastExcept(userId, payload) { for (const [peerId, sockets] of socketsByUserId.entries()) { if (peerId === userId) { continue; } for (const socket of sockets.values()) { send(socket, payload); } } } function send(socket, payload) { if (socket.readyState === 1) { socket.send(JSON.stringify(payload)); } } function parseClientMessage(rawMessage) { let payload; try { payload = JSON.parse(rawMessage); } catch { return null; } const parsed = signalMessageSchema.safeParse(payload); if (!parsed.success) { return null; } if (parsed.data.type === 'ping') { return { type: 'ping' }; } if (parsed.data.type === 'image-generation') { return { type: 'image-generation', requestId: parsed.data.requestId, peerId: parsed.data.peerId, prompt: parsed.data.prompt, }; } if (parsed.data.type === 'speech-transcription') { return { type: 'speech-transcription', requestId: parsed.data.requestId, mimeType: parsed.data.mimeType, audioBase64: parsed.data.audioBase64, }; } return { type: 'signal', to: parsed.data.to, signal: normalizeSignal(parsed.data.signal), }; } async function transcribeAudioPayload(requestId, audioBase64, mimeType) { return await speechTranscriber.transcribe(requestId, audioBase64, mimeType); } async function generateImageFromPrompt(prompt) { const abortController = new AbortController(); const timeoutId = setTimeout(() => abortController.abort(), 120_000); try { const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ model: ollamaImageModel, prompt, size: ollamaImageSize, response_format: 'b64_json', n: 1, }), signal: abortController.signal, }); const payload = await response.json(); if (!response.ok) { const errorMessage = typeof payload.error === 'string' ? payload.error : payload.error?.message; throw new Error(errorMessage || 'Ollama image generation request failed.'); } const imageBase64 = payload.data?.[0]?.b64_json?.trim(); if (!imageBase64) { throw new Error('Ollama did not return image data.'); } return { imageBase64, mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')), }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error('Ollama image generation timed out.'); } throw error; } finally { clearTimeout(timeoutId); } } function inferImageMimeType(imageBuffer) { if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { return 'image/png'; } if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) { return 'image/jpeg'; } if (imageBuffer.length >= 12 && imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' && imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP') { return 'image/webp'; } if (imageBuffer.length >= 6) { const header = imageBuffer.subarray(0, 6).toString('ascii'); if (header === 'GIF87a' || header === 'GIF89a') { return 'image/gif'; } } return 'application/octet-stream'; } function getFrontendBundleManifest() { if (!fs.existsSync(frontendIndexPath)) { return null; } const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => { const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath)); const stats = fs.statSync(absolutePath); const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex'); return { path: relativePath, size: stats.size, sha256, lastModified: stats.mtime.toISOString(), contentType: detectBundleContentType(relativePath), href: bundleAssetHref(relativePath), }; }); files.sort((left, right) => left.path.localeCompare(right.path)); const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString()); const bundleId = files.reduce((hash, file) => { hash.update(file.path); hash.update(file.sha256); hash.update(String(file.size)); return hash; }, crypto.createHash('sha256')).digest('hex'); return { bundleId, generatedAt, indexPath: 'index.html', files, }; } function resolveFrontendBundleAsset(relativeAssetPath) { if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) { return null; } const normalizedPath = toBundleRelativePath(relativeAssetPath); if (normalizedPath.length === 0 || normalizedPath === '.' || normalizedPath.startsWith('../') || normalizedPath.startsWith('/')) { return null; } const absolutePath = path.resolve(frontendDistPath, normalizedPath); const relativeToRoot = path.relative(frontendDistPath, absolutePath); if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) { return null; } const stats = fs.statSync(absolutePath); if (!stats.isFile()) { return null; } return { absolutePath, contentType: detectBundleContentType(normalizedPath), size: stats.size, lastModifiedMs: stats.mtimeMs, etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`, }; } function listBundleFilePaths(rootPath) { return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => { const entryPath = path.join(rootPath, entry.name); if (entry.isDirectory()) { return listBundleFilePaths(entryPath); } if (!entry.isFile()) { return []; } return [entryPath]; }); } function bundleAssetHref(relativePath) { return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`; } function toBundleRelativePath(inputPath) { return path.posix.normalize(inputPath.replaceAll('\\', '/')); } function detectBundleContentType(assetPath) { const extension = path.extname(assetPath).toLowerCase(); switch (extension) { case '.mp3': return 'audio/mpeg'; case '.m4a': return 'audio/mp4'; case '.css': return 'text/css; charset=utf-8'; case '.html': return 'text/html; charset=utf-8'; case '.ico': return 'image/x-icon'; case '.jpeg': case '.jpg': return 'image/jpeg'; case '.js': return 'text/javascript; charset=utf-8'; case '.json': return 'application/json; charset=utf-8'; case '.map': return 'application/json; charset=utf-8'; case '.png': return 'image/png'; case '.svg': return 'image/svg+xml; charset=utf-8'; case '.txt': return 'text/plain; charset=utf-8'; case '.webp': return 'image/webp'; case '.webmanifest': return 'application/manifest+json; charset=utf-8'; case '.woff': return 'font/woff'; case '.woff2': return 'font/woff2'; default: return 'application/octet-stream'; } } function requestMatchesEtag(headerValue, etag) { if (!headerValue) { return false; } const incomingEtags = Array.isArray(headerValue) ? headerValue : headerValue.split(',').map((value) => value.trim()); return incomingEtags.includes(etag) || incomingEtags.includes('*'); } function normalizeSignal(signal) { if (signal.type === 'sdp') { return { type: 'sdp', description: signal.description, }; } return { type: 'ice-candidate', candidate: signal.candidate, }; } function extractBearerToken(authorizationHeader) { if (!authorizationHeader?.startsWith('Bearer ')) { return null; } return authorizationHeader.slice('Bearer '.length).trim(); } function toPublicUser(user) { return { id: user.id, username: user.username, displayName: user.displayName, }; } function isApprovalAdmin(user) { return user.username === approvalAdminUsername; } function hashPassword(password) { const salt = crypto.randomBytes(16).toString('hex'); const derived = crypto.scryptSync(password, salt, 64).toString('hex'); return `${salt}:${derived}`; } function verifyPassword(password, storedHash) { const [salt, stored] = storedHash.split(':'); if (!salt || !stored) { return false; } const incoming = crypto.scryptSync(password, salt, 64); const saved = Buffer.from(stored, 'hex'); return incoming.length === saved.length && crypto.timingSafeEqual(incoming, saved); } function generateUserMessageEncryptionKey() { return crypto.randomBytes(32).toString('base64url'); } function encryptJson(value) { return encryptText(JSON.stringify(value)); } function decryptJson(payload) { return JSON.parse(decryptText(payload)); } function encryptText(value) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, iv); const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; } function decryptText(payload) { const [ivHex, authTagHex, encryptedHex] = payload.split(':'); if (!ivHex || !authTagHex || !encryptedHex) { throw new Error('Encrypted payload is malformed.'); } const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, Buffer.from(ivHex, 'hex')); decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); const decrypted = Buffer.concat([ decipher.update(Buffer.from(encryptedHex, 'hex')), decipher.final(), ]); return decrypted.toString('utf8'); } function loadOrCreateMasterKey(masterKeyFilePath) { if (process.env.PRIVATECHAT_MASTER_KEY) { return process.env.PRIVATECHAT_MASTER_KEY; } if (fs.existsSync(masterKeyFilePath)) { return fs.readFileSync(masterKeyFilePath, 'utf8').trim(); } const generatedKey = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(masterKeyFilePath, generatedKey, { mode: 0o600 }); return generatedKey; } function ensureUserApprovalColumns() { const rows = database.prepare('PRAGMA table_info(users)').all(); const columnNames = new Set(rows.map((row) => row.name)); if (!columnNames.has('is_active')) { database.exec('ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1'); } if (!columnNames.has('approved_at')) { database.exec('ALTER TABLE users ADD COLUMN approved_at TEXT'); } database.exec(` UPDATE users SET approved_at = COALESCE(approved_at, created_at) WHERE is_active = 1 `); } function deriveEncryptionKey(masterKeyMaterial) { return crypto.createHash('sha256').update(masterKeyMaterial).digest(); } function resolveStoragePath(targetPath) { return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath); } function resolveProjectPath(targetPath) { return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath); } function parseAllowedOrigins(value) { if (!value) { return new Set(); } return new Set(value .split(',') .map((origin) => normalizeOrigin(origin)) .filter((origin) => origin.length > 0 && origin !== 'null')); } function normalizeOrigin(origin) { const trimmed = origin.trim(); if (trimmed === 'null') { return trimmed; } return trimmed.replace(/\/+$/, ''); } function isAllowedRequestOrigin(originHeader) { if (!originHeader) { return true; } const origin = normalizeOrigin(originHeader); if (origin === 'null') { return true; } if (allowedCorsOrigins.size === 0) { return true; } return allowedCorsOrigins.has(origin); } function resolveWebAuthnOrigin(request) { const originHeader = request.headers.origin; if (typeof originHeader === 'string' && originHeader.length > 0) { return originHeader; } return webAuthnOrigin; } function resolveWebAuthnUserVerification(value) { switch (value?.toLowerCase()) { case 'discouraged': return 'discouraged'; case 'required': return 'required'; default: return 'preferred'; } } function bytesToBase64Url(value) { return Buffer.from(value).toString('base64url'); } function defaultAccessKeyLabel() { return `Access key ${new Date().toISOString().replace('T', ' ').slice(0, 16)}`; }