diff --git a/.gitignore b/.gitignore index 2f0d303..ddf41fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ server/server/data/privatechat.sqlite-shm server/server/data/privatechat.sqlite-wal server/server/data/master.key client/dist/* +client/apple-client/WebApp/** diff --git a/README.md b/README.md index 5d44cb3..01e18be 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The backend accepts these environment variables: - `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials. - `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key. - `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`. -- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins. +- `CORS_ORIGIN`: Optional comma-separated browser-origin allowlist. If omitted, the server accepts request origins. The special `null` origin from embedded `file://` webviews is accepted. - `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`. - `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`. - `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`. diff --git a/client/public/env.js b/client/public/env.js index 67bc94b..220d46e 100644 --- a/client/public/env.js +++ b/client/public/env.js @@ -1,3 +1,3 @@ window.__PRIVATECHAT_ENV__ = { - "PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr" + "PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr" }; diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 73591cd..cd8e542 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -1,6 +1,13 @@
+ +
← Back to dashboard @@ -100,18 +107,41 @@ [class.bubble-outgoing]="entry.direction === 'outgoing'" [class.bubble-system]="entry.direction === 'system'" > - + @if (entry.direction !== 'system') { +
+ + + @if (isForwardMenuOpen(entry.id)) { +
+ +
+ } +
+ }
- {{ entry.authorLabel }} - + {{ entry.authorLabel }} +
@switch (entry.kind) { @@ -131,6 +161,18 @@ /> } + @if (isVideoEntry(entry)) { + + } + @if (isIncomingJsonFileEntry(entry)) { } @@ -157,6 +199,18 @@
@if (peer(); as selectedPeer) { +
+ + + +
} - + +
+ + +
+ @if (emojiPickerOpen()) { +
+ @for (emoji of emojiOptions; track emoji) { + + } +
+ } + +
+ + +
diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index 6bf832a..ed8f0dd 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -211,23 +211,55 @@ position: relative; align-self: start; max-width: min(75%, 34rem); - padding: 0.9rem 1rem; + padding: 0.9rem 3.4rem 0.9rem 1rem; border-radius: 1.2rem; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14); } -.bubble-delete { +.bubble-actions { position: absolute; top: 0.45rem; right: 0.55rem; + display: flex; + align-items: flex-start; + gap: 0.35rem; +} + +.bubble-action { width: 1.5rem; height: 1.5rem; + padding: 0; border: 0; border-radius: 999px; color: #fff; - background: var(--danger-background); + background: var(--badge-background); line-height: 1; - font-size: 1rem; + font-size: 0.9rem; +} + +.bubble-delete { + background: var(--danger-background); +} + +.bubble-forward-menu { + position: absolute; + top: 1.9rem; + right: 0; + z-index: 2; + min-width: 12rem; + padding: 0.45rem; + border: 1px solid var(--surface-border); + border-radius: 0.85rem; + background: var(--surface-background); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18); +} + +.bubble-forward-select { + width: 100%; + border: 1px solid var(--input-border); + border-radius: 0.65rem; + color: var(--page-text); + background: var(--input-background); } .bubble-incoming { @@ -250,14 +282,21 @@ } .bubble-meta { - display: flex; - justify-content: space-between; - gap: 1rem; + display: grid; + gap: 0.12rem; margin-bottom: 0.35rem; font-size: 0.78rem; opacity: 0.7; } +.bubble-author { + font-weight: 600; +} + +.bubble-time { + display: block; +} + .composer { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -268,10 +307,27 @@ border-top: 1px solid var(--surface-border-soft); } +.composer-actions { + display: grid; + gap: 0.6rem; +} + +.composer-send { + display: grid; + gap: 0.6rem; +} + +.composer-emoji-picker-shell { + position: relative; +} + .composer-file-input { display: none; } +.composer-camera, +.composer-image-generate, +.composer-emoji-trigger, .composer-plus, .send-emoji { width: 3.25rem; @@ -293,6 +349,21 @@ color: var(--placeholder-color); } +.composer-camera { + color: var(--page-text); + background: var(--badge-background); +} + +.composer-image-generate { + color: var(--page-text); + background: linear-gradient(135deg, #ffe6b0, #ffc8a8); +} + +.composer-emoji-trigger { + color: var(--page-text); + background: var(--badge-background); +} + .composer-plus { color: var(--page-text); background: var(--badge-background); @@ -302,6 +373,41 @@ background: linear-gradient(135deg, #def7dd, #9bd5ff); } +.composer-emoji-picker { + position: absolute; + right: 0; + bottom: calc(100% + 0.65rem); + z-index: 3; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.35rem; + width: min(14rem, 70vw); + max-height: 10.35rem; + overflow-y: auto; + overflow-x: hidden; + padding: 0.65rem; + border: 1px solid var(--surface-border); + border-radius: 1rem; + background: var(--panel-background); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18); +} + +.composer-emoji-option { + width: 2.1rem; + height: 2.1rem; + padding: 0; + border: 0; + border-radius: 0.75rem; + background: var(--surface-background); + font-size: 1.2rem; + line-height: 1; +} + +.composer-emoji-option:hover, +.composer-emoji-option:focus-visible { + background: var(--surface-hover-background); +} + .bubble-image { width: 200px; max-width: 100%; @@ -310,6 +416,15 @@ display: block; } +.bubble-video { + width: 200px; + max-width: 100%; + height: auto; + display: block; + border-radius: 1rem; + background: #000; +} + .bubble-download { color: inherit; font-weight: 600; diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index 891c47a..4051c42 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -1,16 +1,17 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { PeerVideoModalComponent } from './peer-video-modal.component'; import { ChatSessionService } from './chat-session.service'; import { JsonFileViewerComponent } from './json-file-viewer.component'; -import type { ChatEntry, ConnectionState } from './models'; +import type { ChatEntry, ConnectionState, PeerSummary } from './models'; @Component({ selector: 'app-chat-page', - imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent], + imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent], templateUrl: './chat-page.component.html', styleUrl: './chat-page.component.scss', }) @@ -20,8 +21,22 @@ export class ChatPageComponent { private readonly routeParamMap = toSignal(this.route.paramMap, { initialValue: this.route.snapshot.paramMap, }); + private composerSelectionStart = 0; + private composerSelectionEnd = 0; messageText = ''; + readonly forwardingEntryId = signal(null); + readonly emojiPickerOpen = signal(false); + readonly emojiOptions = [ + '😀', '😁', '😂', '🤣', '😊', + '😉', '😍', '😘', '😎', '🤔', + '😅', '😭', '😡', '😴', '🙃', + '👍', '👎', '👏', '🙏', '🤝', + '🎉', '🔥', '❤️', '💡', '✅', + '🚀', '👀', '📹', '📎', '💬', + '🌍', '⚡', '⭐', '🎵', '📷', + '🗑️', '⏩', '🛑', '🙌', '👌', + ]; readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly currentUser = computed(() => this.session.currentUser()); @@ -30,6 +45,10 @@ export class ChatPageComponent { .messages() .filter((entry) => entry.peerId === this.peerId()), ); + readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId())); + readonly remoteVideoModalVisible = computed( + () => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(), + ); readonly webRtcState = computed(() => { const selectedPeer = this.peer(); @@ -85,6 +104,19 @@ export class ChatPageComponent { await this.session.sendText(peerId, this.messageText); this.messageText = ''; + this.emojiPickerOpen.set(false); + this.composerSelectionStart = 0; + this.composerSelectionEnd = 0; + } + + async requestGeneratedImage(): Promise { + const peerId = this.peerId(); + + if (!peerId) { + return; + } + + await this.session.requestGeneratedImage(peerId, this.messageText); } handleComposerEnter(event: Event): void { @@ -106,6 +138,37 @@ export class ChatPageComponent { this.session.notifyTypingActivity(peerId, text); } + trackComposerSelection(textarea: HTMLTextAreaElement): void { + this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length; + this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart; + } + + toggleEmojiPicker(event?: Event): void { + event?.stopPropagation(); + this.emojiPickerOpen.update((open) => !open); + } + + insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void { + const selectionStart = textarea.selectionStart ?? this.composerSelectionStart; + const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd; + const before = this.messageText.slice(0, selectionStart); + const after = this.messageText.slice(selectionEnd); + + this.messageText = `${before}${emoji}${after}`; + this.emojiPickerOpen.set(false); + this.handleMessageTextChange(this.messageText); + + const nextSelection = selectionStart + emoji.length; + this.composerSelectionStart = nextSelection; + this.composerSelectionEnd = nextSelection; + + queueMicrotask(() => { + textarea.focus(); + textarea.setSelectionRange(nextSelection, nextSelection); + this.trackComposerSelection(textarea); + }); + } + async sendFile(peerId: string, input: HTMLInputElement): Promise { const file = input.files?.item(0); @@ -126,10 +189,64 @@ export class ChatPageComponent { await this.session.deleteConversation(peerId); } + toggleForwardMenu(entry: ChatEntry, event?: Event): void { + event?.stopPropagation(); + + if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) { + this.forwardingEntryId.set(null); + return; + } + + this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id)); + } + + isForwardMenuOpen(entryId: string): boolean { + return this.forwardingEntryId() === entryId; + } + + forwardTargets(entry: ChatEntry): PeerSummary[] { + if (entry.kind === 'system' || entry.direction === 'system') { + return []; + } + + return this.session.peers().filter((peer) => peer.id !== entry.peerId); + } + + async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise { + if (!targetPeerId) { + return; + } + + await this.session.forwardMessage(targetPeerId, entry); + select.value = ''; + this.forwardingEntryId.set(null); + } + + async toggleCameraStream(peerId: string): Promise { + if (this.session.isStreamingCameraToPeer(peerId)) { + await this.session.stopCameraStream(peerId); + return; + } + + await this.session.startCameraStream(peerId); + } + isImageEntry(entry: ChatEntry): boolean { return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); } + isVideoEntry(entry: ChatEntry): boolean { + if (entry.kind !== 'file' || !entry.downloadUrl) { + return false; + } + + if (entry.fileMimeType?.startsWith('video/')) { + return true; + } + + return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? ''); + } + isIncomingJsonFileEntry(entry: ChatEntry): boolean { return ( entry.kind === 'file' && @@ -160,11 +277,29 @@ export class ChatPageComponent { return this.indicatorTone(this.webRtcState()) === 'offline'; } + isStreamingCameraToSelectedPeer(): boolean { + const peerId = this.peerId(); + + return !!peerId && this.session.isStreamingCameraToPeer(peerId); + } + + closeRemoteVideoModal(): void { + const peerId = this.peerId(); + + if (!peerId) { + return; + } + + this.session.dismissRemoteVideoModal(peerId); + } + async switchPeer(peerId: string): Promise { if (!peerId || peerId === this.peerId()) { return; } + this.forwardingEntryId.set(null); + this.emojiPickerOpen.set(false); this.session.selectPeer(peerId); await this.router.navigate(['/chat', peerId]); } diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 4068cea..65cd7e5 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -1,10 +1,10 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { computed, Injectable, signal } from '@angular/core'; -import type { HttpErrorResponse } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { AccessKeySummary, + AdminUserSummary, AuthenticationOptionsResponse, AuthResponse, ChatEntry, @@ -24,6 +24,10 @@ type PeerBundle = { pc: RTCPeerConnection; channel?: RTCDataChannel; pendingCandidates: RTCIceCandidateInit[]; + pendingNegotiation: boolean; + localCameraStream?: MediaStream; + cameraSenders: RTCRtpSender[]; + remoteCameraStream?: MediaStream; }; type IncomingFileTransfer = { @@ -101,6 +105,10 @@ export class ChatSessionService { private static readonly messageDatabaseName = 'privatechat'; private static readonly messageStoreName = 'conversation_messages'; private static readonly messageRetentionLimit = 256; + private static readonly sessionKeepaliveMs = 5 * 60 * 1000; + private static readonly signalingHeartbeatMs = 25 * 1000; + private static readonly signalingReconnectBaseMs = 1000; + private static readonly signalingReconnectMaxMs = 10 * 1000; private static readonly systemMessageLifetimeMs = 5000; private static readonly typingIndicatorLifetimeMs = 1800; private static readonly typingIdleMs = 1200; @@ -114,6 +122,7 @@ export class ChatSessionService { readonly messages = signal([]); readonly unreadPeerIds = signal([]); readonly typingPeerIds = signal([]); + readonly remoteVideoModalPeerId = signal(null); readonly signalingState = signal('disconnected'); readonly status = signal('Disconnected from signaling server.'); readonly error = signal(null); @@ -148,6 +157,14 @@ export class ChatSessionService { private readonly outgoingTypingIdleTimeouts = new Map(); private readonly outgoingTypingStates = new Map(); private readonly messageStoreOperations = new Map>(); + private readonly pendingImageGenerationRequests = new Map(); + private readonly remoteVideoStreams = signal>([]); + private readonly activeCameraPeerId = signal(null); + private sessionKeepaliveIntervalId: number | null = null; + private websocketHeartbeatIntervalId: number | null = null; + private websocketReconnectTimeoutId: number | null = null; + private websocketReconnectAttempt = 0; + private suppressSocketReconnect = false; private messageEncryptionKey: CryptoKey | null = null; private messageDatabasePromise: Promise | null = null; private websocket: WebSocket | null = null; @@ -325,14 +342,107 @@ export class ChatSessionService { this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' }); this.addSystemMessage(peerId, 'Opening WebRTC data channel.'); + await this.negotiatePeer(peerId, bundle); + } - const offer = await bundle.pc.createOffer(); - await bundle.pc.setLocalDescription(offer); + async startCameraStream(peerId: string): Promise { + if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') { + this.error.set('This browser does not support webcam capture.'); + return; + } - this.sendSignal(peerId, { - type: 'sdp', - description: bundle.pc.localDescription!.toJSON(), - }); + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + this.error.set('You must be connected to signaling before starting webcam capture.'); + return; + } + + const activeCameraPeerId = this.activeCameraPeerId(); + + if (activeCameraPeerId && activeCameraPeerId !== peerId) { + await this.stopCameraStream(activeCameraPeerId); + } + + const bundle = this.ensurePeerBundle(peerId, true); + + if (bundle.localCameraStream) { + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + + bundle.localCameraStream = stream; + bundle.cameraSenders = stream.getTracks().map((track) => { + track.onended = () => { + void this.stopCameraStream(peerId, false); + }; + + return bundle.pc.addTrack(track, stream); + }); + + this.activeCameraPeerId.set(peerId); + this.sendCameraState(peerId, true); + this.addSystemMessage(peerId, 'Sharing webcam capture.'); + await this.negotiatePeer(peerId, bundle); + } catch { + this.error.set('Could not start webcam capture.'); + } + } + + async stopCameraStream(peerId: string, notifyPeer = true): Promise { + const bundle = this.peerBundles.get(peerId); + + if (!bundle?.localCameraStream && this.activeCameraPeerId() !== peerId) { + return; + } + + if (bundle) { + for (const sender of bundle.cameraSenders) { + bundle.pc.removeTrack(sender); + } + + bundle.cameraSenders = []; + + if (bundle.localCameraStream) { + for (const track of bundle.localCameraStream.getTracks()) { + track.onended = null; + track.stop(); + } + } + + bundle.localCameraStream = undefined; + } + + if (this.activeCameraPeerId() === peerId) { + this.activeCameraPeerId.set(null); + } + + if (notifyPeer) { + this.sendCameraState(peerId, false); + } + + this.addSystemMessage(peerId, 'Stopped webcam capture.'); + + if (bundle) { + await this.negotiatePeer(peerId, bundle); + } + } + + isStreamingCameraToPeer(peerId: string): boolean { + return this.activeCameraPeerId() === peerId; + } + + remoteVideoStreamForPeer(peerId: string): MediaStream | null { + return this.remoteVideoStreams().find((entry) => entry.peerId === peerId)?.stream ?? null; + } + + dismissRemoteVideoModal(peerId: string): void { + if (this.remoteVideoModalPeerId() === peerId) { + this.remoteVideoModalPeerId.set(null); + } } async registerAccessKey(label: string): Promise { @@ -397,26 +507,7 @@ export class ChatSessionService { return; } - const envelope: DataEnvelope = { - type: 'text', - id: crypto.randomUUID(), - body: trimmed, - authorId: this.currentUser()!.id, - authorName: this.currentUser()!.displayName, - sentAt: Date.now(), - }; - - channel.send(JSON.stringify(envelope)); - this.sendTypingState(peerId, false); - this.pushMessage({ - id: envelope.id, - peerId, - direction: 'outgoing', - kind: 'text', - createdAt: envelope.sentAt, - authorLabel: 'You', - text: trimmed, - }); + this.sendTextEnvelope(peerId, channel, trimmed); } async sendJson(peerId: string, rawPayload: string): Promise { @@ -439,25 +530,7 @@ export class ChatSessionService { return; } - const envelope: DataEnvelope = { - type: 'json', - id: crypto.randomUUID(), - body: parsedPayload, - authorId: this.currentUser()!.id, - authorName: this.currentUser()!.displayName, - sentAt: Date.now(), - }; - - channel.send(JSON.stringify(envelope)); - this.pushMessage({ - id: envelope.id, - peerId, - direction: 'outgoing', - kind: 'json', - createdAt: envelope.sentAt, - authorLabel: 'You', - payload: parsedPayload, - }); + this.sendJsonEnvelope(peerId, channel, parsedPayload); } async sendFile(peerId: string, file: File): Promise { @@ -506,6 +579,51 @@ export class ChatSessionService { }, file); } + async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise { + if (entry.kind === 'system' || entry.direction === 'system') { + return; + } + + const channel = await this.ensureOpenChannel(targetPeerId); + + if (!channel) { + return; + } + + switch (entry.kind) { + case 'text': + if (!entry.text) { + return; + } + + this.sendTextEnvelope(targetPeerId, channel, entry.text); + return; + case 'json': + this.sendJsonEnvelope(targetPeerId, channel, entry.payload); + return; + case 'file': + if (!entry.downloadUrl) { + this.error.set('This file cannot be forwarded because its data is unavailable.'); + return; + } + + try { + const response = await fetch(entry.downloadUrl); + const blob = await response.blob(); + const file = new File([blob], entry.fileName || 'attachment', { + type: entry.fileMimeType || blob.type || 'application/octet-stream', + }); + + await this.sendFile(targetPeerId, file); + } catch { + this.error.set('Could not forward this file.'); + } + return; + default: + return; + } + } + private async authenticate(path: string, payload: Record): Promise { this.error.set(null); this.notice.set(null); @@ -533,9 +651,61 @@ export class ChatSessionService { this.status.set(`Authenticated as ${response.user.displayName}.`); await this.loadPersistedMessages(response.user.id); await this.loadAccessKeys(); + this.startSessionKeepalive(); await this.connectWebSocket(); } + private sendTextEnvelope(peerId: string, channel: RTCDataChannel, text: string): void { + const trimmed = text.trim(); + + if (!trimmed) { + return; + } + + const envelope: DataEnvelope = { + type: 'text', + id: crypto.randomUUID(), + body: trimmed, + authorId: this.currentUser()!.id, + authorName: this.currentUser()!.displayName, + sentAt: Date.now(), + }; + + channel.send(JSON.stringify(envelope)); + this.sendTypingState(peerId, false); + this.pushMessage({ + id: envelope.id, + peerId, + direction: 'outgoing', + kind: 'text', + createdAt: envelope.sentAt, + authorLabel: 'You', + text: trimmed, + }); + } + + private sendJsonEnvelope(peerId: string, channel: RTCDataChannel, payload: unknown): void { + const envelope: DataEnvelope = { + type: 'json', + id: crypto.randomUUID(), + body: payload, + authorId: this.currentUser()!.id, + authorName: this.currentUser()!.displayName, + sentAt: Date.now(), + }; + + channel.send(JSON.stringify(envelope)); + this.pushMessage({ + id: envelope.id, + peerId, + direction: 'outgoing', + kind: 'json', + createdAt: envelope.sentAt, + authorLabel: 'You', + payload, + }); + } + async loadPendingApprovalUsers(): Promise { const token = this.token(); @@ -568,6 +738,69 @@ export class ChatSessionService { ); } + async loadAdminUsers(): Promise { + const token = this.token(); + + if (!token) { + throw new Error('Authentication required.'); + } + + const response = await firstValueFrom( + this.http.get<{ users: AdminUserSummary[] }>(`${this.serverUrl()}/api/admin/users`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + return response.users; + } + + async deleteUserAccount(userId: string): Promise { + const token = this.token(); + + if (!token) { + throw new Error('Authentication required.'); + } + + await firstValueFrom( + this.http.delete(`${this.serverUrl()}/api/admin/users/${encodeURIComponent(userId)}`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + + if (this.currentUser()?.id === userId) { + this.clearLocalAuth('User deleted.'); + } + } + + async requestGeneratedImage(peerId: string, prompt: string): Promise { + const trimmedPrompt = prompt.trim(); + + if (!trimmedPrompt) { + this.error.set('Enter a text prompt before requesting an image.'); + return; + } + + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + this.error.set('You must be connected to signaling before requesting an image.'); + return; + } + + const requestId = crypto.randomUUID(); + + this.pendingImageGenerationRequests.set(requestId, { + peerId, + prompt: trimmedPrompt, + }); + this.error.set(null); + this.addSystemMessage(peerId, 'Generating image from prompt.'); + this.websocket.send(JSON.stringify({ + type: 'image-generation', + requestId, + peerId, + prompt: trimmedPrompt, + })); + } + private async loadAccessKeys(): Promise { const token = this.token(); @@ -596,6 +829,7 @@ export class ChatSessionService { return; } + this.clearWebSocketReconnect(); this.disconnectWebSocket(); this.resetPeerConnections(); @@ -607,32 +841,65 @@ export class ChatSessionService { this.websocket = websocket; websocket.onopen = () => { + if (this.websocket !== websocket) { + return; + } + + this.websocketReconnectAttempt = 0; + this.startWebSocketHeartbeat(websocket); this.signalingState.set('connected'); this.status.set('Connected to signaling server.'); }; websocket.onmessage = (event) => { + if (this.websocket !== websocket) { + return; + } + const message = JSON.parse(event.data) as ServerEvent; void this.handleServerEvent(message); }; websocket.onerror = () => { + if (this.websocket !== websocket) { + return; + } + this.signalingState.set('failed'); this.error.set('The signaling socket encountered an error.'); }; websocket.onclose = () => { + const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect; + + this.stopWebSocketHeartbeat(); this.signalingState.set('disconnected'); this.status.set('Signaling connection closed.'); - this.websocket = null; + + if (this.websocket === websocket) { + this.websocket = null; + } + this.peers.update((peers) => peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })), ); + + if (this.suppressSocketReconnect) { + this.suppressSocketReconnect = false; + return; + } + + if (shouldReconnect) { + this.scheduleWebSocketReconnect(); + } }; } private disconnectWebSocket(): void { + this.stopWebSocketHeartbeat(); + if (this.websocket) { + this.suppressSocketReconnect = true; this.websocket.close(); this.websocket = null; } @@ -652,8 +919,9 @@ export class ChatSessionService { case 'peer-left': this.releasePeerBundle(event.peerId, false); this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId)); - this.clearUnreadPeer(event.peerId); - this.clearPeerTyping(event.peerId); + this.clearUnreadPeer(event.peerId); + this.clearPeerTyping(event.peerId); + this.clearRemoteVideoState(event.peerId); if (this.activePeerId() === event.peerId) { this.activePeerId.set(this.peers()[0]?.id ?? null); } @@ -662,6 +930,14 @@ export class ChatSessionService { case 'signal': await this.handleSignal(event.from, event.signal); break; + case 'image-generated': + this.handleGeneratedImage(event); + break; + case 'image-generation-error': + this.handleGeneratedImageError(event); + break; + case 'pong': + break; case 'error': this.error.set(event.message); if (/auth|session/i.test(event.message)) { @@ -671,6 +947,44 @@ export class ChatSessionService { } } + private handleGeneratedImage(event: Extract): void { + const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId); + + if (pendingRequest) { + this.pendingImageGenerationRequests.delete(event.requestId); + } + + const peerId = pendingRequest?.peerId ?? event.peerId; + const imageBlob = this.base64ToBlob(event.imageBase64, event.mimeType); + const extension = this.fileExtensionForMimeType(event.mimeType); + const fileName = `generated-image-${event.requestId.slice(0, 8)}.${extension}`; + + this.pushMessage({ + id: event.requestId, + peerId, + direction: 'outgoing', + kind: 'file', + createdAt: event.createdAt, + authorLabel: 'You', + text: pendingRequest?.prompt ?? event.prompt, + fileName, + fileSize: imageBlob.size, + fileMimeType: event.mimeType, + downloadUrl: URL.createObjectURL(imageBlob), + }, imageBlob); + } + + private handleGeneratedImageError(event: Extract): void { + const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId); + + if (pendingRequest) { + this.pendingImageGenerationRequests.delete(event.requestId); + this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.'); + } + + this.error.set(event.message); + } + private async restoreSession(): Promise { const token = this.token(); @@ -694,12 +1008,108 @@ export class ChatSessionService { this.writeStorage('privatechat.user', JSON.stringify(response.user)); await this.loadPersistedMessages(response.user.id); await this.loadAccessKeys(); + this.startSessionKeepalive(); await this.connectWebSocket(); } catch { this.clearLocalAuth('Saved session expired. Sign in again.'); } } + private startSessionKeepalive(): void { + this.stopSessionKeepalive(); + + if (typeof window === 'undefined' || !this.token()) { + return; + } + + this.sessionKeepaliveIntervalId = window.setInterval(() => { + void this.refreshSessionLease(); + }, ChatSessionService.sessionKeepaliveMs); + } + + private stopSessionKeepalive(): void { + if (this.sessionKeepaliveIntervalId === null || typeof window === 'undefined') { + return; + } + + window.clearInterval(this.sessionKeepaliveIntervalId); + this.sessionKeepaliveIntervalId = null; + } + + private async refreshSessionLease(): Promise { + const token = this.token(); + + if (!token) { + this.stopSessionKeepalive(); + return; + } + + try { + await firstValueFrom( + this.http.get(`${this.serverUrl()}/api/auth/session`, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + } catch (error) { + if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) { + this.clearLocalAuth('Session expired. Sign in again.'); + } + } + } + + private startWebSocketHeartbeat(websocket: WebSocket): void { + this.stopWebSocketHeartbeat(); + + if (typeof window === 'undefined') { + return; + } + + this.websocketHeartbeatIntervalId = window.setInterval(() => { + if (this.websocket !== websocket || websocket.readyState !== WebSocket.OPEN) { + this.stopWebSocketHeartbeat(); + return; + } + + websocket.send(JSON.stringify({ type: 'ping' })); + }, ChatSessionService.signalingHeartbeatMs); + } + + private stopWebSocketHeartbeat(): void { + if (this.websocketHeartbeatIntervalId === null || typeof window === 'undefined') { + return; + } + + window.clearInterval(this.websocketHeartbeatIntervalId); + this.websocketHeartbeatIntervalId = null; + } + + private scheduleWebSocketReconnect(): void { + if (typeof window === 'undefined' || this.websocketReconnectTimeoutId !== null || !this.token() || !this.currentUser()) { + return; + } + + const delay = Math.min( + ChatSessionService.signalingReconnectBaseMs * 2 ** this.websocketReconnectAttempt, + ChatSessionService.signalingReconnectMaxMs, + ); + this.websocketReconnectAttempt += 1; + this.status.set(`Reconnecting to signaling server in ${Math.round(delay / 1000)}s.`); + + this.websocketReconnectTimeoutId = window.setTimeout(() => { + this.websocketReconnectTimeoutId = null; + void this.connectWebSocket(); + }, delay); + } + + private clearWebSocketReconnect(): void { + if (this.websocketReconnectTimeoutId === null || typeof window === 'undefined') { + return; + } + + window.clearTimeout(this.websocketReconnectTimeoutId); + this.websocketReconnectTimeoutId = null; + } + private mergePresence(peers: Array): void { const previous = new Map(this.peers().map((peer) => [peer.id, peer])); @@ -792,6 +1202,8 @@ export class ChatSessionService { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }), pendingCandidates: [], + pendingNegotiation: false, + cameraSenders: [], }; bundle.pc.onicecandidate = (event) => { @@ -816,10 +1228,44 @@ export class ChatSessionService { } }; + bundle.pc.onsignalingstatechange = () => { + if (bundle.pc.signalingState === 'stable' && bundle.pendingNegotiation) { + bundle.pendingNegotiation = false; + void this.negotiatePeer(peerId, bundle); + } + }; + bundle.pc.ondatachannel = (event) => { this.attachDataChannel(peerId, event.channel, bundle); }; + bundle.pc.ontrack = (event) => { + const [stream] = event.streams; + const remoteStream = stream ?? bundle.remoteCameraStream ?? new MediaStream(); + + if (!stream) { + remoteStream.addTrack(event.track); + } + + bundle.remoteCameraStream = remoteStream; + this.upsertRemoteVideoStream(peerId, remoteStream); + this.remoteVideoModalPeerId.set(peerId); + + event.track.onended = () => { + if (!bundle.remoteCameraStream) { + return; + } + + const remainingLiveTracks = bundle.remoteCameraStream + .getVideoTracks() + .filter((track) => track.readyState === 'live' && track !== event.track); + + if (remainingLiveTracks.length === 0) { + this.clearRemoteVideoState(peerId); + } + }; + }; + if (initiator) { const channel = bundle.pc.createDataChannel('privatechat'); this.attachDataChannel(peerId, channel, bundle); @@ -904,6 +1350,13 @@ export class ChatSessionService { case 'typing': this.setPeerTyping(peerId, envelope.active); break; + case 'camera-state': + if (envelope.active) { + this.remoteVideoModalPeerId.set(peerId); + } else { + this.clearRemoteVideoState(peerId); + } + break; } } @@ -956,6 +1409,35 @@ export class ChatSessionService { } } + private async negotiatePeer(peerId: string, bundle: PeerBundle): Promise { + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + return; + } + + if (bundle.pc.signalingState !== 'stable') { + bundle.pendingNegotiation = true; + return; + } + + const offer = await bundle.pc.createOffer(); + await bundle.pc.setLocalDescription(offer); + + this.sendSignal(peerId, { + type: 'sdp', + description: bundle.pc.localDescription!.toJSON(), + }); + } + + private sendCameraState(peerId: string, active: boolean): void { + const channel = this.peerBundles.get(peerId)?.channel; + + if (!channel || channel.readyState !== 'open') { + return; + } + + channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope)); + } + private sendSignal(peerId: string, signal: SignalPayload): void { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { return; @@ -975,22 +1457,63 @@ export class ChatSessionService { return channel; } + private async ensureOpenChannel(peerId: string): Promise { + const openChannel = this.requireOpenChannel(peerId); + + if (openChannel) { + return openChannel; + } + + await this.connectToPeer(peerId); + + return this.waitForOpenChannel(peerId); + } + private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise { while (channel.bufferedAmount > threshold) { await new Promise((resolve) => window.setTimeout(resolve, 25)); } } + private async waitForOpenChannel(peerId: string, timeoutMs = 8000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const channel = this.peerBundles.get(peerId)?.channel; + + if (channel?.readyState === 'open') { + return channel; + } + + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + + this.error.set('Could not open a peer channel for forwarding.'); + return null; + } + private releasePeerBundle(peerId: string, preservePeerState: boolean): void { const bundle = this.peerBundles.get(peerId); this.clearPeerTyping(peerId); this.clearOutgoingTyping(peerId); + this.clearRemoteVideoState(peerId); if (!bundle) { return; } + if (bundle.localCameraStream) { + for (const track of bundle.localCameraStream.getTracks()) { + track.onended = null; + track.stop(); + } + } + + if (this.activeCameraPeerId() === peerId) { + this.activeCameraPeerId.set(null); + } + bundle.channel?.close(); bundle.pc.close(); this.peerBundles.delete(peerId); @@ -1143,10 +1666,16 @@ export class ChatSessionService { } private clearLocalAuth(statusMessage: string): void { + this.clearWebSocketReconnect(); this.disconnectWebSocket(); this.resetPeerConnections(); + this.stopSessionKeepalive(); this.clearSystemMessageTimeouts(); this.clearTypingTimeouts(); + this.pendingImageGenerationRequests.clear(); + this.remoteVideoStreams.set([]); + this.remoteVideoModalPeerId.set(null); + this.activeCameraPeerId.set(null); this.messageEncryptionKey = null; this.revokeMessageDownloads(this.messages()); this.currentUser.set(null); @@ -1576,6 +2105,28 @@ export class ChatSessionService { this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); } + private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void { + this.remoteVideoStreams.update((entries) => { + const existingIndex = entries.findIndex((entry) => entry.peerId === peerId); + + if (existingIndex === -1) { + return [...entries, { peerId, stream }]; + } + + const nextEntries = [...entries]; + nextEntries[existingIndex] = { peerId, stream }; + return nextEntries; + }); + } + + private clearRemoteVideoState(peerId: string): void { + this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); + + if (this.remoteVideoModalPeerId() === peerId) { + this.remoteVideoModalPeerId.set(null); + } + } + private setPeerTyping(peerId: string, active: boolean): void { const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId); @@ -1694,6 +2245,32 @@ export class ChatSessionService { }); } + private base64ToBlob(value: string, mimeType: string): Blob { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return new Blob([bytes], { type: mimeType }); + } + + private fileExtensionForMimeType(mimeType: string): string { + switch (mimeType) { + case 'image/png': + return 'png'; + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + case 'image/gif': + return 'gif'; + default: + return 'bin'; + } + } + private toWebSocketUrl(httpUrl: string, token: string): string { const normalized = new URL(httpUrl); normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:'; diff --git a/client/src/app/home-page.component.html b/client/src/app/home-page.component.html index 994b3a8..f78b48d 100644 --- a/client/src/app/home-page.component.html +++ b/client/src/app/home-page.component.html @@ -261,6 +261,63 @@ }
+ + @if (session.isApprovalAdmin()) { +
+
+
+

User administration

+

Delete any user account directly from SQLite.

+
+ +
+ + @if (adminUsersError()) { +
{{ adminUsersError() }}
+ } + +
+ @if (loadingAdminUsers()) { +
Loading users...
+ } @else if (adminUsers().length === 0) { +
No users found.
+ } @else { + @for (user of adminUsers(); track user.id) { +
+
+
+
{{ user.displayName }}
+
@{{ user.username }}
+
+ {{ user.isActive ? 'Approved' : 'Pending approval' }} + @if (user.approvedAt) { + · {{ user.approvedAt | date: 'short' }} + } +
+
Created: {{ user.createdAt | date: 'medium' }}
+
+ +
+
+ } + } +
+
+ } diff --git a/client/src/app/home-page.component.ts b/client/src/app/home-page.component.ts index b6adb83..2a1e2c8 100644 --- a/client/src/app/home-page.component.ts +++ b/client/src/app/home-page.component.ts @@ -1,9 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, effect, inject } from '@angular/core'; +import { Component, effect, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router, RouterLink } from '@angular/router'; import { ChatSessionService } from './chat-session.service'; +import type { AdminUserSummary } from './models'; import { ThemeService } from './theme.service'; @Component({ @@ -23,6 +24,10 @@ export class HomePageComponent { username = ''; password = ''; accessKeyLabel = ''; + readonly adminUsers = signal([]); + readonly loadingAdminUsers = signal(false); + readonly deletingUserId = signal(null); + readonly adminUsersError = signal(null); constructor(readonly session: ChatSessionService) { this.serverUrl = session.serverUrl(); @@ -39,6 +44,19 @@ export class HomePageComponent { void this.router.navigate(['/chat', activePeerId], { replaceUrl: true }); }); } + + effect(() => { + const currentUser = this.session.currentUser(); + + if (!currentUser || !this.session.isApprovalAdmin()) { + this.adminUsers.set([]); + this.adminUsersError.set(null); + this.loadingAdminUsers.set(false); + return; + } + + void this.reloadAdminUsers(); + }); } async submitAuth(): Promise { @@ -80,6 +98,44 @@ export class HomePageComponent { this.accessKeyLabel = ''; } + async reloadAdminUsers(): Promise { + this.loadingAdminUsers.set(true); + this.adminUsersError.set(null); + + try { + this.adminUsers.set(await this.session.loadAdminUsers()); + } catch (error) { + this.adminUsersError.set( + error instanceof Error ? error.message : 'Could not load users.', + ); + } finally { + this.loadingAdminUsers.set(false); + } + } + + async deleteUser(user: AdminUserSummary): Promise { + if ( + typeof window !== 'undefined' && + !window.confirm(`Delete user ${user.username}? This removes the account from SQLite.`) + ) { + return; + } + + this.deletingUserId.set(user.id); + this.adminUsersError.set(null); + + try { + await this.session.deleteUserAccount(user.id); + this.adminUsers.update((users) => users.filter((candidate) => candidate.id !== user.id)); + } catch (error) { + this.adminUsersError.set( + error instanceof Error ? error.message : 'Could not delete that user.', + ); + } finally { + this.deletingUserId.set(null); + } + } + async openChatUi(): Promise { const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id; diff --git a/client/src/app/json-file-viewer.component.scss b/client/src/app/json-file-viewer.component.scss index e99b909..1abc33d 100644 --- a/client/src/app/json-file-viewer.component.scss +++ b/client/src/app/json-file-viewer.component.scss @@ -1,11 +1,11 @@ :host { display: block; - max-width: 95%; + max-width: min(95%, 320px); } .json-viewer-shell { - width: 95%; - max-width: 95%; + width: min(95%, 480px); + max-width: min(95%, 480px); min-width: 0; overflow: hidden; border-radius: 0.9rem; diff --git a/client/src/app/models.ts b/client/src/app/models.ts index 862ac08..b549d87 100644 --- a/client/src/app/models.ts +++ b/client/src/app/models.ts @@ -35,6 +35,15 @@ export interface PendingApprovalUser { createdAt: string; } +export interface AdminUserSummary { + id: string; + username: string; + displayName: string; + isActive: boolean; + createdAt: string; + approvedAt: string | null; +} + export interface AccessKeySummary { id: string; credentialId: string; @@ -105,6 +114,22 @@ export type ServerEvent = | { type: 'peer-joined'; peer: UserProfile } | { type: 'peer-left'; peerId: string } | { type: 'signal'; from: string; signal: SignalPayload } + | { + type: 'image-generated'; + requestId: string; + peerId: string; + prompt: string; + createdAt: number; + mimeType: string; + imageBase64: string; + } + | { + type: 'image-generation-error'; + requestId: string; + peerId: string; + message: string; + } + | { type: 'pong' } | { type: 'error'; message: string }; export type DataEnvelope = @@ -141,4 +166,8 @@ export type DataEnvelope = | { type: 'typing'; active: boolean; + } + | { + type: 'camera-state'; + active: boolean; }; diff --git a/client/src/app/peer-video-modal.component.scss b/client/src/app/peer-video-modal.component.scss new file mode 100644 index 0000000..1f649ab --- /dev/null +++ b/client/src/app/peer-video-modal.component.scss @@ -0,0 +1,54 @@ +:host { + display: contents; +} + +.video-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + place-items: center; + padding: 1.5rem; + background: rgba(3, 8, 14, 0.72); + backdrop-filter: blur(10px); +} + +.video-modal-card { + width: min(100%, 56rem); + border: 1px solid var(--surface-border); + border-radius: 1.5rem; + background: var(--panel-background); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); +} + +.video-modal-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; + padding: 1rem 1rem 0; +} + +.video-modal-close { + width: 2.5rem; + height: 2.5rem; + border: 0; + border-radius: 999px; + color: var(--page-text); + background: var(--badge-background); + font-size: 1.35rem; + line-height: 1; +} + +.video-modal-body { + padding: 1rem; +} + +.video-modal-player { + width: 100%; + display: block; + border-radius: 1rem; + background: #000; + aspect-ratio: 16 / 9; + object-fit: cover; +} diff --git a/client/src/app/peer-video-modal.component.ts b/client/src/app/peer-video-modal.component.ts new file mode 100644 index 0000000..bd62876 --- /dev/null +++ b/client/src/app/peer-video-modal.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from '@angular/common'; +import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core'; + +@Component({ + selector: 'app-peer-video-modal', + imports: [CommonModule], + template: ` + @if (visible) { +
+
+
+
+

{{ title }}

+

Live webcam capture from your peer.

+
+ +
+ +
+ +
+
+
+ } + `, + styleUrl: './peer-video-modal.component.scss', +}) +export class PeerVideoModalComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input() visible = false; + @Input() stream: MediaStream | null = null; + @Input() title = 'Live webcam'; + @Output() readonly closeRequested = new EventEmitter(); + @ViewChild('videoElement') + set videoElementRef(value: ElementRef | undefined) { + this.videoElement = value; + this.syncVideoSource(); + } + + private videoElement?: ElementRef; + + ngAfterViewInit(): void { + this.syncVideoSource(); + } + + ngOnChanges(): void { + this.syncVideoSource(); + } + + ngOnDestroy(): void { + this.detachVideoSource(); + } + + requestClose(): void { + this.closeRequested.emit(); + } + + private syncVideoSource(): void { + const video = this.videoElement?.nativeElement; + + if (!video) { + return; + } + + video.muted = true; + video.srcObject = this.visible ? this.stream : null; + + if (this.visible && this.stream) { + void video.play().catch(() => { + // Autoplay may be delayed until user interaction depending on platform policy. + }); + } + } + + private detachVideoSource(): void { + const video = this.videoElement?.nativeElement; + + if (!video) { + return; + } + + video.pause(); + video.srcObject = null; + } +} diff --git a/server/dist/index.js b/server/dist/index.js index 2ec0580..0849e77 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -40,6 +40,9 @@ const verifyAccessKeyAuthenticationSchema = z.object({ const approvePendingUserParamsSchema = z.object({ userId: z.string().min(1), }); +const adminDeleteUserParamsSchema = z.object({ + userId: z.string().min(1), +}); const wsQuerySchema = z.object({ token: z.string().min(1), }); @@ -66,15 +69,30 @@ const signalMessageSchema = z.discriminatedUnion('type', [ }), ]), }), + z.object({ + type: z.literal('image-generation'), + requestId: z.string().uuid(), + peerId: z.string().min(1), + prompt: z.string().trim().min(1).max(4000), + }), + z.object({ + type: z.literal('ping'), + }), ]); -const app = Fastify({ logger: true }); +const app = Fastify({ logger: true, trustProxy: true }); const approvalAdminUsername = 'ladparis'; const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data'); const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite')); const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key')); const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser'); +const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, ''); +const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest'; +const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024'; const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12); const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60); +const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN); +const corsAllowedHeaders = ['Authorization', 'Content-Type']; +const corsMethods = ['GET', 'POST', 'OPTIONS']; const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200'; const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat'; const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION); @@ -134,6 +152,11 @@ const selectPendingUsersStatement = database.prepare(` WHERE is_active = 0 ORDER BY created_at ASC `); +const selectAllUsersStatement = database.prepare(` + SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at + FROM users + ORDER BY created_at DESC +`); const approveUserStatement = database.prepare(` UPDATE users SET is_active = 1, approved_at = ? @@ -168,18 +191,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(` FROM webauthn_credentials WHERE credential_id = ? `); +const deleteAccessKeysByUserStatement = database.prepare(` + DELETE FROM webauthn_credentials + WHERE user_id = ? +`); const updateAccessKeyStatement = database.prepare(` UPDATE webauthn_credentials SET encrypted_registration = ? WHERE credential_id = ? `); +const deleteUserStatement = database.prepare(` + DELETE FROM users + WHERE id = ? +`); const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex')); const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0'); const socketsByUserId = new Map(); await redis.ping(); await app.register(cors, { - origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true, + origin(origin, callback) { + callback(null, isAllowedRequestOrigin(origin)); + }, credentials: false, + allowedHeaders: corsAllowedHeaders, + methods: corsMethods, }); await app.register(jwt, { secret: jwtSecret, @@ -405,6 +440,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => { user: toPublicUser(approvedUser), }; }); +app.get('/api/admin/users', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + if (!authContext) { + return; + } + if (!isApprovalAdmin(authContext.user)) { + return reply.code(403).send({ message: 'Only ladparis can delete users.' }); + } + return { + users: listAdminUsers(), + }; +}); +app.delete('/api/admin/users/:userId', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + if (!authContext) { + return; + } + if (!isApprovalAdmin(authContext.user)) { + return reply.code(403).send({ message: 'Only ladparis can delete users.' }); + } + const parsed = adminDeleteUserParamsSchema.safeParse(request.params); + if (!parsed.success) { + return reply.code(400).send({ + message: 'Invalid user deletion request.', + issues: parsed.error.flatten(), + }); + } + const deletedUser = await deleteUserAccount(parsed.data.userId); + if (!deletedUser) { + return reply.code(404).send({ message: 'User not found.' }); + } + return { + user: toPublicUser(deletedUser), + }; +}); app.post('/api/auth/logout', async (request, reply) => { const authContext = await authenticateRequest(request, reply); if (!authContext) { @@ -526,6 +596,11 @@ const port = Number(process.env.PORT ?? 16990); await app.listen({ port, host: '0.0.0.0' }); app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`); async function openSocket(socket, request) { + if (!isAllowedRequestOrigin(request.headers.origin)) { + send(socket, { type: 'error', message: 'Origin not allowed.' }); + socket.close(); + return; + } const query = wsQuerySchema.safeParse(request.query); if (!query.success) { send(socket, { type: 'error', message: 'Missing token.' }); @@ -574,6 +649,34 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) { send(socket, { type: 'error', message: 'Unsupported signaling message.' }); return; } + if (parsed.type === 'ping') { + send(socket, { type: 'pong' }); + return; + } + if (parsed.type === 'image-generation') { + try { + const generatedImage = await generateImageFromPrompt(parsed.prompt); + send(socket, { + type: 'image-generated', + requestId: parsed.requestId, + peerId: parsed.peerId, + prompt: parsed.prompt, + createdAt: Date.now(), + mimeType: generatedImage.mimeType, + imageBase64: generatedImage.imageBase64, + }); + } + catch (error) { + app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed'); + send(socket, { + type: 'image-generation-error', + requestId: parsed.requestId, + peerId: parsed.peerId, + message: error instanceof Error ? error.message : 'Image generation failed.', + }); + } + return; + } let delivered = 0; const recipientSockets = socketsByUserId.get(parsed.to); if (recipientSockets) { @@ -683,6 +786,17 @@ function listPendingApprovalUsers() { createdAt: row.created_at, })); } +function listAdminUsers() { + const rows = selectAllUsersStatement.all(); + return rows.map((row) => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + isActive: row.is_active === 1, + createdAt: row.created_at, + approvedAt: row.approved_at, + })); +} function approveUser(userId) { const approvedAt = new Date().toISOString(); const result = approveUserStatement.run(approvedAt, userId); @@ -691,6 +805,19 @@ function approveUser(userId) { } return findUserById(userId); } +async function deleteUserAccount(userId) { + const user = findUserById(userId); + if (!user) { + return null; + } + deleteAccessKeysByUserStatement.run(userId); + const result = deleteUserStatement.run(userId); + if (result.changes === 0) { + return null; + } + await destroyUserSessions(userId); + return user; +} function persistAccessKey(userId, input) { createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({ credentialId: input.credentialId, @@ -802,6 +929,32 @@ async function getSession(sessionId) { async function destroySession(sessionId) { await redis.del(sessionKey(sessionId)); } +async function destroyUserSessions(userId) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100); + cursor = nextCursor; + for (const key of keys) { + const payload = await redis.get(key); + if (!payload) { + continue; + } + let session = null; + try { + session = JSON.parse(payload); + } + catch { + session = null; + } + if (!session || session.userId !== userId) { + continue; + } + await destroySession(session.sessionId); + await clearPendingRegistration(session.sessionId); + closeSocketSession(userId, session.sessionId); + } + } while (cursor !== '0'); +} function sessionKey(sessionId) { return `privatechat:session:${sessionId}`; } @@ -889,12 +1042,87 @@ function parseClientMessage(rawMessage) { if (!parsed.success) { return null; } + if (parsed.data.type === 'ping') { + return { type: 'ping' }; + } + if (parsed.data.type === 'image-generation') { + return { + type: 'image-generation', + requestId: parsed.data.requestId, + peerId: parsed.data.peerId, + prompt: parsed.data.prompt, + }; + } return { type: 'signal', to: parsed.data.to, signal: normalizeSignal(parsed.data.signal), }; } +async function generateImageFromPrompt(prompt) { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 120_000); + try { + const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: ollamaImageModel, + prompt, + size: ollamaImageSize, + response_format: 'b64_json', + n: 1, + }), + signal: abortController.signal, + }); + const payload = await response.json(); + if (!response.ok) { + const errorMessage = typeof payload.error === 'string' + ? payload.error + : payload.error?.message; + throw new Error(errorMessage || 'Ollama image generation request failed.'); + } + const imageBase64 = payload.data?.[0]?.b64_json?.trim(); + if (!imageBase64) { + throw new Error('Ollama did not return image data.'); + } + return { + imageBase64, + mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')), + }; + } + catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Ollama image generation timed out.'); + } + throw error; + } + finally { + clearTimeout(timeoutId); + } +} +function inferImageMimeType(imageBuffer) { + if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return 'image/png'; + } + if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) { + return 'image/jpeg'; + } + if (imageBuffer.length >= 12 && + imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' && + imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP') { + return 'image/webp'; + } + if (imageBuffer.length >= 6) { + const header = imageBuffer.subarray(0, 6).toString('ascii'); + if (header === 'GIF87a' || header === 'GIF89a') { + return 'image/gif'; + } + } + return 'application/octet-stream'; +} function normalizeSignal(signal) { if (signal.type === 'sdp') { return { @@ -1001,6 +1229,35 @@ function resolveStoragePath(targetPath) { function resolveProjectPath(targetPath) { return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath); } +function parseAllowedOrigins(value) { + if (!value) { + return new Set(); + } + return new Set(value + .split(',') + .map((origin) => normalizeOrigin(origin)) + .filter((origin) => origin.length > 0 && origin !== 'null')); +} +function normalizeOrigin(origin) { + const trimmed = origin.trim(); + if (trimmed === 'null') { + return trimmed; + } + return trimmed.replace(/\/+$/, ''); +} +function isAllowedRequestOrigin(originHeader) { + if (!originHeader) { + return true; + } + const origin = normalizeOrigin(originHeader); + if (origin === 'null') { + return true; + } + if (allowedCorsOrigins.size === 0) { + return true; + } + return allowedCorsOrigins.has(origin); +} function resolveWebAuthnOrigin(request) { const originHeader = request.headers.origin; if (typeof originHeader === 'string' && originHeader.length > 0) { diff --git a/server/src/index.ts b/server/src/index.ts index 8372a57..02a60ff 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -84,6 +84,15 @@ type PendingApprovalUser = { createdAt: string; }; +type AdminUserSummary = { + id: string; + username: string; + displayName: string; + isActive: boolean; + createdAt: string; + approvedAt: string | null; +}; + type DatabaseAccessKeyRow = { id: string; user_id: string; @@ -97,17 +106,43 @@ type SignalPayload = | { type: 'sdp'; description: RTCSessionDescriptionInit } | { type: 'ice-candidate'; candidate: RTCIceCandidateInit }; -type ClientMessage = { - type: 'signal'; - to: string; - signal: SignalPayload; -}; +type ClientMessage = + | { + type: 'signal'; + to: string; + signal: SignalPayload; + } + | { + type: 'image-generation'; + requestId: string; + peerId: string; + prompt: string; + } + | { + type: 'ping'; + }; type ServerMessage = | { type: 'presence'; self: PublicUser; peers: PublicUser[] } | { type: 'peer-joined'; peer: PublicUser } | { type: 'peer-left'; peerId: string } | { type: 'signal'; from: string; signal: SignalPayload } + | { + type: 'image-generated'; + requestId: string; + peerId: string; + prompt: string; + createdAt: number; + mimeType: string; + imageBase64: string; + } + | { + type: 'image-generation-error'; + requestId: string; + peerId: string; + message: string; + } + | { type: 'pong' } | { type: 'error'; message: string }; type StoredCredentials = { @@ -194,6 +229,10 @@ const approvePendingUserParamsSchema = z.object({ userId: z.string().min(1), }); +const adminDeleteUserParamsSchema = z.object({ + userId: z.string().min(1), +}); + const wsQuerySchema = z.object({ token: z.string().min(1), }); @@ -221,9 +260,18 @@ const signalMessageSchema = z.discriminatedUnion('type', [ }), ]), }), + z.object({ + type: z.literal('image-generation'), + requestId: z.string().uuid(), + peerId: z.string().min(1), + prompt: z.string().trim().min(1).max(4000), + }), + z.object({ + type: z.literal('ping'), + }), ]); -const app = Fastify({ logger: true }); +const app = Fastify({ logger: true, trustProxy: true }); const approvalAdminUsername = 'ladparis'; const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data'); @@ -236,8 +284,14 @@ const masterKeyPath = resolveStoragePath( const frontendDistPath = resolveProjectPath( process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser', ); +const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, ''); +const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest'; +const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024'; const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12); const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60); +const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN); +const corsAllowedHeaders = ['Authorization', 'Content-Type']; +const corsMethods = ['GET', 'POST', 'OPTIONS']; const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200'; const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat'; const webAuthnUserVerification = resolveWebAuthnUserVerification( @@ -304,6 +358,11 @@ const selectPendingUsersStatement = database.prepare(` WHERE is_active = 0 ORDER BY created_at ASC `); +const selectAllUsersStatement = database.prepare(` + SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at + FROM users + ORDER BY created_at DESC +`); const approveUserStatement = database.prepare(` UPDATE users SET is_active = 1, approved_at = ? @@ -338,11 +397,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(` FROM webauthn_credentials WHERE credential_id = ? `); +const deleteAccessKeysByUserStatement = database.prepare(` + DELETE FROM webauthn_credentials + WHERE user_id = ? +`); const updateAccessKeyStatement = database.prepare(` UPDATE webauthn_credentials SET encrypted_registration = ? WHERE credential_id = ? `); +const deleteUserStatement = database.prepare(` + DELETE FROM users + WHERE id = ? +`); const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex')); const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0'); @@ -351,8 +418,12 @@ const socketsByUserId = new Map>(); await redis.ping(); await app.register(cors, { - origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true, + origin(origin, callback) { + callback(null, isAllowedRequestOrigin(origin)); + }, credentials: false, + allowedHeaders: corsAllowedHeaders, + methods: corsMethods, }); await app.register(jwt, { @@ -664,6 +735,53 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => { }; }); +app.get('/api/admin/users', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + + if (!authContext) { + return; + } + + if (!isApprovalAdmin(authContext.user)) { + return reply.code(403).send({ message: 'Only ladparis can delete users.' }); + } + + return { + users: listAdminUsers(), + }; +}); + +app.delete('/api/admin/users/:userId', async (request, reply) => { + const authContext = await authenticateRequest(request, reply); + + if (!authContext) { + return; + } + + if (!isApprovalAdmin(authContext.user)) { + return reply.code(403).send({ message: 'Only ladparis can delete users.' }); + } + + const parsed = adminDeleteUserParamsSchema.safeParse(request.params); + + if (!parsed.success) { + return reply.code(400).send({ + message: 'Invalid user deletion request.', + issues: parsed.error.flatten(), + }); + } + + const deletedUser = await deleteUserAccount(parsed.data.userId); + + if (!deletedUser) { + return reply.code(404).send({ message: 'User not found.' }); + } + + return { + user: toPublicUser(deletedUser), + }; +}); + app.post('/api/auth/logout', async (request, reply) => { const authContext = await authenticateRequest(request, reply); @@ -829,6 +947,12 @@ await app.listen({ port, host: '0.0.0.0' }); app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`); async function openSocket(socket: WebSocket, request: FastifyRequest): Promise { + if (!isAllowedRequestOrigin(request.headers.origin)) { + send(socket, { type: 'error', message: 'Origin not allowed.' }); + socket.close(); + return; + } + const query = wsQuerySchema.safeParse(request.query); if (!query.success) { @@ -901,6 +1025,37 @@ async function handleSocketMessage( return; } + if (parsed.type === 'ping') { + send(socket, { type: 'pong' }); + return; + } + + if (parsed.type === 'image-generation') { + try { + const generatedImage = await generateImageFromPrompt(parsed.prompt); + + send(socket, { + type: 'image-generated', + requestId: parsed.requestId, + peerId: parsed.peerId, + prompt: parsed.prompt, + createdAt: Date.now(), + mimeType: generatedImage.mimeType, + imageBase64: generatedImage.imageBase64, + }); + } catch (error) { + app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed'); + send(socket, { + type: 'image-generation-error', + requestId: parsed.requestId, + peerId: parsed.peerId, + message: error instanceof Error ? error.message : 'Image generation failed.', + }); + } + + return; + } + let delivered = 0; const recipientSockets = socketsByUserId.get(parsed.to); @@ -1056,6 +1211,19 @@ function listPendingApprovalUsers(): PendingApprovalUser[] { })); } +function listAdminUsers(): AdminUserSummary[] { + const rows = selectAllUsersStatement.all() as DatabaseUserRow[]; + + return rows.map((row) => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + isActive: row.is_active === 1, + createdAt: row.created_at, + approvedAt: row.approved_at, + })); +} + function approveUser(userId: string): UserRecord | null { const approvedAt = new Date().toISOString(); const result = approveUserStatement.run(approvedAt, userId); @@ -1067,6 +1235,25 @@ function approveUser(userId: string): UserRecord | null { return findUserById(userId); } +async function deleteUserAccount(userId: string): Promise { + const user = findUserById(userId); + + if (!user) { + return null; + } + + deleteAccessKeysByUserStatement.run(userId); + + const result = deleteUserStatement.run(userId); + + if (result.changes === 0) { + return null; + } + + await destroyUserSessions(userId); + return user; +} + function persistAccessKey( userId: string, input: { @@ -1248,6 +1435,39 @@ async function destroySession(sessionId: string): Promise { await redis.del(sessionKey(sessionId)); } +async function destroyUserSessions(userId: string): Promise { + let cursor = '0'; + + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100); + cursor = nextCursor; + + for (const key of keys) { + const payload = await redis.get(key); + + if (!payload) { + continue; + } + + let session: SessionRecord | null = null; + + try { + session = JSON.parse(payload) as SessionRecord; + } catch { + session = null; + } + + if (!session || session.userId !== userId) { + continue; + } + + await destroySession(session.sessionId); + await clearPendingRegistration(session.sessionId); + closeSocketSession(userId, session.sessionId); + } + } while (cursor !== '0'); +} + function sessionKey(sessionId: string): string { return `privatechat:session:${sessionId}`; } @@ -1364,6 +1584,19 @@ function parseClientMessage(rawMessage: string): ClientMessage | null { return null; } + if (parsed.data.type === 'ping') { + return { type: 'ping' }; + } + + if (parsed.data.type === 'image-generation') { + return { + type: 'image-generation', + requestId: parsed.data.requestId, + peerId: parsed.data.peerId, + prompt: parsed.data.prompt, + }; + } + return { type: 'signal', to: parsed.data.to, @@ -1371,7 +1604,88 @@ function parseClientMessage(rawMessage: string): ClientMessage | null { }; } -function normalizeSignal(signal: ClientMessage['signal']): SignalPayload { +async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 120_000); + + try { + const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: ollamaImageModel, + prompt, + size: ollamaImageSize, + response_format: 'b64_json', + n: 1, + }), + signal: abortController.signal, + }); + + const payload = await response.json() as { + error?: { message?: string } | string; + data?: Array<{ b64_json?: string }>; + }; + + if (!response.ok) { + const errorMessage = typeof payload.error === 'string' + ? payload.error + : payload.error?.message; + throw new Error(errorMessage || 'Ollama image generation request failed.'); + } + + const imageBase64 = payload.data?.[0]?.b64_json?.trim(); + + if (!imageBase64) { + throw new Error('Ollama did not return image data.'); + } + + return { + imageBase64, + mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')), + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Ollama image generation timed out.'); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +function inferImageMimeType(imageBuffer: Buffer): string { + if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return 'image/png'; + } + + if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) { + return 'image/jpeg'; + } + + if ( + imageBuffer.length >= 12 && + imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' && + imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + + if (imageBuffer.length >= 6) { + const header = imageBuffer.subarray(0, 6).toString('ascii'); + + if (header === 'GIF87a' || header === 'GIF89a') { + return 'image/gif'; + } + } + + return 'application/octet-stream'; +} + +function normalizeSignal(signal: Extract['signal']): SignalPayload { if (signal.type === 'sdp') { return { type: 'sdp', @@ -1508,6 +1822,47 @@ function resolveProjectPath(targetPath: string): string { return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath); } +function parseAllowedOrigins(value: string | undefined): Set { + if (!value) { + return new Set(); + } + + return new Set( + value + .split(',') + .map((origin) => normalizeOrigin(origin)) + .filter((origin) => origin.length > 0 && origin !== 'null'), + ); +} + +function normalizeOrigin(origin: string): string { + const trimmed = origin.trim(); + + if (trimmed === 'null') { + return trimmed; + } + + return trimmed.replace(/\/+$/, ''); +} + +function isAllowedRequestOrigin(originHeader: string | undefined): boolean { + if (!originHeader) { + return true; + } + + const origin = normalizeOrigin(originHeader); + + if (origin === 'null') { + return true; + } + + if (allowedCorsOrigins.size === 0) { + return true; + } + + return allowedCorsOrigins.has(origin); +} + function resolveWebAuthnOrigin(request: FastifyRequest): string { const originHeader = request.headers.origin;