Many new functionalities

This commit is contained in:
2026-03-10 02:49:27 +01:00
parent 640d92d231
commit 61612b52d3
15 changed files with 1920 additions and 97 deletions

261
server/dist/index.js vendored
View File

@@ -40,6 +40,9 @@ const verifyAccessKeyAuthenticationSchema = z.object({
const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1),
});
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -66,15 +69,30 @@ const signalMessageSchema = z.discriminatedUnion('type', [
}),
]),
}),
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'),
}),
]);
const app = Fastify({ logger: true });
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 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);
@@ -134,6 +152,11 @@ const selectPendingUsersStatement = database.prepare(`
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 = ?
@@ -168,18 +191,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
FROM webauthn_credentials
WHERE credential_id = ?
`);
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials
SET encrypted_registration = ?
WHERE credential_id = ?
`);
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
const socketsByUserId = new Map();
await redis.ping();
await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
});
await app.register(jwt, {
secret: jwtSecret,
@@ -405,6 +440,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
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) {
@@ -526,6 +596,11 @@ const port = Number(process.env.PORT ?? 16990);
await app.listen({ port, host: '0.0.0.0' });
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket, request) {
if (!isAllowedRequestOrigin(request.headers.origin)) {
send(socket, { type: 'error', message: 'Origin not allowed.' });
socket.close();
return;
}
const query = wsQuerySchema.safeParse(request.query);
if (!query.success) {
send(socket, { type: 'error', message: 'Missing token.' });
@@ -574,6 +649,34 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
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;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
if (recipientSockets) {
@@ -683,6 +786,17 @@ function listPendingApprovalUsers() {
createdAt: row.created_at,
}));
}
function listAdminUsers() {
const rows = selectAllUsersStatement.all();
return rows.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
isActive: row.is_active === 1,
createdAt: row.created_at,
approvedAt: row.approved_at,
}));
}
function approveUser(userId) {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
@@ -691,6 +805,19 @@ function approveUser(userId) {
}
return findUserById(userId);
}
async function deleteUserAccount(userId) {
const user = findUserById(userId);
if (!user) {
return null;
}
deleteAccessKeysByUserStatement.run(userId);
const result = deleteUserStatement.run(userId);
if (result.changes === 0) {
return null;
}
await destroyUserSessions(userId);
return user;
}
function persistAccessKey(userId, input) {
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
credentialId: input.credentialId,
@@ -802,6 +929,32 @@ async function getSession(sessionId) {
async function destroySession(sessionId) {
await redis.del(sessionKey(sessionId));
}
async function destroyUserSessions(userId) {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
cursor = nextCursor;
for (const key of keys) {
const payload = await redis.get(key);
if (!payload) {
continue;
}
let session = null;
try {
session = JSON.parse(payload);
}
catch {
session = null;
}
if (!session || session.userId !== userId) {
continue;
}
await destroySession(session.sessionId);
await clearPendingRegistration(session.sessionId);
closeSocketSession(userId, session.sessionId);
}
} while (cursor !== '0');
}
function sessionKey(sessionId) {
return `privatechat:session:${sessionId}`;
}
@@ -889,12 +1042,87 @@ function parseClientMessage(rawMessage) {
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,
};
}
return {
type: 'signal',
to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal),
};
}
async function generateImageFromPrompt(prompt) {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
try {
const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: ollamaImageModel,
prompt,
size: ollamaImageSize,
response_format: 'b64_json',
n: 1,
}),
signal: abortController.signal,
});
const payload = await response.json();
if (!response.ok) {
const errorMessage = typeof payload.error === 'string'
? payload.error
: payload.error?.message;
throw new Error(errorMessage || 'Ollama image generation request failed.');
}
const imageBase64 = payload.data?.[0]?.b64_json?.trim();
if (!imageBase64) {
throw new Error('Ollama did not return image data.');
}
return {
imageBase64,
mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')),
};
}
catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Ollama image generation timed out.');
}
throw error;
}
finally {
clearTimeout(timeoutId);
}
}
function inferImageMimeType(imageBuffer) {
if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return 'image/png';
}
if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) {
return 'image/jpeg';
}
if (imageBuffer.length >= 12 &&
imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP') {
return 'image/webp';
}
if (imageBuffer.length >= 6) {
const header = imageBuffer.subarray(0, 6).toString('ascii');
if (header === 'GIF87a' || header === 'GIF89a') {
return 'image/gif';
}
}
return 'application/octet-stream';
}
function normalizeSignal(signal) {
if (signal.type === 'sdp') {
return {
@@ -1001,6 +1229,35 @@ function resolveStoragePath(targetPath) {
function resolveProjectPath(targetPath) {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
}
function parseAllowedOrigins(value) {
if (!value) {
return new Set();
}
return new Set(value
.split(',')
.map((origin) => normalizeOrigin(origin))
.filter((origin) => origin.length > 0 && origin !== 'null'));
}
function normalizeOrigin(origin) {
const trimmed = origin.trim();
if (trimmed === 'null') {
return trimmed;
}
return trimmed.replace(/\/+$/, '');
}
function isAllowedRequestOrigin(originHeader) {
if (!originHeader) {
return true;
}
const origin = normalizeOrigin(originHeader);
if (origin === 'null') {
return true;
}
if (allowedCorsOrigins.size === 0) {
return true;
}
return allowedCorsOrigins.has(origin);
}
function resolveWebAuthnOrigin(request) {
const originHeader = request.headers.origin;
if (typeof originHeader === 'string' && originHeader.length > 0) {

View File

@@ -84,6 +84,15 @@ type PendingApprovalUser = {
createdAt: string;
};
type AdminUserSummary = {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
};
type DatabaseAccessKeyRow = {
id: string;
user_id: string;
@@ -97,17 +106,43 @@ type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
type ClientMessage = {
type: 'signal';
to: string;
signal: SignalPayload;
};
type ClientMessage =
| {
type: 'signal';
to: string;
signal: SignalPayload;
}
| {
type: 'image-generation';
requestId: string;
peerId: string;
prompt: string;
}
| {
type: 'ping';
};
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: 'pong' }
| { type: 'error'; message: string };
type StoredCredentials = {
@@ -194,6 +229,10 @@ const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1),
});
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -221,9 +260,18 @@ const signalMessageSchema = z.discriminatedUnion('type', [
}),
]),
}),
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'),
}),
]);
const app = Fastify({ logger: true });
const app = Fastify({ logger: true, trustProxy: true });
const approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
@@ -236,8 +284,14 @@ const masterKeyPath = resolveStoragePath(
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 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(
@@ -304,6 +358,11 @@ const selectPendingUsersStatement = database.prepare(`
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 = ?
@@ -338,11 +397,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
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');
@@ -351,8 +418,12 @@ const socketsByUserId = new Map<string, Map<string, WebSocket>>();
await redis.ping();
await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
});
await app.register(jwt, {
@@ -664,6 +735,53 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
};
});
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);
@@ -829,6 +947,12 @@ 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) {
@@ -901,6 +1025,37 @@ async function handleSocketMessage(
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;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
@@ -1056,6 +1211,19 @@ function listPendingApprovalUsers(): PendingApprovalUser[] {
}));
}
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);
@@ -1067,6 +1235,25 @@ function approveUser(userId: string): UserRecord | 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: {
@@ -1248,6 +1435,39 @@ 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}`;
}
@@ -1364,6 +1584,19 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
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,
};
}
return {
type: 'signal',
to: parsed.data.to,
@@ -1371,7 +1604,88 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
};
}
function normalizeSignal(signal: ClientMessage['signal']): SignalPayload {
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 normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
if (signal.type === 'sdp') {
return {
type: 'sdp',
@@ -1508,6 +1822,47 @@ 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;