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;