documents preview
This commit is contained in:
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -5960,6 +5961,19 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="chat-main">
|
<div class="chat-main">
|
||||||
<div class="conversation">
|
<div #conversationContainer class="conversation">
|
||||||
@if (conversation().length === 0) {
|
@if (conversation().length === 0) {
|
||||||
<div class="empty-chat">
|
<div class="empty-chat">
|
||||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
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 (entry.direction !== 'system') {
|
||||||
<div class="bubble-actions">
|
<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
|
<button
|
||||||
class="bubble-action"
|
class="bubble-action"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -223,6 +234,20 @@
|
|||||||
@if (entry.downloadUrl) {
|
@if (entry.downloadUrl) {
|
||||||
<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)) {
|
||||||
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
@case ('voice') {
|
@case ('voice') {
|
||||||
|
|||||||
@@ -527,6 +527,30 @@
|
|||||||
font-weight: 600;
|
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-image,
|
||||||
.bubble-video {
|
.bubble-video {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
@@ -11,7 +12,14 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-page',
|
selector: 'app-chat-page',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerCallModalComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
JsonFileViewerComponent,
|
||||||
|
OfficePdfPreviewComponent,
|
||||||
|
PeerCallModalComponent,
|
||||||
|
],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
@@ -37,12 +45,18 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
private dictationCompletionPromise: Promise<void> | null = null;
|
private dictationCompletionPromise: Promise<void> | null = null;
|
||||||
private resolveDictationCompletion: (() => void) | null = null;
|
private resolveDictationCompletion: (() => void) | null = null;
|
||||||
private dictationApplyToken = 0;
|
private dictationApplyToken = 0;
|
||||||
|
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||||
@ViewChild('callAudioElement')
|
@ViewChild('callAudioElement')
|
||||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||||
this.callAudioElement = value;
|
this.callAudioElement = value;
|
||||||
this.syncCallAudioSource();
|
this.syncCallAudioSource();
|
||||||
}
|
}
|
||||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||||
|
@ViewChild('conversationContainer')
|
||||||
|
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||||
|
this.conversationContainer = value;
|
||||||
|
}
|
||||||
|
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
messageText = '';
|
messageText = '';
|
||||||
readonly forwardingEntryId = signal<string | null>(null);
|
readonly forwardingEntryId = signal<string | null>(null);
|
||||||
@@ -209,6 +223,32 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.remoteCallAudioStream();
|
this.remoteCallAudioStream();
|
||||||
this.syncCallAudioSource();
|
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 {
|
ngOnDestroy(): void {
|
||||||
@@ -546,6 +586,16 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.forwardingEntryId.set(null);
|
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> {
|
async endVoiceCall(peerId: string): Promise<void> {
|
||||||
await this.session.endVoiceCall(peerId);
|
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);
|
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 {
|
isVideoEntry(entry: ChatEntry): boolean {
|
||||||
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||||
return false;
|
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 {
|
isPeerTyping(peerId: string): boolean {
|
||||||
return this.session.typingPeerIds().includes(peerId);
|
return this.session.typingPeerIds().includes(peerId);
|
||||||
}
|
}
|
||||||
@@ -804,4 +885,18 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
audio.pause();
|
audio.pause();
|
||||||
audio.srcObject = null;
|
audio.srcObject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollConversationToBottom(): void {
|
||||||
|
const container = this.conversationContainer?.nativeElement;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ type LegacyPersistedChatEntry = {
|
|||||||
kind: Exclude<ChatEntry['kind'], 'system'>;
|
kind: Exclude<ChatEntry['kind'], 'system'>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
fileBlob?: Blob;
|
fileBlob?: Blob;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewBlob?: Blob;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EncryptedPersistedChatEntry = {
|
type EncryptedPersistedChatEntry = {
|
||||||
@@ -78,17 +81,26 @@ type EncryptedPersistedChatEntry = {
|
|||||||
payloadIv: number[];
|
payloadIv: number[];
|
||||||
encryptedFileBlob?: PersistedBinary;
|
encryptedFileBlob?: PersistedBinary;
|
||||||
fileIv?: number[];
|
fileIv?: number[];
|
||||||
|
encryptedPreviewBlob?: PersistedBinary;
|
||||||
|
previewIv?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
|
type PersistedChatEntry = LegacyPersistedChatEntry | EncryptedPersistedChatEntry;
|
||||||
|
|
||||||
type PersistedChatEntryContent = {
|
type PersistedChatEntryContent = {
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OfficePreviewResponse = {
|
||||||
|
mimeType: string;
|
||||||
|
pdfBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEnv = {
|
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> {
|
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.notice.set(null);
|
this.notice.set(null);
|
||||||
@@ -1055,6 +1092,7 @@ export class ChatSessionService {
|
|||||||
kind: 'file',
|
kind: 'file',
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
authorLabel: 'You',
|
authorLabel: 'You',
|
||||||
|
generatedByAi: true,
|
||||||
text: pendingRequest?.prompt ?? event.prompt,
|
text: pendingRequest?.prompt ?? event.prompt,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: imageBlob.size,
|
fileSize: imageBlob.size,
|
||||||
@@ -1503,7 +1541,7 @@ export class ChatSessionService {
|
|||||||
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
||||||
break;
|
break;
|
||||||
case 'file-complete':
|
case 'file-complete':
|
||||||
this.finalizeIncomingFile(peerId, envelope.id);
|
void this.finalizeIncomingFile(peerId, envelope.id);
|
||||||
break;
|
break;
|
||||||
case 'typing':
|
case 'typing':
|
||||||
this.setPeerTyping(peerId, envelope.active);
|
this.setPeerTyping(peerId, envelope.active);
|
||||||
@@ -1533,15 +1571,30 @@ export class ChatSessionService {
|
|||||||
transfer.receivedBytes += arrayBuffer.byteLength;
|
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);
|
const transfer = this.incomingFiles.get(peerId);
|
||||||
|
|
||||||
if (!transfer || transfer.id !== transferId) {
|
if (!transfer || transfer.id !== transferId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.incomingFiles.delete(peerId);
|
||||||
|
|
||||||
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
const blob = new Blob(transfer.chunks, { type: transfer.mimeType });
|
||||||
const downloadUrl = URL.createObjectURL(blob);
|
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({
|
this.pushMessage({
|
||||||
id: transfer.id,
|
id: transfer.id,
|
||||||
@@ -1554,9 +1607,9 @@ export class ChatSessionService {
|
|||||||
fileSize: transfer.size,
|
fileSize: transfer.size,
|
||||||
fileMimeType: transfer.mimeType,
|
fileMimeType: transfer.mimeType,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
}, blob);
|
previewMimeType,
|
||||||
|
previewDownloadUrl,
|
||||||
this.incomingFiles.delete(peerId);
|
}, blob, previewBlob);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
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));
|
this.messages.update((messages) => [...messages, entry].sort((left, right) => left.createdAt - right.createdAt));
|
||||||
|
|
||||||
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
|
if (entry.direction === 'incoming' && entry.kind !== 'system' && this.activePeerId() !== entry.peerId) {
|
||||||
@@ -1875,7 +1928,7 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.kind !== 'system') {
|
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 currentUserId = this.currentUser()?.id;
|
||||||
const messageEncryptionKey = this.messageEncryptionKey;
|
const messageEncryptionKey = this.messageEncryptionKey;
|
||||||
|
|
||||||
@@ -2134,15 +2187,20 @@ export class ChatSessionService {
|
|||||||
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
const storageKey = this.messageStorageKey(currentUserId, entry.peerId, entry.id);
|
||||||
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
const encryptedPayload = await this.encryptPersistedMessageContent(messageEncryptionKey, {
|
||||||
authorLabel: entry.authorLabel,
|
authorLabel: entry.authorLabel,
|
||||||
|
generatedByAi: entry.generatedByAi,
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
fileName: entry.fileName,
|
fileName: entry.fileName,
|
||||||
fileSize: entry.fileSize,
|
fileSize: entry.fileSize,
|
||||||
fileMimeType: entry.fileMimeType,
|
fileMimeType: entry.fileMimeType,
|
||||||
|
previewMimeType: entry.previewMimeType,
|
||||||
});
|
});
|
||||||
const encryptedFileBlob = fileBlob
|
const encryptedFileBlob = fileBlob
|
||||||
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
|
? await this.encryptBinary(messageEncryptionKey, await fileBlob.arrayBuffer())
|
||||||
: null;
|
: null;
|
||||||
|
const encryptedPreviewBlob = previewBlob
|
||||||
|
? await this.encryptBinary(messageEncryptionKey, await previewBlob.arrayBuffer())
|
||||||
|
: null;
|
||||||
const persistedEntry: EncryptedPersistedChatEntry = {
|
const persistedEntry: EncryptedPersistedChatEntry = {
|
||||||
storageKey,
|
storageKey,
|
||||||
ownerUserId: currentUserId,
|
ownerUserId: currentUserId,
|
||||||
@@ -2158,6 +2216,10 @@ export class ChatSessionService {
|
|||||||
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
|
? this.serializePersistedBinary(encryptedFileBlob.ciphertext)
|
||||||
: undefined,
|
: undefined,
|
||||||
fileIv: encryptedFileBlob ? Array.from(encryptedFileBlob.iv) : 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 () => {
|
await this.queueMessageStoreOperation(storageKey, async () => {
|
||||||
@@ -2204,6 +2266,7 @@ export class ChatSessionService {
|
|||||||
try {
|
try {
|
||||||
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
|
const content = await this.decryptPersistedMessageContent(messageEncryptionKey, entry);
|
||||||
let downloadUrl: string | undefined;
|
let downloadUrl: string | undefined;
|
||||||
|
let previewDownloadUrl: string | undefined;
|
||||||
|
|
||||||
if (entry.encryptedFileBlob && entry.fileIv) {
|
if (entry.encryptedFileBlob && entry.fileIv) {
|
||||||
const decryptedFile = await this.decryptBinary(
|
const decryptedFile = await this.decryptBinary(
|
||||||
@@ -2217,6 +2280,18 @@ export class ChatSessionService {
|
|||||||
downloadUrl = URL.createObjectURL(fileBlob);
|
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 {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
peerId: entry.peerId,
|
peerId: entry.peerId,
|
||||||
@@ -2224,12 +2299,15 @@ export class ChatSessionService {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
authorLabel: content.authorLabel,
|
authorLabel: content.authorLabel,
|
||||||
|
generatedByAi: content.generatedByAi,
|
||||||
text: content.text,
|
text: content.text,
|
||||||
payload: content.payload,
|
payload: content.payload,
|
||||||
fileName: content.fileName,
|
fileName: content.fileName,
|
||||||
fileSize: content.fileSize,
|
fileSize: content.fileSize,
|
||||||
fileMimeType: content.fileMimeType,
|
fileMimeType: content.fileMimeType,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
|
previewMimeType: content.previewMimeType,
|
||||||
|
previewDownloadUrl,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not decrypt persisted chat message.', error);
|
console.warn('Could not decrypt persisted chat message.', error);
|
||||||
@@ -2245,12 +2323,15 @@ export class ChatSessionService {
|
|||||||
kind: entry.kind,
|
kind: entry.kind,
|
||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
authorLabel: entry.authorLabel,
|
authorLabel: entry.authorLabel,
|
||||||
|
generatedByAi: entry.generatedByAi,
|
||||||
text: entry.text,
|
text: entry.text,
|
||||||
payload: entry.payload,
|
payload: entry.payload,
|
||||||
fileName: entry.fileName,
|
fileName: entry.fileName,
|
||||||
fileSize: entry.fileSize,
|
fileSize: entry.fileSize,
|
||||||
fileMimeType: entry.fileMimeType,
|
fileMimeType: entry.fileMimeType,
|
||||||
|
previewMimeType: entry.previewMimeType,
|
||||||
downloadUrl: entry.fileBlob ? URL.createObjectURL(entry.fileBlob) : undefined,
|
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',
|
type: entry.fileMimeType || 'application/octet-stream',
|
||||||
})
|
})
|
||||||
: undefined;
|
: 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> {
|
private async migrateEncryptedPersistedMessage(entry: EncryptedPersistedChatEntry): Promise<void> {
|
||||||
@@ -2293,6 +2379,10 @@ export class ChatSessionService {
|
|||||||
if (entry.downloadUrl?.startsWith('blob:')) {
|
if (entry.downloadUrl?.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(entry.downloadUrl);
|
URL.revokeObjectURL(entry.downloadUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.previewDownloadUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(entry.previewDownloadUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2451,6 +2541,10 @@ export class ChatSessionService {
|
|||||||
URL.revokeObjectURL(message.downloadUrl);
|
URL.revokeObjectURL(message.downloadUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.previewDownloadUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(message.previewDownloadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = this.systemMessageTimeouts.get(messageId);
|
const timeoutId = this.systemMessageTimeouts.get(messageId);
|
||||||
|
|
||||||
if (typeof timeoutId !== 'undefined') {
|
if (typeof timeoutId !== 'undefined') {
|
||||||
@@ -2837,6 +2931,56 @@ export class ChatSessionService {
|
|||||||
return new Blob([bytes], { type: mimeType });
|
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 {
|
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';
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface ChatEntry {
|
|||||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
generatedByAi?: boolean;
|
||||||
showSpinner?: boolean;
|
showSpinner?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
@@ -104,6 +105,8 @@ export interface ChatEntry {
|
|||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewDownloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallMode = 'audio' | 'video';
|
export type CallMode = 'audio' | 'video';
|
||||||
|
|||||||
13
client/src/app/office-pdf-preview.component.scss
Normal file
13
client/src/app/office-pdf-preview.component.scss
Normal 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);
|
||||||
|
}
|
||||||
45
client/src/app/office-pdf-preview.component.ts
Normal file
45
client/src/app/office-pdf-preview.component.ts
Normal 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
68
server/dist/index.js
vendored
@@ -2,13 +2,14 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
@@ -47,6 +48,11 @@ const adminDeleteUserParamsSchema = z.object({
|
|||||||
const webBundleFileParamsSchema = z.object({
|
const webBundleFileParamsSchema = z.object({
|
||||||
'*': z.string().min(1),
|
'*': 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({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
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 webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
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 speechTranscriber = new SpeechTranscriber({
|
const speechTranscriber = new SpeechTranscriber({
|
||||||
serviceUrl: speechTranscriptionServiceUrl,
|
serviceUrl: speechTranscriptionServiceUrl,
|
||||||
language: speechTranscriptionLanguage,
|
language: speechTranscriptionLanguage,
|
||||||
@@ -462,6 +469,35 @@ 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) => {
|
||||||
|
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) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
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) {
|
function createUser(input) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const user = {
|
const user = {
|
||||||
|
|||||||
29
server/package-lock.json
generated
29
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -1002,6 +1003,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -1536,6 +1543,19 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -2029,6 +2049,15 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/toad-cache": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"libreoffice-convert": "^1.8.1",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
@@ -10,6 +10,7 @@ import jwt from '@fastify/jwt';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import {
|
import {
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
@@ -271,6 +272,12 @@ const webBundleFileParamsSchema = z.object({
|
|||||||
'*': z.string().min(1),
|
'*': 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({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -346,6 +353,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 speechTranscriber = new SpeechTranscriber(
|
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) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(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: {
|
function createUser(input: {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user