2345 lines
62 KiB
TypeScript
2345 lines
62 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
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,
|
|
type AuthenticationResponseJSON,
|
|
verifyRegistrationResponse,
|
|
type RegistrationResponseJSON,
|
|
} from '@simplewebauthn/server';
|
|
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
|
import { Redis } from 'ioredis';
|
|
import type WebSocket from 'ws';
|
|
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));
|
|
|
|
type UserRecord = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
passwordHash: string;
|
|
messageEncryptionKey: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
approvedAt: string | null;
|
|
};
|
|
|
|
type PublicUser = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
};
|
|
|
|
type AuthToken = {
|
|
sub: string;
|
|
username: string;
|
|
displayName: string;
|
|
sid: string;
|
|
};
|
|
|
|
type SessionRecord = {
|
|
sessionId: string;
|
|
userId: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AuthContext = {
|
|
user: UserRecord;
|
|
session: SessionRecord;
|
|
token: AuthToken;
|
|
};
|
|
|
|
type DatabaseUserRow = {
|
|
id: string;
|
|
username: string;
|
|
display_name: string;
|
|
encrypted_credentials: string;
|
|
is_active: number;
|
|
created_at: string;
|
|
approved_at: string | null;
|
|
};
|
|
|
|
type DatabaseTableColumnRow = {
|
|
name: string;
|
|
};
|
|
|
|
type PendingApprovalUser = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AdminUserSummary = {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
isActive: boolean;
|
|
createdAt: string;
|
|
approvedAt: string | null;
|
|
};
|
|
|
|
type DatabaseAccessKeyRow = {
|
|
id: string;
|
|
user_id: string;
|
|
credential_id: string;
|
|
label: string;
|
|
encrypted_registration: string;
|
|
created_at: string;
|
|
};
|
|
|
|
type SignalPayload =
|
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
|
|
|
type ClientMessage =
|
|
| {
|
|
type: 'signal';
|
|
to: string;
|
|
signal: SignalPayload;
|
|
}
|
|
| {
|
|
type: 'image-generation';
|
|
requestId: string;
|
|
peerId: string;
|
|
prompt: string;
|
|
}
|
|
| {
|
|
type: 'ping';
|
|
}
|
|
| {
|
|
type: 'speech-transcription';
|
|
requestId: string;
|
|
mimeType: string;
|
|
audioBase64: string;
|
|
};
|
|
|
|
type ServerMessage =
|
|
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
|
|
| { type: 'peer-joined'; peer: PublicUser }
|
|
| { type: 'peer-left'; peerId: string }
|
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
|
| {
|
|
type: 'image-generated';
|
|
requestId: string;
|
|
peerId: string;
|
|
prompt: string;
|
|
createdAt: number;
|
|
mimeType: string;
|
|
imageBase64: string;
|
|
}
|
|
| {
|
|
type: 'image-generation-error';
|
|
requestId: string;
|
|
peerId: string;
|
|
message: string;
|
|
}
|
|
| {
|
|
type: 'speech-transcribed';
|
|
requestId: string;
|
|
text: string;
|
|
}
|
|
| {
|
|
type: 'speech-transcription-error';
|
|
requestId: string;
|
|
message: string;
|
|
}
|
|
| { type: 'pong' }
|
|
| { type: 'error'; message: string };
|
|
|
|
type StoredCredentials = {
|
|
passwordHash: string;
|
|
messageEncryptionKey?: string;
|
|
};
|
|
|
|
type StoredAccessKey = {
|
|
credentialId: string;
|
|
publicKey: string;
|
|
counter: number;
|
|
transports: string[];
|
|
deviceType: string;
|
|
backedUp: boolean;
|
|
aaguid: string;
|
|
};
|
|
|
|
type AccessKeySummary = {
|
|
id: string;
|
|
credentialId: string;
|
|
label: string;
|
|
transports: string[];
|
|
deviceType: string;
|
|
backedUp: boolean;
|
|
aaguid: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type WebBundleFileEntry = {
|
|
path: string;
|
|
size: number;
|
|
sha256: string;
|
|
lastModified: string;
|
|
contentType: string;
|
|
href: string;
|
|
};
|
|
|
|
type WebBundleManifest = {
|
|
bundleId: string;
|
|
generatedAt: string;
|
|
indexPath: string;
|
|
files: WebBundleFileEntry[];
|
|
};
|
|
|
|
type PendingRegistration = {
|
|
challenge: string;
|
|
label: string;
|
|
expectedOrigin: string;
|
|
expectedRpId: string;
|
|
};
|
|
|
|
type PendingAuthentication = {
|
|
challenge: string;
|
|
expectedOrigin: string;
|
|
expectedRpId: string;
|
|
expectedUserId: string | null;
|
|
};
|
|
|
|
type WebRtcCandidate = {
|
|
candidate?: string;
|
|
sdpMid?: string | null;
|
|
sdpMLineIndex?: number | null;
|
|
usernameFragment?: string | null;
|
|
};
|
|
|
|
type WebRtcDescription = {
|
|
type: 'offer' | 'answer' | 'pranswer' | 'rollback';
|
|
sdp?: string;
|
|
};
|
|
|
|
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<RegistrationResponseJSON>(),
|
|
});
|
|
|
|
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<AuthenticationResponseJSON>(),
|
|
});
|
|
|
|
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 documentPreviewSchema = 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 execFileAsync = promisify(execFile);
|
|
|
|
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<string, Map<string, WebSocket>>();
|
|
|
|
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 as (
|
|
| 'ble'
|
|
| 'cable'
|
|
| 'hybrid'
|
|
| 'internal'
|
|
| 'nfc'
|
|
| 'smart-card'
|
|
| 'usb'
|
|
)[],
|
|
}))
|
|
: undefined,
|
|
});
|
|
|
|
await redis.set(
|
|
webAuthnAuthenticationKey(attemptId),
|
|
JSON.stringify({
|
|
challenge: options.challenge,
|
|
expectedOrigin,
|
|
expectedRpId,
|
|
expectedUserId: user?.id ?? null,
|
|
} satisfies PendingAuthentication),
|
|
'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 as (
|
|
| 'ble'
|
|
| 'cable'
|
|
| 'hybrid'
|
|
| 'internal'
|
|
| 'nfc'
|
|
| 'smart-card'
|
|
| 'usb'
|
|
)[],
|
|
},
|
|
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/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
|
const authContext = await authenticateRequest(request, reply);
|
|
|
|
if (!authContext) {
|
|
return;
|
|
}
|
|
|
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
|
|
|
if (!parsed.success) {
|
|
return reply.code(400).send({
|
|
message: 'Invalid document preview payload.',
|
|
issues: parsed.error.flatten(),
|
|
});
|
|
}
|
|
|
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
|
}
|
|
|
|
try {
|
|
const previewImageBuffer = await createDocumentPreviewImage(
|
|
parsed.data.fileName,
|
|
parsed.data.mimeType,
|
|
parsed.data.fileBase64,
|
|
);
|
|
|
|
return {
|
|
mimeType: 'image/png',
|
|
imageBase64: previewImageBuffer.toString('base64'),
|
|
};
|
|
} catch (error) {
|
|
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
|
return reply.code(422).send({
|
|
message: describeDocumentPreviewFailure(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 as (
|
|
| 'ble'
|
|
| 'cable'
|
|
| 'hybrid'
|
|
| 'internal'
|
|
| 'nfc'
|
|
| 'smart-card'
|
|
| 'usb'
|
|
)[],
|
|
})),
|
|
});
|
|
|
|
await redis.set(
|
|
webAuthnRegistrationKey(authContext.session.sessionId),
|
|
JSON.stringify({
|
|
challenge: options.challenge,
|
|
label,
|
|
expectedOrigin,
|
|
expectedRpId,
|
|
} satisfies PendingRegistration),
|
|
'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: WebSocket, request: FastifyRequest): Promise<void> {
|
|
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<string, WebSocket>();
|
|
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: WebSocket.RawData) => {
|
|
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: string,
|
|
sessionId: string,
|
|
socket: WebSocket,
|
|
rawMessage: string,
|
|
): Promise<void> {
|
|
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: UserRecord, sessionId: string) {
|
|
return {
|
|
token: app.jwt.sign({
|
|
sub: user.id,
|
|
username: user.username,
|
|
displayName: user.displayName,
|
|
sid: sessionId,
|
|
} satisfies AuthToken),
|
|
user: toPublicUser(user),
|
|
messageEncryptionKey: user.messageEncryptionKey,
|
|
};
|
|
}
|
|
|
|
async function authenticateRequest(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): Promise<AuthContext | null> {
|
|
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: string): Promise<AuthContext | null> {
|
|
let decoded: AuthToken;
|
|
|
|
try {
|
|
decoded = app.jwt.verify<AuthToken>(token);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return authenticateTokenFromSession(decoded.sub, decoded.sid, decoded);
|
|
}
|
|
|
|
async function authenticateTokenFromSession(
|
|
userId: string,
|
|
sessionId: string,
|
|
decoded?: AuthToken,
|
|
): Promise<AuthContext | null> {
|
|
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: string, fileBase64: string): Promise<Buffer> {
|
|
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 });
|
|
}
|
|
|
|
async function createDocumentPreviewImage(
|
|
fileName: string,
|
|
mimeType: string,
|
|
fileBase64: string,
|
|
): Promise<Buffer> {
|
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
|
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
|
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
|
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
|
|
|
return renderPdfFirstPageToPng(pdfBuffer);
|
|
}
|
|
|
|
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
|
|
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
|
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
|
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
|
const imagePath = `${outputBasePath}.png`;
|
|
|
|
try {
|
|
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
|
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
|
return await fs.promises.readFile(imagePath);
|
|
} finally {
|
|
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
|
|
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
|
|
|
if (inputBuffer.byteLength === 0) {
|
|
throw new Error(emptyMessage);
|
|
}
|
|
|
|
return inputBuffer;
|
|
}
|
|
|
|
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
|
|
if (isPdfFile(fileName, mimeType)) {
|
|
return true;
|
|
}
|
|
|
|
return isSupportedOfficeDocument(fileName, mimeType);
|
|
}
|
|
|
|
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
|
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 isPdfFile(fileName: string, mimeType: string): boolean {
|
|
const normalizedFileName = fileName.trim().toLowerCase();
|
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
|
|
|
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
|
}
|
|
|
|
function normalizeOfficeDocumentFileName(fileName: string): string {
|
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
|
}
|
|
|
|
function describeDocumentPreviewFailure(error: unknown): string {
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
|
}
|
|
|
|
if (error instanceof Error && error.message.trim()) {
|
|
return `Document preview generation failed: ${error.message}`;
|
|
}
|
|
|
|
return 'Document preview generation failed.';
|
|
}
|
|
|
|
function createUser(input: {
|
|
username: string;
|
|
displayName: string;
|
|
password: string;
|
|
isActive: boolean;
|
|
}): UserRecord {
|
|
const createdAt = new Date().toISOString();
|
|
const user: UserRecord = {
|
|
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<StoredCredentials>({
|
|
passwordHash: user.passwordHash,
|
|
messageEncryptionKey: user.messageEncryptionKey,
|
|
}),
|
|
user.isActive ? 1 : 0,
|
|
user.approvedAt,
|
|
user.createdAt,
|
|
);
|
|
|
|
return user;
|
|
}
|
|
|
|
function listPendingApprovalUsers(): PendingApprovalUser[] {
|
|
const rows = selectPendingUsersStatement.all() as DatabaseUserRow[];
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
username: row.username,
|
|
displayName: row.display_name,
|
|
createdAt: row.created_at,
|
|
}));
|
|
}
|
|
|
|
function listAdminUsers(): AdminUserSummary[] {
|
|
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
|
|
|
|
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: string): UserRecord | null {
|
|
const approvedAt = new Date().toISOString();
|
|
const result = approveUserStatement.run(approvedAt, userId);
|
|
|
|
if (result.changes === 0) {
|
|
return null;
|
|
}
|
|
|
|
return findUserById(userId);
|
|
}
|
|
|
|
async function deleteUserAccount(userId: string): Promise<UserRecord | null> {
|
|
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: string,
|
|
input: {
|
|
credentialId: string;
|
|
label: string;
|
|
publicKey: string;
|
|
counter: number;
|
|
transports: string[];
|
|
deviceType: string;
|
|
backedUp: boolean;
|
|
aaguid: string;
|
|
},
|
|
): void {
|
|
createAccessKeyStatement.run(
|
|
crypto.randomUUID(),
|
|
userId,
|
|
input.credentialId,
|
|
input.label,
|
|
encryptJson<StoredAccessKey>({
|
|
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: {
|
|
row: DatabaseAccessKeyRow;
|
|
user: UserRecord;
|
|
registration: StoredAccessKey;
|
|
},
|
|
changes: {
|
|
counter: number;
|
|
deviceType: string;
|
|
backedUp: boolean;
|
|
},
|
|
): void {
|
|
const nextRegistration: StoredAccessKey = {
|
|
...storedAccessKey.registration,
|
|
counter: changes.counter,
|
|
deviceType: changes.deviceType,
|
|
backedUp: changes.backedUp,
|
|
};
|
|
|
|
updateAccessKeyStatement.run(
|
|
encryptJson<StoredAccessKey>(nextRegistration),
|
|
storedAccessKey.row.credential_id,
|
|
);
|
|
}
|
|
|
|
function findUserByUsername(username: string): UserRecord | null {
|
|
const row = selectUserByUsernameStatement.get(username) as DatabaseUserRow | undefined;
|
|
|
|
return row ? hydrateUser(row) : null;
|
|
}
|
|
|
|
function findUserById(userId: string): UserRecord | null {
|
|
const row = selectUserByIdStatement.get(userId) as DatabaseUserRow | undefined;
|
|
|
|
return row ? hydrateUser(row) : null;
|
|
}
|
|
|
|
function hydrateUser(row: DatabaseUserRow): UserRecord {
|
|
const credentials = decryptJson<StoredCredentials>(row.encrypted_credentials);
|
|
const messageEncryptionKey = credentials.messageEncryptionKey ?? generateUserMessageEncryptionKey();
|
|
|
|
if (!credentials.messageEncryptionKey) {
|
|
updateUserCredentialsStatement.run(
|
|
encryptJson<StoredCredentials>({
|
|
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: string): StoredAccessKey[] {
|
|
const rows = selectAccessKeysByUserStatement.all(userId) as DatabaseAccessKeyRow[];
|
|
|
|
return rows.map((row) => decryptJson<StoredAccessKey>(row.encrypted_registration));
|
|
}
|
|
|
|
function findStoredAccessKeyByCredentialId(credentialId: string): {
|
|
row: DatabaseAccessKeyRow;
|
|
user: UserRecord;
|
|
registration: StoredAccessKey;
|
|
} | null {
|
|
const row = selectAccessKeyByCredentialIdStatement.get(credentialId) as DatabaseAccessKeyRow | undefined;
|
|
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
|
|
const user = findUserById(row.user_id);
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
row,
|
|
user,
|
|
registration: decryptJson<StoredAccessKey>(row.encrypted_registration),
|
|
};
|
|
}
|
|
|
|
function listAccessKeys(userId: string): AccessKeySummary[] {
|
|
const rows = selectAccessKeysByUserStatement.all(userId) as DatabaseAccessKeyRow[];
|
|
|
|
return rows.map((row) => {
|
|
const registration = decryptJson<StoredAccessKey>(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: string, createValue: () => string): string {
|
|
const row = selectSecretStatement.get(name) as { encrypted_value: string } | undefined;
|
|
|
|
if (row) {
|
|
return decryptText(row.encrypted_value);
|
|
}
|
|
|
|
const value = createValue();
|
|
insertSecretStatement.run(name, encryptText(value), new Date().toISOString());
|
|
return value;
|
|
}
|
|
|
|
async function createSession(userId: string): Promise<SessionRecord> {
|
|
const session: SessionRecord = {
|
|
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: string): Promise<SessionRecord | null> {
|
|
const payload = await redis.get(sessionKey(sessionId));
|
|
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(payload) as SessionRecord;
|
|
}
|
|
|
|
async function destroySession(sessionId: string): Promise<void> {
|
|
await redis.del(sessionKey(sessionId));
|
|
}
|
|
|
|
async function destroyUserSessions(userId: string): Promise<void> {
|
|
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: SessionRecord | null = null;
|
|
|
|
try {
|
|
session = JSON.parse(payload) as SessionRecord;
|
|
} 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: string): string {
|
|
return `privatechat:session:${sessionId}`;
|
|
}
|
|
|
|
function webAuthnRegistrationKey(sessionId: string): string {
|
|
return `privatechat:webauthn-registration:${sessionId}`;
|
|
}
|
|
|
|
function webAuthnAuthenticationKey(attemptId: string): string {
|
|
return `privatechat:webauthn-authentication:${attemptId}`;
|
|
}
|
|
|
|
async function getPendingRegistration(sessionId: string): Promise<PendingRegistration | null> {
|
|
const payload = await redis.get(webAuthnRegistrationKey(sessionId));
|
|
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(payload) as PendingRegistration;
|
|
}
|
|
|
|
async function clearPendingRegistration(sessionId: string): Promise<void> {
|
|
await redis.del(webAuthnRegistrationKey(sessionId));
|
|
}
|
|
|
|
async function getPendingAuthentication(attemptId: string): Promise<PendingAuthentication | null> {
|
|
const payload = await redis.get(webAuthnAuthenticationKey(attemptId));
|
|
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(payload) as PendingAuthentication;
|
|
}
|
|
|
|
async function clearPendingAuthentication(attemptId: string): Promise<void> {
|
|
await redis.del(webAuthnAuthenticationKey(attemptId));
|
|
}
|
|
|
|
function closeSocketSession(userId: string, sessionId: string): void {
|
|
const socket = socketsByUserId.get(userId)?.get(sessionId);
|
|
|
|
if (socket && socket.readyState < 2) {
|
|
socket.close();
|
|
}
|
|
}
|
|
|
|
function detachSocket(userId: string, sessionId: string): boolean {
|
|
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: string): PublicUser[] {
|
|
const peers: PublicUser[] = [];
|
|
|
|
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: string, payload: ServerMessage): void {
|
|
for (const [peerId, sockets] of socketsByUserId.entries()) {
|
|
if (peerId === userId) {
|
|
continue;
|
|
}
|
|
|
|
for (const socket of sockets.values()) {
|
|
send(socket, payload);
|
|
}
|
|
}
|
|
}
|
|
|
|
function send(socket: WebSocket, payload: ServerMessage): void {
|
|
if (socket.readyState === 1) {
|
|
socket.send(JSON.stringify(payload));
|
|
}
|
|
}
|
|
|
|
function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|
let payload: unknown;
|
|
|
|
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: string, audioBase64: string, mimeType: string): Promise<string> {
|
|
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
|
}
|
|
|
|
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
|
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() as {
|
|
error?: { message?: string } | string;
|
|
data?: Array<{ b64_json?: string }>;
|
|
};
|
|
|
|
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: Buffer): string {
|
|
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(): WebBundleManifest | null {
|
|
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),
|
|
} satisfies WebBundleFileEntry;
|
|
});
|
|
|
|
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: string): {
|
|
absolutePath: string;
|
|
contentType: string;
|
|
size: number;
|
|
lastModifiedMs: number;
|
|
etag: string;
|
|
} | null {
|
|
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: string): string[] {
|
|
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: string): string {
|
|
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
|
|
}
|
|
|
|
function toBundleRelativePath(inputPath: string): string {
|
|
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
|
|
}
|
|
|
|
function detectBundleContentType(assetPath: string): string {
|
|
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: string | string[] | undefined, etag: string): boolean {
|
|
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: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
|
|
if (signal.type === 'sdp') {
|
|
return {
|
|
type: 'sdp',
|
|
description: signal.description as WebRtcDescription,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'ice-candidate',
|
|
candidate: signal.candidate as WebRtcCandidate,
|
|
};
|
|
}
|
|
|
|
function extractBearerToken(authorizationHeader?: string): string | null {
|
|
if (!authorizationHeader?.startsWith('Bearer ')) {
|
|
return null;
|
|
}
|
|
|
|
return authorizationHeader.slice('Bearer '.length).trim();
|
|
}
|
|
|
|
function toPublicUser(user: UserRecord): PublicUser {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
displayName: user.displayName,
|
|
};
|
|
}
|
|
|
|
function isApprovalAdmin(user: UserRecord): boolean {
|
|
return user.username === approvalAdminUsername;
|
|
}
|
|
|
|
function hashPassword(password: string): string {
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
const derived = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
|
|
return `${salt}:${derived}`;
|
|
}
|
|
|
|
function verifyPassword(password: string, storedHash: string): boolean {
|
|
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(): string {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
function encryptJson<T>(value: T): string {
|
|
return encryptText(JSON.stringify(value));
|
|
}
|
|
|
|
function decryptJson<T>(payload: string): T {
|
|
return JSON.parse(decryptText(payload)) as T;
|
|
}
|
|
|
|
function encryptText(value: string): string {
|
|
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: string): string {
|
|
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: string): string {
|
|
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(): void {
|
|
const rows = database.prepare('PRAGMA table_info(users)').all() as DatabaseTableColumnRow[];
|
|
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: string): Buffer {
|
|
return crypto.createHash('sha256').update(masterKeyMaterial).digest();
|
|
}
|
|
|
|
function resolveStoragePath(targetPath: string): string {
|
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(process.cwd(), targetPath);
|
|
}
|
|
|
|
function resolveProjectPath(targetPath: string): string {
|
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
|
}
|
|
|
|
function parseAllowedOrigins(value: string | undefined): Set<string> {
|
|
if (!value) {
|
|
return new Set();
|
|
}
|
|
|
|
return new Set(
|
|
value
|
|
.split(',')
|
|
.map((origin) => normalizeOrigin(origin))
|
|
.filter((origin) => origin.length > 0 && origin !== 'null'),
|
|
);
|
|
}
|
|
|
|
function normalizeOrigin(origin: string): string {
|
|
const trimmed = origin.trim();
|
|
|
|
if (trimmed === 'null') {
|
|
return trimmed;
|
|
}
|
|
|
|
return trimmed.replace(/\/+$/, '');
|
|
}
|
|
|
|
function isAllowedRequestOrigin(originHeader: string | undefined): boolean {
|
|
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: FastifyRequest): string {
|
|
const originHeader = request.headers.origin;
|
|
|
|
if (typeof originHeader === 'string' && originHeader.length > 0) {
|
|
return originHeader;
|
|
}
|
|
|
|
return webAuthnOrigin;
|
|
}
|
|
|
|
function resolveWebAuthnUserVerification(
|
|
value: string | undefined,
|
|
): 'discouraged' | 'preferred' | 'required' {
|
|
switch (value?.toLowerCase()) {
|
|
case 'discouraged':
|
|
return 'discouraged';
|
|
case 'required':
|
|
return 'required';
|
|
default:
|
|
return 'preferred';
|
|
}
|
|
}
|
|
|
|
function bytesToBase64Url(value: Uint8Array): string {
|
|
return Buffer.from(value).toString('base64url');
|
|
}
|
|
|
|
function defaultAccessKeyLabel(): string {
|
|
return `Access key ${new Date().toISOString().replace('T', ' ').slice(0, 16)}`;
|
|
}
|