diff --git a/client/package-lock.json b/client/package-lock.json index e921101..f302a8f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.8", + "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -5960,6 +5961,19 @@ "node": ">= 0.6" } }, + "node_modules/ngx-extended-pdf-viewer": { + "version": "25.6.4", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz", + "integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=17.0.0 <22.0.0", + "@angular/core": ">=17.0.0 <22.0.0" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", diff --git a/client/package.json b/client/package.json index 21f8a5a..ba41488 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "bootstrap": "^5.3.8", + "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 509f34c..54e90b4 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -129,7 +129,7 @@
-
+
@if (conversation().length === 0) {
No text messages yet. The chat page is ready as soon as the peer channel opens. @@ -145,6 +145,17 @@ > @if (entry.direction !== 'system') {
+ @if (isGeneratedImageEntry(entry)) { + + }
} @case ('voice') { diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index 059909d..0c4253d 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -527,6 +527,30 @@ font-weight: 600; } +.bubble-preview { + display: grid; + gap: 0.45rem; +} + +.bubble-preview-label { + font-size: 0.82rem; + font-weight: 600; + opacity: 0.78; +} + +.bubble-preview-placeholder { + display: grid; + place-items: center; + width: min(240px, 100%); + min-height: 320px; + padding: 1rem; + border: 1px dashed var(--input-border); + border-radius: 1rem; + color: var(--page-text-soft); + background: color-mix(in srgb, var(--surface-background) 85%, white); + text-align: center; +} + .bubble-image, .bubble-video { width: 200px; diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index 3dab7cd..43ff54b 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -3,6 +3,7 @@ import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, sig import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { OfficePdfPreviewComponent } from './office-pdf-preview.component'; import { PeerCallModalComponent } from './peer-call-modal.component'; import { ChatSessionService } from './chat-session.service'; @@ -11,7 +12,14 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models @Component({ selector: 'app-chat-page', - imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent], + imports: [ + CommonModule, + FormsModule, + RouterLink, + JsonFileViewerComponent, + OfficePdfPreviewComponent, + PeerCallModalComponent, + ], templateUrl: './chat-page.component.html', styleUrl: './chat-page.component.scss', }) @@ -37,12 +45,18 @@ export class ChatPageComponent implements OnDestroy { private dictationCompletionPromise: Promise | null = null; private resolveDictationCompletion: (() => void) | null = null; private dictationApplyToken = 0; + private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null; @ViewChild('callAudioElement') set callAudioElementRef(value: ElementRef | undefined) { this.callAudioElement = value; this.syncCallAudioSource(); } private callAudioElement?: ElementRef; + @ViewChild('conversationContainer') + set conversationContainerRef(value: ElementRef | undefined) { + this.conversationContainer = value; + } + private conversationContainer?: ElementRef; messageText = ''; readonly forwardingEntryId = signal(null); @@ -209,6 +223,32 @@ export class ChatPageComponent implements OnDestroy { this.remoteCallAudioStream(); this.syncCallAudioSource(); }); + + effect(() => { + const peerId = this.peerId(); + const entries = this.conversation(); + const snapshot = { + peerId, + length: entries.length, + lastEntryId: entries.at(-1)?.id ?? null, + }; + const previousSnapshot = this.lastConversationSnapshot; + + this.lastConversationSnapshot = snapshot; + + if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) { + return; + } + + const hasNewTailEntry = snapshot.length > previousSnapshot.length + || (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId); + + if (!hasNewTailEntry) { + return; + } + + this.scrollConversationToBottom(); + }); } ngOnDestroy(): void { @@ -546,6 +586,16 @@ export class ChatPageComponent implements OnDestroy { this.forwardingEntryId.set(null); } + async sendGeneratedImage(entry: ChatEntry): Promise { + const peerId = this.peerId(); + + if (!peerId) { + return; + } + + await this.session.sendGeneratedImageToPeer(entry, peerId); + } + async endVoiceCall(peerId: string): Promise { await this.session.endVoiceCall(peerId); } @@ -570,6 +620,10 @@ export class ChatPageComponent implements OnDestroy { return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); } + isGeneratedImageEntry(entry: ChatEntry): boolean { + return this.isImageEntry(entry) && entry.generatedByAi === true; + } + isVideoEntry(entry: ChatEntry): boolean { if (entry.kind !== 'file' || !entry.downloadUrl) { return false; @@ -592,6 +646,33 @@ export class ChatPageComponent implements OnDestroy { ); } + hasPdfPreview(entry: ChatEntry): boolean { + return ( + entry.kind === 'file' && + ( + ( + entry.previewMimeType === 'application/pdf' && + !!entry.previewDownloadUrl + ) || + ( + !!entry.downloadUrl && + ( + entry.fileMimeType === 'application/pdf' || + entry.fileName?.toLowerCase().endsWith('.pdf') === true + ) + ) + ) + ); + } + + pdfPreviewUrl(entry: ChatEntry): string | null { + if (!this.hasPdfPreview(entry)) { + return null; + } + + return entry.previewDownloadUrl ?? entry.downloadUrl ?? null; + } + isPeerTyping(peerId: string): boolean { return this.session.typingPeerIds().includes(peerId); } @@ -804,4 +885,18 @@ export class ChatPageComponent implements OnDestroy { audio.pause(); audio.srcObject = null; } + + private scrollConversationToBottom(): void { + const container = this.conversationContainer?.nativeElement; + + if (!container) { + return; + } + + queueMicrotask(() => { + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight; + }); + }); + } } diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index ae13bd3..4fbd9b4 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -57,12 +57,15 @@ type LegacyPersistedChatEntry = { kind: Exclude; createdAt: number; authorLabel: string; + generatedByAi?: boolean; text?: string; payload?: unknown; fileName?: string; fileSize?: number; fileMimeType?: string; fileBlob?: Blob; + previewMimeType?: string; + previewBlob?: Blob; }; type EncryptedPersistedChatEntry = { @@ -78,17 +81,26 @@ type EncryptedPersistedChatEntry = { payloadIv: number[]; encryptedFileBlob?: PersistedBinary; fileIv?: number[]; + encryptedPreviewBlob?: PersistedBinary; + previewIv?: number[]; }; type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry; type PersistedChatEntryContent = { authorLabel: string; + generatedByAi?: boolean; text?: string; payload?: unknown; fileName?: string; fileSize?: number; fileMimeType?: string; + previewMimeType?: string; +}; + +type OfficePreviewResponse = { + mimeType: string; + pdfBase64: string; }; type RuntimeEnv = { @@ -669,6 +681,31 @@ export class ChatSessionService { } } + async sendGeneratedImageToPeer(entry: ChatEntry, targetPeerId: string): Promise { + if (entry.kind !== 'file' || !entry.generatedByAi || !entry.downloadUrl) { + this.error.set('This image is not available to send.'); + return; + } + + const channel = await this.ensureOpenChannel(targetPeerId); + + if (!channel) { + return; + } + + try { + const response = await fetch(entry.downloadUrl); + const blob = await response.blob(); + const file = new File([blob], entry.fileName || 'generated-image', { + type: entry.fileMimeType || blob.type || 'application/octet-stream', + }); + + await this.sendFile(targetPeerId, file, 'file'); + } catch { + this.error.set('Could not send this generated image.'); + } + } + private async authenticate(path: string, payload: Record): Promise { this.error.set(null); this.notice.set(null); @@ -1055,6 +1092,7 @@ export class ChatSessionService { kind: 'file', createdAt: event.createdAt, authorLabel: 'You', + generatedByAi: true, text: pendingRequest?.prompt ?? event.prompt, fileName, fileSize: imageBlob.size, @@ -1503,7 +1541,7 @@ export class ChatSessionService { this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`); break; case 'file-complete': - this.finalizeIncomingFile(peerId, envelope.id); + void this.finalizeIncomingFile(peerId, envelope.id); break; case 'typing': this.setPeerTyping(peerId, envelope.active); @@ -1533,15 +1571,30 @@ export class ChatSessionService { transfer.receivedBytes += arrayBuffer.byteLength; } - private finalizeIncomingFile(peerId: string, transferId: string): void { + private async finalizeIncomingFile(peerId: string, transferId: string): Promise { const transfer = this.incomingFiles.get(peerId); if (!transfer || transfer.id !== transferId) { return; } + this.incomingFiles.delete(peerId); + const blob = new Blob(transfer.chunks, { type: transfer.mimeType }); const downloadUrl = URL.createObjectURL(blob); + let previewBlob: Blob | undefined; + let previewMimeType: string | undefined; + let previewDownloadUrl: string | undefined; + + if (transfer.kind === 'file' && this.isOfficeDocumentFile(transfer.name, transfer.mimeType)) { + const officePreview = await this.generateOfficeDocumentPreview(transfer.name, blob); + + if (officePreview) { + previewBlob = officePreview.blob; + previewMimeType = officePreview.mimeType; + previewDownloadUrl = URL.createObjectURL(officePreview.blob); + } + } this.pushMessage({ id: transfer.id, @@ -1554,9 +1607,9 @@ export class ChatSessionService { fileSize: transfer.size, fileMimeType: transfer.mimeType, downloadUrl, - }, blob); - - this.incomingFiles.delete(peerId); + previewMimeType, + previewDownloadUrl, + }, blob, previewBlob); } private async flushPendingCandidates(bundle: PeerBundle): Promise { @@ -1867,7 +1920,7 @@ export class ChatSessionService { ); } - private pushMessage(entry: ChatEntry, fileBlob?: Blob): void { + private pushMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): void { this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt)); if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) { @@ -1875,7 +1928,7 @@ export class ChatSessionService { } if (entry.kind !== 'system') { - void this.persistMessage(entry, fileBlob); + void this.persistMessage(entry, fileBlob, previewBlob); } } @@ -2121,7 +2174,7 @@ export class ChatSessionService { } } - private async persistMessage(entry: ChatEntry, fileBlob?: Blob): Promise { + private async persistMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): Promise { const currentUserId = this.currentUser()?.id; const messageEncryptionKey = this.messageEncryptionKey; @@ -2134,15 +2187,20 @@ export class ChatSessionService { const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id); const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, { authorLabel: entry.authorLabel, + generatedByAi: entry.generatedByAi, text: entry.text, payload: entry.payload, fileName: entry.fileName, fileSize: entry.fileSize, fileMimeType: entry.fileMimeType, + previewMimeType: entry.previewMimeType, }); const encryptedFileBlob = fileBlob ? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer()) : null; + const encryptedPreviewBlob = previewBlob + ? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer()) + : null; const persistedEntry: EncryptedPersistedChatEntry = { storageKey, ownerUserId: currentUserId, @@ -2158,6 +2216,10 @@ export class ChatSessionService { ? this.serializePersistedBinary(encryptedFileBlob.ciphertext) : undefined, fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : undefined, + encryptedPreviewBlob: encryptedPreviewBlob + ? this.serializePersistedBinary(encryptedPreviewBlob.ciphertext) + : undefined, + previewIv: encryptedPreviewBlob ? Array.from(encryptedPreviewBlob.iv) : undefined, }; await this.queueMessageStoreOperation(storageKey, async () => { @@ -2204,6 +2266,7 @@ export class ChatSessionService { try { const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry); let downloadUrl: string | undefined; + let previewDownloadUrl: string | undefined; if (entry.encryptedFileBlob && entry.fileIv) { const decryptedFile = await this.decryptBinary( @@ -2217,6 +2280,18 @@ export class ChatSessionService { downloadUrl = URL.createObjectURL(fileBlob); } + if (entry.encryptedPreviewBlob && entry.previewIv) { + const decryptedPreview = await this.decryptBinary( + messageEncryptionKey, + this.deserializePersistedBinary(entry.encryptedPreviewBlob), + Uint8Array.from(entry.previewIv).buffer, + ); + const previewBlob = new Blob([decryptedPreview], { + type: content.previewMimeType || 'application/pdf', + }); + previewDownloadUrl = URL.createObjectURL(previewBlob); + } + return { id: entry.id, peerId: entry.peerId, @@ -2224,12 +2299,15 @@ export class ChatSessionService { kind: entry.kind, createdAt: entry.createdAt, authorLabel: content.authorLabel, + generatedByAi: content.generatedByAi, text: content.text, payload: content.payload, fileName: content.fileName, fileSize: content.fileSize, fileMimeType: content.fileMimeType, downloadUrl, + previewMimeType: content.previewMimeType, + previewDownloadUrl, }; } catch (error) { console.warn('Could not decrypt persisted chat message.', error); @@ -2245,12 +2323,15 @@ export class ChatSessionService { kind: entry.kind, createdAt: entry.createdAt, authorLabel: entry.authorLabel, + generatedByAi: entry.generatedByAi, text: entry.text, payload: entry.payload, fileName: entry.fileName, fileSize: entry.fileSize, fileMimeType: entry.fileMimeType, + previewMimeType: entry.previewMimeType, downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined, + previewDownloadUrl: entry.previewBlob ? URL.createObjectURL(entry.previewBlob) : undefined, }; } @@ -2261,8 +2342,13 @@ export class ChatSessionService { type: entry.fileMimeType || 'application/octet-stream', }) : undefined; + const previewBlob = entry.previewBlob + ? new Blob([await entry.previewBlob.arrayBuffer()], { + type: entry.previewMimeType || 'application/pdf', + }) + : undefined; - await this.persistMessage(hydratedEntry, fileBlob); + await this.persistMessage(hydratedEntry, fileBlob, previewBlob); } private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise { @@ -2293,6 +2379,10 @@ export class ChatSessionService { if (entry.downloadUrl?.startsWith('blob:')) { URL.revokeObjectURL(entry.downloadUrl); } + + if (entry.previewDownloadUrl?.startsWith('blob:')) { + URL.revokeObjectURL(entry.previewDownloadUrl); + } } } @@ -2451,6 +2541,10 @@ export class ChatSessionService { URL.revokeObjectURL(message.downloadUrl); } + if (message.previewDownloadUrl?.startsWith('blob:')) { + URL.revokeObjectURL(message.previewDownloadUrl); + } + const timeoutId = this.systemMessageTimeouts.get(messageId); if (typeof timeoutId !== 'undefined') { @@ -2837,6 +2931,56 @@ export class ChatSessionService { return new Blob([bytes], { type: mimeType }); } + private async generateOfficeDocumentPreview( + fileName: string, + fileBlob: Blob, + ): Promise<{ blob: Blob; mimeType: string } | null> { + const token = this.token(); + + if (!token) { + return null; + } + + try { + const response = await firstValueFrom( + this.http.post( + `${this.serverUrl()}/api/files/office-preview`, + { + fileName, + mimeType: fileBlob.type || 'application/octet-stream', + fileBase64: await this.blobToBase64(fileBlob), + }, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ), + ); + + return { + blob: this.base64ToBlob(response.pdfBase64, response.mimeType), + mimeType: response.mimeType, + }; + } catch (error) { + console.warn('Could not generate office document preview.', error); + return null; + } + } + + private isOfficeDocumentFile(fileName?: string, mimeType?: string): boolean { + const normalizedName = 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(normalizedName); + } + private fileExtensionForMimeType(mimeType: string): string { const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream'; diff --git a/client/src/app/models.ts b/client/src/app/models.ts index 93d036e..7e8665f 100644 --- a/client/src/app/models.ts +++ b/client/src/app/models.ts @@ -97,6 +97,7 @@ export interface ChatEntry { kind: 'text' | 'json' | 'file' | 'voice' | 'system'; createdAt: number; authorLabel: string; + generatedByAi?: boolean; showSpinner?: boolean; text?: string; payload?: unknown; @@ -104,6 +105,8 @@ export interface ChatEntry { fileSize?: number; fileMimeType?: string; downloadUrl?: string; + previewMimeType?: string; + previewDownloadUrl?: string; } export type CallMode = 'audio' | 'video'; diff --git a/client/src/app/office-pdf-preview.component.scss b/client/src/app/office-pdf-preview.component.scss new file mode 100644 index 0000000..91557e8 --- /dev/null +++ b/client/src/app/office-pdf-preview.component.scss @@ -0,0 +1,13 @@ +: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); +} diff --git a/client/src/app/office-pdf-preview.component.ts b/client/src/app/office-pdf-preview.component.ts new file mode 100644 index 0000000..6411639 --- /dev/null +++ b/client/src/app/office-pdf-preview.component.ts @@ -0,0 +1,45 @@ +import { Component, Input } from '@angular/core'; +import { NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer'; + +@Component({ + selector: 'app-office-pdf-preview', + imports: [NgxExtendedPdfViewerModule], + template: ` +
+ +
+ `, + styleUrl: './office-pdf-preview.component.scss', +}) +export class OfficePdfPreviewComponent { + @Input({ required: true }) src!: string; + @Input() fileName = 'document.pdf'; +} diff --git a/server/dist/index.js b/server/dist/index.js index 11b9a12..699ab4f 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -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 = { diff --git a/server/package-lock.json b/server/package-lock.json index 06af055..8b8f9d2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 3d8df28..9d925b5 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/server/src/index.ts b/server/src/index.ts index 208343f..447679f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { + 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;