documents preview - image
This commit is contained in:
@@ -235,17 +235,14 @@
|
|||||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (hasPdfPreview(entry)) {
|
@if (hasDocumentPreviewImage(entry)) {
|
||||||
<div class="bubble-preview">
|
<div class="bubble-preview">
|
||||||
<div class="bubble-preview-label">Preview</div>
|
<div class="bubble-preview-label">Preview</div>
|
||||||
@defer (on viewport) {
|
<img
|
||||||
<app-office-pdf-preview
|
class="bubble-preview-image"
|
||||||
[src]="pdfPreviewUrl(entry)!"
|
[src]="documentPreviewImageUrl(entry)"
|
||||||
[fileName]="entry.fileName ?? 'document.pdf'"
|
[alt]="entry.fileName || 'Document preview'"
|
||||||
></app-office-pdf-preview>
|
/>
|
||||||
} @placeholder {
|
|
||||||
<div class="bubble-preview-placeholder">Loading preview…</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -538,17 +538,14 @@
|
|||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-preview-placeholder {
|
.bubble-preview-image {
|
||||||
display: grid;
|
display: block;
|
||||||
place-items: center;
|
|
||||||
width: min(240px, 100%);
|
width: min(240px, 100%);
|
||||||
min-height: 320px;
|
max-width: 100%;
|
||||||
padding: 1rem;
|
height: auto;
|
||||||
border: 1px dashed var(--input-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
color: var(--page-text-soft);
|
background: #fff;
|
||||||
background: color-mix(in srgb, var(--surface-background) 85%, white);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-image,
|
.bubble-image,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, sig
|
|||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { OfficePdfPreviewComponent } from './office-pdf-preview.component';
|
|
||||||
|
|
||||||
import { PeerCallModalComponent } from './peer-call-modal.component';
|
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
@@ -17,7 +16,6 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
JsonFileViewerComponent,
|
JsonFileViewerComponent,
|
||||||
OfficePdfPreviewComponent,
|
|
||||||
PeerCallModalComponent,
|
PeerCallModalComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
@@ -646,31 +644,20 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPdfPreview(entry: ChatEntry): boolean {
|
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
||||||
return (
|
return (
|
||||||
entry.kind === 'file' &&
|
entry.kind === 'file' &&
|
||||||
(
|
!!entry.previewDownloadUrl &&
|
||||||
(
|
(entry.previewMimeType?.startsWith('image/') ?? false)
|
||||||
entry.previewMimeType === 'application/pdf' &&
|
|
||||||
!!entry.previewDownloadUrl
|
|
||||||
) ||
|
|
||||||
(
|
|
||||||
!!entry.downloadUrl &&
|
|
||||||
(
|
|
||||||
entry.fileMimeType === 'application/pdf' ||
|
|
||||||
entry.fileName?.toLowerCase().endsWith('.pdf') === true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfPreviewUrl(entry: ChatEntry): string | null {
|
documentPreviewImageUrl(entry: ChatEntry): string | null {
|
||||||
if (!this.hasPdfPreview(entry)) {
|
if (!this.hasDocumentPreviewImage(entry)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.previewDownloadUrl ?? entry.downloadUrl ?? null;
|
return entry.previewDownloadUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isPeerTyping(peerId: string): boolean {
|
isPeerTyping(peerId: string): boolean {
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ type PersistedChatEntryContent = {
|
|||||||
previewMimeType?: string;
|
previewMimeType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OfficePreviewResponse = {
|
type DocumentPreviewImageResponse = {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
pdfBase64: string;
|
imageBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEnv = {
|
type RuntimeEnv = {
|
||||||
@@ -1586,13 +1586,13 @@ export class ChatSessionService {
|
|||||||
let previewMimeType: string | undefined;
|
let previewMimeType: string | undefined;
|
||||||
let previewDownloadUrl: string | undefined;
|
let previewDownloadUrl: string | undefined;
|
||||||
|
|
||||||
if (transfer.kind === 'file' && this.isOfficeDocumentFile(transfer.name, transfer.mimeType)) {
|
if (transfer.kind === 'file' && this.isPreviewableDocumentFile(transfer.name, transfer.mimeType)) {
|
||||||
const officePreview = await this.generateOfficeDocumentPreview(transfer.name, blob);
|
const imagePreview = await this.generateDocumentPreviewImage(transfer.name, blob);
|
||||||
|
|
||||||
if (officePreview) {
|
if (imagePreview) {
|
||||||
previewBlob = officePreview.blob;
|
previewBlob = imagePreview.blob;
|
||||||
previewMimeType = officePreview.mimeType;
|
previewMimeType = imagePreview.mimeType;
|
||||||
previewDownloadUrl = URL.createObjectURL(officePreview.blob);
|
previewDownloadUrl = URL.createObjectURL(imagePreview.blob);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2287,7 +2287,7 @@ export class ChatSessionService {
|
|||||||
Uint8Array.from(entry.previewIv).buffer,
|
Uint8Array.from(entry.previewIv).buffer,
|
||||||
);
|
);
|
||||||
const previewBlob = new Blob([decryptedPreview], {
|
const previewBlob = new Blob([decryptedPreview], {
|
||||||
type: content.previewMimeType || 'application/pdf',
|
type: content.previewMimeType || 'image/png',
|
||||||
});
|
});
|
||||||
previewDownloadUrl = URL.createObjectURL(previewBlob);
|
previewDownloadUrl = URL.createObjectURL(previewBlob);
|
||||||
}
|
}
|
||||||
@@ -2344,7 +2344,7 @@ export class ChatSessionService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const previewBlob = entry.previewBlob
|
const previewBlob = entry.previewBlob
|
||||||
? new Blob([await entry.previewBlob.arrayBuffer()], {
|
? new Blob([await entry.previewBlob.arrayBuffer()], {
|
||||||
type: entry.previewMimeType || 'application/pdf',
|
type: entry.previewMimeType || 'image/png',
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -2931,7 +2931,7 @@ export class ChatSessionService {
|
|||||||
return new Blob([bytes], { type: mimeType });
|
return new Blob([bytes], { type: mimeType });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateOfficeDocumentPreview(
|
private async generateDocumentPreviewImage(
|
||||||
fileName: string,
|
fileName: string,
|
||||||
fileBlob: Blob,
|
fileBlob: Blob,
|
||||||
): Promise<{ blob: Blob; mimeType: string } | null> {
|
): Promise<{ blob: Blob; mimeType: string } | null> {
|
||||||
@@ -2943,8 +2943,8 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.post<OfficePreviewResponse>(
|
this.http.post<DocumentPreviewImageResponse>(
|
||||||
`${this.serverUrl()}/api/files/office-preview`,
|
`${this.serverUrl()}/api/files/document-preview-image`,
|
||||||
{
|
{
|
||||||
fileName,
|
fileName,
|
||||||
mimeType: fileBlob.type || 'application/octet-stream',
|
mimeType: fileBlob.type || 'application/octet-stream',
|
||||||
@@ -2957,15 +2957,19 @@ export class ChatSessionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blob: this.base64ToBlob(response.pdfBase64, response.mimeType),
|
blob: this.base64ToBlob(response.imageBase64, response.mimeType),
|
||||||
mimeType: response.mimeType,
|
mimeType: response.mimeType,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not generate office document preview.', error);
|
console.warn('Could not generate document preview image.', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPreviewableDocumentFile(fileName?: string, mimeType?: string): boolean {
|
||||||
|
return this.isOfficeDocumentFile(fileName, mimeType) || this.isPdfFile(fileName, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean {
|
private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean {
|
||||||
const normalizedName = fileName?.trim().toLowerCase() ?? '';
|
const normalizedName = fileName?.trim().toLowerCase() ?? '';
|
||||||
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
|
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
|
||||||
@@ -2981,6 +2985,13 @@ export class ChatSessionService {
|
|||||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedName);
|
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPdfFile(fileName?: string, mimeType?: string): boolean {
|
||||||
|
const normalizedName = fileName?.trim().toLowerCase() ?? '';
|
||||||
|
const normalizedMimeType = mimeType?.trim().toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return normalizedMimeType === 'application/pdf' || normalizedName.endsWith('.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
private fileExtensionForMimeType(mimeType: string): string {
|
private fileExtensionForMimeType(mimeType: string): string {
|
||||||
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: min(240px, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.office-pdf-preview-shell {
|
|
||||||
width: min(240px, 100%);
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-office-pdf-preview',
|
|
||||||
imports: [NgxExtendedPdfViewerModule],
|
|
||||||
template: `
|
|
||||||
<div class="office-pdf-preview-shell">
|
|
||||||
<ngx-extended-pdf-viewer
|
|
||||||
[src]="src"
|
|
||||||
[filenameForDownload]="fileName"
|
|
||||||
[height]="'320px'"
|
|
||||||
[page]="1"
|
|
||||||
[zoom]="'page-width'"
|
|
||||||
[showToolbar]="false"
|
|
||||||
[showSidebarButton]="false"
|
|
||||||
[showFindButton]="false"
|
|
||||||
[showPagingButtons]="false"
|
|
||||||
[showPageNumber]="false"
|
|
||||||
[showPageLabel]="false"
|
|
||||||
[showZoomButtons]="false"
|
|
||||||
[showZoomDropdown]="false"
|
|
||||||
[showPresentationModeButton]="false"
|
|
||||||
[showOpenFileButton]="false"
|
|
||||||
[showPrintButton]="false"
|
|
||||||
[showDownloadButton]="false"
|
|
||||||
[showSecondaryToolbarButton]="false"
|
|
||||||
[showRotateButton]="false"
|
|
||||||
[showRotateCwButton]="false"
|
|
||||||
[showRotateCcwButton]="false"
|
|
||||||
[showScrollingButtons]="false"
|
|
||||||
[showSpreadButton]="false"
|
|
||||||
[showPropertiesButton]="false"
|
|
||||||
[showHandToolButton]="false"
|
|
||||||
[showBorders]="false"
|
|
||||||
[textLayer]="false"
|
|
||||||
></ngx-extended-pdf-viewer>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styleUrl: './office-pdf-preview.component.scss',
|
|
||||||
})
|
|
||||||
export class OfficePdfPreviewComponent {
|
|
||||||
@Input({ required: true }) src!: string;
|
|
||||||
@Input() fileName = 'document.pdf';
|
|
||||||
}
|
|
||||||
72
server/dist/index.js
vendored
72
server/dist/index.js
vendored
@@ -1,5 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { promisify, TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
@@ -48,7 +50,7 @@ const adminDeleteUserParamsSchema = z.object({
|
|||||||
const webBundleFileParamsSchema = z.object({
|
const webBundleFileParamsSchema = z.object({
|
||||||
'*': z.string().min(1),
|
'*': z.string().min(1),
|
||||||
});
|
});
|
||||||
const officePreviewSchema = z.object({
|
const documentPreviewSchema = z.object({
|
||||||
fileName: z.string().trim().min(1).max(256),
|
fileName: z.string().trim().min(1).max(256),
|
||||||
mimeType: z.string().trim().min(1).max(256),
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
fileBase64: z.string().min(1).max(96_000_000),
|
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 frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
const speechTranscriber = new SpeechTranscriber({
|
const speechTranscriber = new SpeechTranscriber({
|
||||||
serviceUrl: speechTranscriptionServiceUrl,
|
serviceUrl: speechTranscriptionServiceUrl,
|
||||||
language: speechTranscriptionLanguage,
|
language: speechTranscriptionLanguage,
|
||||||
@@ -469,32 +472,32 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
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);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = officePreviewSchema.safeParse(request.body);
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
message: 'Invalid office preview payload.',
|
message: 'Invalid document preview payload.',
|
||||||
issues: parsed.error.flatten(),
|
issues: parsed.error.flatten(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
}
|
}
|
||||||
try {
|
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 {
|
return {
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'image/png',
|
||||||
pdfBase64: pdfBuffer.toString('base64'),
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (error) {
|
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({
|
return reply.code(422).send({
|
||||||
message: describeOfficePreviewFailure(error),
|
message: describeDocumentPreviewFailure(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -879,6 +882,40 @@ async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
|||||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
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) {
|
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||||
const normalizedFileName = fileName.trim().toLowerCase();
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||||
@@ -889,17 +926,22 @@ function isSupportedOfficeDocument(fileName, mimeType) {
|
|||||||
}
|
}
|
||||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
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) {
|
function normalizeOfficeDocumentFileName(fileName) {
|
||||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||||
}
|
}
|
||||||
function describeOfficePreviewFailure(error) {
|
function describeDocumentPreviewFailure(error) {
|
||||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
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()) {
|
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) {
|
function createUser(input) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { promisify, TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
@@ -272,7 +274,7 @@ const webBundleFileParamsSchema = z.object({
|
|||||||
'*': z.string().min(1),
|
'*': z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const officePreviewSchema = z.object({
|
const documentPreviewSchema = z.object({
|
||||||
fileName: z.string().trim().min(1).max(256),
|
fileName: z.string().trim().min(1).max(256),
|
||||||
mimeType: z.string().trim().min(1).max(256),
|
mimeType: z.string().trim().min(1).max(256),
|
||||||
fileBase64: z.string().min(1).max(96_000_000),
|
fileBase64: z.string().min(1).max(96_000_000),
|
||||||
@@ -354,6 +356,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
|||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
const speechTranscriber = new SpeechTranscriber(
|
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);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = officePreviewSchema.safeParse(request.body);
|
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
message: 'Invalid office preview payload.',
|
message: 'Invalid document preview payload.',
|
||||||
issues: parsed.error.flatten(),
|
issues: parsed.error.flatten(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||||
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
|
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 {
|
return {
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'image/png',
|
||||||
pdfBase64: pdfBuffer.toString('base64'),
|
imageBase64: previewImageBuffer.toString('base64'),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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({
|
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 });
|
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 {
|
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||||
const normalizedFileName = fileName.trim().toLowerCase();
|
const normalizedFileName = fileName.trim().toLowerCase();
|
||||||
const normalizedMimeType = mimeType.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);
|
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 {
|
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
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') {
|
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()) {
|
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: {
|
function createUser(input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user