documents preview
This commit is contained in:
68
server/dist/index.js
vendored
68
server/dist/index.js
vendored
@@ -2,13 +2,14 @@ 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 { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||
import Fastify from 'fastify';
|
||||
import { Redis } from 'ioredis';
|
||||
@@ -47,6 +48,11 @@ const adminDeleteUserParamsSchema = z.object({
|
||||
const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
const officePreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -111,6 +117,7 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
const speechTranscriber = new SpeechTranscriber({
|
||||
serviceUrl: speechTranscriptionServiceUrl,
|
||||
language: speechTranscriptionLanguage,
|
||||
@@ -462,6 +469,35 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||
};
|
||||
});
|
||||
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
const parsed = officePreviewSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid office preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
try {
|
||||
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
|
||||
return {
|
||||
mimeType: 'application/pdf',
|
||||
pdfBase64: pdfBuffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeOfficePreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
@@ -835,6 +871,36 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
|
||||
},
|
||||
};
|
||||
}
|
||||
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||
return true;
|
||||
}
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
function normalizeOfficeDocumentFileName(fileName) {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
function describeOfficePreviewFailure(error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Office preview generation failed because LibreOffice is not installed on the server.';
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Office preview generation failed: ${error.message}`;
|
||||
}
|
||||
return 'Office preview generation failed.';
|
||||
}
|
||||
function createUser(input) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const user = {
|
||||
|
||||
29
server/package-lock.json
generated
29
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -1002,6 +1003,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1536,6 +1543,19 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libreoffice-convert": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
|
||||
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.3",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2029,6 +2049,15 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import cors from '@fastify/cors';
|
||||
@@ -10,6 +10,7 @@ import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -271,6 +272,12 @@ const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
|
||||
const officePreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -346,6 +353,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
||||
);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
|
||||
const speechTranscriber = new SpeechTranscriber(
|
||||
{
|
||||
@@ -795,6 +803,41 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = officePreviewSchema.safeParse(request.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid office preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
|
||||
|
||||
return {
|
||||
mimeType: 'application/pdf',
|
||||
pdfBase64: pdfBuffer.toString('base64'),
|
||||
};
|
||||
} catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeOfficePreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
@@ -1294,6 +1337,48 @@ async function authenticateTokenFromSession(
|
||||
};
|
||||
}
|
||||
|
||||
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
|
||||
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
|
||||
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
|
||||
function describeOfficePreviewFailure(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Office preview generation failed because LibreOffice is not installed on the server.';
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Office preview generation failed: ${error.message}`;
|
||||
}
|
||||
|
||||
return 'Office preview generation failed.';
|
||||
}
|
||||
|
||||
function createUser(input: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
|
||||
Reference in New Issue
Block a user