documents preview - image

This commit is contained in:
2026-03-11 09:40:03 +01:00
parent 0e4c79b735
commit 11cc5350c8
8 changed files with 176 additions and 140 deletions

72
server/dist/index.js vendored
View File

@@ -1,5 +1,7 @@
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify, TextEncoder } from 'node:util';
@@ -48,7 +50,7 @@ const adminDeleteUserParamsSchema = z.object({
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const officePreviewSchema = z.object({
const documentPreviewSchema = z.object({
fileName: z.string().trim().min(1).max(256),
mimeType: z.string().trim().min(1).max(256),
fileBase64: z.string().min(1).max(96_000_000),
@@ -118,6 +120,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEB
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
@@ -469,32 +472,32 @@ 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) => {
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = officePreviewSchema.safeParse(request.body);
const parsed = documentPreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid office preview payload.',
message: 'Invalid document 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.' });
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
return {
mimeType: 'application/pdf',
pdfBase64: pdfBuffer.toString('base64'),
mimeType: 'image/png',
imageBase64: previewImageBuffer.toString('base64'),
};
}
catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
return reply.code(422).send({
message: describeOfficePreviewFailure(error),
message: describeDocumentPreviewFailure(error),
});
}
});
@@ -879,6 +882,40 @@ async function convertOfficeDocumentToPdf(fileName, fileBase64) {
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
}
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
const normalizedMimeType = mimeType.trim().toLowerCase();
const pdfBuffer = normalizedMimeType === 'application/pdf'
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
: await convertOfficeDocumentToPdf(fileName, fileBase64);
return renderPdfFirstPageToPng(pdfBuffer);
}
async function renderPdfFirstPageToPng(pdfBuffer) {
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
const pdfPath = path.join(tempDirectory, 'source.pdf');
const outputBasePath = path.join(tempDirectory, 'page-preview');
const imagePath = `${outputBasePath}.png`;
try {
await fs.promises.writeFile(pdfPath, pdfBuffer);
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
return await fs.promises.readFile(imagePath);
}
finally {
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
}
}
function decodeBase64File(fileBase64, emptyMessage) {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error(emptyMessage);
}
return inputBuffer;
}
function isSupportedPreviewDocument(fileName, mimeType) {
if (isPdfFile(fileName, mimeType)) {
return true;
}
return isSupportedOfficeDocument(fileName, mimeType);
}
function isSupportedOfficeDocument(fileName, mimeType) {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
@@ -889,17 +926,22 @@ function isSupportedOfficeDocument(fileName, mimeType) {
}
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
}
function isPdfFile(fileName, mimeType) {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
}
function normalizeOfficeDocumentFileName(fileName) {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeOfficePreviewFailure(error) {
function describeDocumentPreviewFailure(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.';
return 'Document preview generation failed because a required conversion tool is missing on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Office preview generation failed: ${error.message}`;
return `Document preview generation failed: ${error.message}`;
}
return 'Office preview generation failed.';
return 'Document preview generation failed.';
}
function createUser(input) {
const createdAt = new Date().toISOString();

View File

@@ -1,5 +1,7 @@
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify, TextEncoder } from 'node:util';
@@ -272,7 +274,7 @@ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const officePreviewSchema = z.object({
const documentPreviewSchema = z.object({
fileName: z.string().trim().min(1).max(256),
mimeType: z.string().trim().min(1).max(256),
fileBase64: z.string().min(1).max(96_000_000),
@@ -354,6 +356,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const execFileAsync = promisify(execFile);
const speechTranscriber = new SpeechTranscriber(
{
@@ -803,37 +806,41 @@ app.get('/api/auth/session', async (request, reply) => {
};
});
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = officePreviewSchema.safeParse(request.body);
const parsed = documentPreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid office preview payload.',
message: 'Invalid document 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.' });
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
const previewImageBuffer = await createDocumentPreviewImage(
parsed.data.fileName,
parsed.data.mimeType,
parsed.data.fileBase64,
);
return {
mimeType: 'application/pdf',
pdfBase64: pdfBuffer.toString('base64'),
mimeType: 'image/png',
imageBase64: previewImageBuffer.toString('base64'),
};
} catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
return reply.code(422).send({
message: describeOfficePreviewFailure(error),
message: describeDocumentPreviewFailure(error),
});
}
});
@@ -1348,6 +1355,52 @@ async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string):
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
}
async function createDocumentPreviewImage(
fileName: string,
mimeType: string,
fileBase64: string,
): Promise<Buffer> {
const normalizedMimeType = mimeType.trim().toLowerCase();
const pdfBuffer = normalizedMimeType === 'application/pdf'
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
: await convertOfficeDocumentToPdf(fileName, fileBase64);
return renderPdfFirstPageToPng(pdfBuffer);
}
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
const pdfPath = path.join(tempDirectory, 'source.pdf');
const outputBasePath = path.join(tempDirectory, 'page-preview');
const imagePath = `${outputBasePath}.png`;
try {
await fs.promises.writeFile(pdfPath, pdfBuffer);
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
return await fs.promises.readFile(imagePath);
} finally {
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
}
}
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error(emptyMessage);
}
return inputBuffer;
}
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
if (isPdfFile(fileName, mimeType)) {
return true;
}
return isSupportedOfficeDocument(fileName, mimeType);
}
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
@@ -1363,20 +1416,27 @@ function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
}
function isPdfFile(fileName: string, mimeType: string): boolean {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
}
function normalizeOfficeDocumentFileName(fileName: string): string {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeOfficePreviewFailure(error: unknown): string {
function describeDocumentPreviewFailure(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.';
return 'Document preview generation failed because a required conversion tool is missing on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Office preview generation failed: ${error.message}`;
return `Document preview generation failed: ${error.message}`;
}
return 'Office preview generation failed.';
return 'Document preview generation failed.';
}
function createUser(input: {