documents preview

This commit is contained in:
2026-03-11 09:09:15 +01:00
parent ffdea4fe62
commit 0e4c79b735
13 changed files with 558 additions and 13 deletions

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -129,7 +129,7 @@
</aside>
<div class="chat-main">
<div class="conversation">
<div #conversationContainer class="conversation">
@if (conversation().length === 0) {
<div class="empty-chat">
No text messages yet. The chat page is ready as soon as the peer channel opens.
@@ -145,6 +145,17 @@
>
@if (entry.direction !== 'system') {
<div class="bubble-actions">
@if (isGeneratedImageEntry(entry)) {
<button
class="bubble-action"
type="button"
(click)="sendGeneratedImage(entry)"
title="Send image to peer"
aria-label="Send image to peer"
>
📤
</button>
}
<button
class="bubble-action"
type="button"
@@ -223,6 +234,20 @@
@if (entry.downloadUrl) {
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
}
@if (hasPdfPreview(entry)) {
<div class="bubble-preview">
<div class="bubble-preview-label">Preview</div>
@defer (on viewport) {
<app-office-pdf-preview
[src]="pdfPreviewUrl(entry)!"
[fileName]="entry.fileName ?? 'document.pdf'"
></app-office-pdf-preview>
} @placeholder {
<div class="bubble-preview-placeholder">Loading preview…</div>
}
</div>
}
</div>
}
@case ('voice') {

View File

@@ -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;

View File

@@ -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<void> | 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<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
@ViewChild('conversationContainer')
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef<HTMLDivElement>;
messageText = '';
readonly forwardingEntryId = signal<string | null>(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<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.sendGeneratedImageToPeer(entry, peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
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;
});
});
}
}

View File

@@ -57,12 +57,15 @@ type LegacyPersistedChatEntry = {
kind: Exclude<ChatEntry['kind'], 'system'>;
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<void> {
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
@@ -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<void> {
private async persistMessage(entry: ChatEntry, fileBlob?: Blob, previewBlob?: Blob): Promise<void> {
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<void> {
@@ -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<OfficePreviewResponse>(
`${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';

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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: `
<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';
}

68
server/dist/index.js vendored
View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TextEncoder } from 'node:util';
import { promisify, TextEncoder } from 'node:util';
import { DatabaseSync } from 'node:sqlite';
import cors from '@fastify/cors';
@@ -10,6 +10,7 @@ import jwt from '@fastify/jwt';
import fastifyStatic from '@fastify/static';
import websocket from '@fastify/websocket';
import dotenv from 'dotenv';
import libreOffice from 'libreoffice-convert';
import {
generateAuthenticationOptions,
generateRegistrationOptions,
@@ -271,6 +272,12 @@ const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const officePreviewSchema = z.object({
fileName: z.string().trim().min(1).max(256),
mimeType: z.string().trim().min(1).max(256),
fileBase64: z.string().min(1).max(96_000_000),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -346,6 +353,7 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
);
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
const speechTranscriber = new SpeechTranscriber(
{
@@ -795,6 +803,41 @@ app.get('/api/auth/session', async (request, reply) => {
};
});
app.post('/api/files/office-preview', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
const parsed = officePreviewSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid office preview payload.',
issues: parsed.error.flatten(),
});
}
if (!isSupportedOfficeDocument(parsed.data.fileName, parsed.data.mimeType)) {
return reply.code(400).send({ message: 'Only DOCX, XLSX, and PPTX files can be previewed.' });
}
try {
const pdfBuffer = await convertOfficeDocumentToPdf(parsed.data.fileName, parsed.data.fileBase64);
return {
mimeType: 'application/pdf',
pdfBase64: pdfBuffer.toString('base64'),
};
} catch (error) {
app.log.warn({ err: error, userId: authContext.user.id }, 'Office preview generation failed');
return reply.code(422).send({
message: describeOfficePreviewFailure(error),
});
}
});
app.get('/api/admin/pending-users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
@@ -1294,6 +1337,48 @@ async function authenticateTokenFromSession(
};
}
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
const inputBuffer = Buffer.from(fileBase64, 'base64');
if (inputBuffer.byteLength === 0) {
throw new Error('The uploaded office document is empty.');
}
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
}
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
const normalizedFileName = fileName.trim().toLowerCase();
const normalizedMimeType = mimeType.trim().toLowerCase();
if (
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
) {
return true;
}
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
}
function normalizeOfficeDocumentFileName(fileName: string): string {
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
}
function describeOfficePreviewFailure(error: unknown): string {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return 'Office preview generation failed because LibreOffice is not installed on the server.';
}
if (error instanceof Error && error.message.trim()) {
return `Office preview generation failed: ${error.message}`;
}
return 'Office preview generation failed.';
}
function createUser(input: {
username: string;
displayName: string;