Files
PrivateChat/server/dist/index.js

1564 lines
56 KiB
JavaScript
Raw Normal View History

2026-03-09 19:35:08 +01:00
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
2026-03-11 09:09:15 +01:00
import { promisify, TextEncoder } from 'node:util';
2026-03-09 19:35:08 +01:00
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';
2026-03-11 09:09:15 +01:00
import libreOffice from 'libreoffice-convert';
2026-03-09 19:35:08 +01:00
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
import Fastify from 'fastify';
import { Redis } from 'ioredis';
import { z } from 'zod';
2026-03-11 03:08:27 +01:00
import { SpeechTranscriber } from './speech-transcriber.js';
2026-03-09 19:35:08 +01:00
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),
});
2026-03-10 02:49:27 +01:00
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
2026-03-10 04:13:32 +01:00
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
2026-03-11 09:09:15 +01:00
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),
});
2026-03-09 19:35:08 +01:00
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(),
}),
}),
]),
}),
2026-03-10 02:49:27 +01:00
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'),
}),
2026-03-11 00:26:49 +01:00
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),
}),
2026-03-09 19:35:08 +01:00
]);
2026-03-10 02:49:27 +01:00
const app = Fastify({ logger: true, trustProxy: true });
2026-03-09 19:35:08 +01:00
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');
2026-03-10 02:49:27 +01:00
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';
2026-03-11 03:08:27 +01:00
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);
2026-03-09 19:35:08 +01:00
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
2026-03-10 02:49:27 +01:00
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
const corsMethods = ['GET', 'POST', 'OPTIONS'];
2026-03-09 19:35:08 +01:00
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);
2026-03-11 09:09:15 +01:00
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
2026-03-11 03:08:27 +01:00
const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
requestTimeoutMs: speechTranscriptionTimeoutMs,
2026-03-11 00:26:49 +01:00
}, app.log);
2026-03-09 19:35:08 +01:00
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
`);
2026-03-10 02:49:27 +01:00
const selectAllUsersStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
ORDER BY created_at DESC
`);
2026-03-09 19:35:08 +01:00
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 = ?
`);
2026-03-10 02:49:27 +01:00
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
2026-03-09 19:35:08 +01:00
const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials
SET encrypted_registration = ?
WHERE credential_id = ?
`);
2026-03-10 02:49:27 +01:00
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
2026-03-09 19:35:08 +01:00
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, {
2026-03-10 02:49:27 +01:00
origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
2026-03-09 19:35:08 +01:00
credentials: false,
2026-03-10 02:49:27 +01:00
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
2026-03-09 19:35:08 +01:00
});
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 }));
2026-03-10 04:13:32 +01:00
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));
});
2026-03-09 19:35:08 +01:00
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,
};
});
2026-03-11 09:09:15 +01:00
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),
});
}
});
2026-03-09 19:35:08 +01:00
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),
};
});
2026-03-10 02:49:27 +01:00
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),
};
});
2026-03-09 19:35:08 +01:00
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) {
2026-03-10 02:49:27 +01:00
if (!isAllowedRequestOrigin(request.headers.origin)) {
send(socket, { type: 'error', message: 'Origin not allowed.' });
socket.close();
return;
}
2026-03-09 19:35:08 +01:00
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;
}
2026-03-10 02:49:27 +01:00
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;
}
2026-03-11 00:26:49 +01:00
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) {
2026-03-11 03:08:27 +01:00
app.log.warn({ err: error, userId }, 'Speech transcription failed');
2026-03-11 00:26:49 +01:00
send(socket, {
type: 'speech-transcription-error',
requestId: parsed.requestId,
message: error instanceof Error ? error.message : 'Speech transcription failed.',
});
}
return;
}
2026-03-09 19:35:08 +01:00
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,
},
};
}
2026-03-11 09:09:15 +01:00
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.';
}
2026-03-09 19:35:08 +01:00
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,
}));
}
2026-03-10 02:49:27 +01:00
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,
}));
}
2026-03-09 19:35:08 +01:00
function approveUser(userId) {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
if (result.changes === 0) {
return null;
}
return findUserById(userId);
}
2026-03-10 02:49:27 +01:00
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;
}
2026-03-09 19:35:08 +01:00
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));
}
2026-03-10 02:49:27 +01:00
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');
}
2026-03-09 19:35:08 +01:00
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;
}
2026-03-10 02:49:27 +01:00
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,
};
}
2026-03-11 00:26:49 +01:00
if (parsed.data.type === 'speech-transcription') {
return {
type: 'speech-transcription',
requestId: parsed.data.requestId,
mimeType: parsed.data.mimeType,
audioBase64: parsed.data.audioBase64,
};
}
2026-03-09 19:35:08 +01:00
return {
type: 'signal',
to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal),
};
}
2026-03-11 00:26:49 +01:00
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
2026-03-11 03:08:27 +01:00
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
2026-03-11 00:26:49 +01:00
}
2026-03-10 02:49:27 +01:00
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';
}
2026-03-10 04:13:32 +01:00
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) {
2026-03-10 22:36:21 +01:00
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
2026-03-10 04:13:32 +01:00
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('*');
}
2026-03-09 19:35:08 +01:00
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);
}
2026-03-10 02:49:27 +01:00
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);
}
2026-03-09 19:35:08 +01:00
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)}`;
}