Many new functionalities
This commit is contained in:
261
server/dist/index.js
vendored
261
server/dist/index.js
vendored
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user