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';
|
|
|
|
|
import { 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 { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
|
|
|
import Fastify from 'fastify';
|
|
|
|
|
import { Redis } from 'ioredis';
|
|
|
|
|
import { z } from 'zod';
|
|
|
|
|
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-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-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-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);
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
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-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,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
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-09 19:35:08 +01:00
|
|
|
return {
|
|
|
|
|
type: 'signal',
|
|
|
|
|
to: parsed.data.to,
|
|
|
|
|
signal: normalizeSignal(parsed.data.signal),
|
|
|
|
|
};
|
|
|
|
|
}
|
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) {
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|