Many new functionalities

This commit is contained in:
2026-03-10 02:49:27 +01:00
parent 640d92d231
commit 01e8f1e3dc
15 changed files with 1920 additions and 97 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ server/server/data/privatechat.sqlite-shm
server/server/data/privatechat.sqlite-wal server/server/data/privatechat.sqlite-wal
server/server/data/master.key server/server/data/master.key
client/dist/* client/dist/*
client/apple-client/WebApp/**

View File

@@ -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`: Optional master key for encrypting SQLite secret material and user credentials.
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key. - `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`. - `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_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_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`. - `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.

View File

@@ -1,3 +1,3 @@
window.__PRIVATECHAT_ENV__ = { window.__PRIVATECHAT_ENV__ = {
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr" "PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
}; };

View File

@@ -1,6 +1,13 @@
<main class="chat-shell py-4"> <main class="chat-shell py-4">
<div class="container-lg"> <div class="container-lg">
<section class="chat-page panel p-3 p-lg-4"> <section class="chat-page panel p-3 p-lg-4">
<app-peer-video-modal
[visible]="remoteVideoModalVisible()"
[stream]="remoteVideoStream()"
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
(closeRequested)="closeRemoteVideoModal()"
></app-peer-video-modal>
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4"> <div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
<div> <div>
<a class="back-link" routerLink="/">← Back to dashboard</a> <a class="back-link" routerLink="/">← Back to dashboard</a>
@@ -100,18 +107,41 @@
[class.bubble-outgoing]="entry.direction === 'outgoing'" [class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'" [class.bubble-system]="entry.direction === 'system'"
> >
<button @if (entry.direction !== 'system') {
class="bubble-delete" <div class="bubble-actions">
type="button" <button
(click)="deleteMessage(entry)" class="bubble-action"
title="Delete message" type="button"
aria-label="Delete message" (click)="toggleForwardMenu(entry, $event)"
> title="Forward message"
× aria-label="Forward message"
</button> >
</button>
<button
class="bubble-action bubble-delete"
type="button"
(click)="deleteMessage(entry)"
title="Delete message"
aria-label="Delete message"
>
×
</button>
@if (isForwardMenuOpen(entry.id)) {
<div class="bubble-forward-menu">
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
<option value="">Forward to…</option>
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
}
</select>
</div>
}
</div>
}
<div class="bubble-meta"> <div class="bubble-meta">
<span>{{ entry.authorLabel }}</span> <span class="bubble-author">{{ entry.authorLabel }}</span>
<time>{{ entry.createdAt | date: 'shortTime' }}</time> <time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
</div> </div>
@switch (entry.kind) { @switch (entry.kind) {
@@ -131,6 +161,18 @@
/> />
} }
@if (isVideoEntry(entry)) {
<video
class="bubble-video"
[src]="entry.downloadUrl"
controls
autoplay
muted
playsinline
preload="metadata"
></video>
}
@if (isIncomingJsonFileEntry(entry)) { @if (isIncomingJsonFileEntry(entry)) {
<app-json-file-viewer [entry]="entry"></app-json-file-viewer> <app-json-file-viewer [entry]="entry"></app-json-file-viewer>
} }
@@ -157,6 +199,18 @@
<div class="composer"> <div class="composer">
@if (peer(); as selectedPeer) { @if (peer(); as selectedPeer) {
<div class="composer-actions">
<button
class="composer-camera"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
(click)="toggleCameraStream(selectedPeer.id)"
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
>
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
</button>
<input <input
#fileInput #fileInput
class="composer-file-input" class="composer-file-input"
@@ -174,27 +228,74 @@
> >
+ +
</button> </button>
</div>
} }
<textarea <textarea
#composerTextarea
class="form-control composer-textarea" class="form-control composer-textarea"
rows="3" rows="3"
[(ngModel)]="messageText" [(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)" (ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)" (keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()" [disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer" placeholder="Write a text message to your peer"
></textarea> ></textarea>
<button
class="send-emoji" <div class="composer-send">
type="button" <button
[disabled]="!session.isSelectedPeerReady()" class="composer-image-generate"
(click)="sendMessage()" type="button"
title="Send message" [disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
aria-label="Send message" (click)="requestGeneratedImage()"
> title="Generate image from prompt"
aria-label="Generate image from prompt"
</button> >
🖼️
</button>
<div class="composer-emoji-picker-shell">
@if (emojiPickerOpen()) {
<div class="composer-emoji-picker">
@for (emoji of emojiOptions; track emoji) {
<button
class="composer-emoji-option"
type="button"
(click)="insertEmoji(emoji, composerTextarea)"
[attr.aria-label]="'Insert ' + emoji"
[title]="'Insert ' + emoji"
>
{{ emoji }}
</button>
}
</div>
}
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -211,23 +211,55 @@
position: relative; position: relative;
align-self: start; align-self: start;
max-width: min(75%, 34rem); max-width: min(75%, 34rem);
padding: 0.9rem 1rem; padding: 0.9rem 3.4rem 0.9rem 1rem;
border-radius: 1.2rem; border-radius: 1.2rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
} }
.bubble-delete { .bubble-actions {
position: absolute; position: absolute;
top: 0.45rem; top: 0.45rem;
right: 0.55rem; right: 0.55rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.bubble-action {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
padding: 0;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
color: #fff; color: #fff;
background: var(--danger-background); background: var(--badge-background);
line-height: 1; 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 { .bubble-incoming {
@@ -250,14 +282,21 @@
} }
.bubble-meta { .bubble-meta {
display: flex; display: grid;
justify-content: space-between; gap: 0.12rem;
gap: 1rem;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
font-size: 0.78rem; font-size: 0.78rem;
opacity: 0.7; opacity: 0.7;
} }
.bubble-author {
font-weight: 600;
}
.bubble-time {
display: block;
}
.composer { .composer {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
@@ -268,10 +307,27 @@
border-top: 1px solid var(--surface-border-soft); 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 { .composer-file-input {
display: none; display: none;
} }
.composer-camera,
.composer-image-generate,
.composer-emoji-trigger,
.composer-plus, .composer-plus,
.send-emoji { .send-emoji {
width: 3.25rem; width: 3.25rem;
@@ -293,6 +349,21 @@
color: var(--placeholder-color); 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 { .composer-plus {
color: var(--page-text); color: var(--page-text);
background: var(--badge-background); background: var(--badge-background);
@@ -302,6 +373,41 @@
background: linear-gradient(135deg, #def7dd, #9bd5ff); 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 { .bubble-image {
width: 200px; width: 200px;
max-width: 100%; max-width: 100%;
@@ -310,6 +416,15 @@
display: block; display: block;
} }
.bubble-video {
width: 200px;
max-width: 100%;
height: auto;
display: block;
border-radius: 1rem;
background: #000;
}
.bubble-download { .bubble-download {
color: inherit; color: inherit;
font-weight: 600; font-weight: 600;

View File

@@ -1,16 +1,17 @@
import { CommonModule } from '@angular/common'; 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 { 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 { PeerVideoModalComponent } from './peer-video-modal.component';
import { ChatSessionService } from './chat-session.service'; import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component'; import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState } from './models'; import type { ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({ @Component({
selector: 'app-chat-page', selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent], imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
templateUrl: './chat-page.component.html', templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
@@ -20,8 +21,22 @@ export class ChatPageComponent {
private readonly routeParamMap = toSignal(this.route.paramMap, { private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap, initialValue: this.route.snapshot.paramMap,
}); });
private composerSelectionStart = 0;
private composerSelectionEnd = 0;
messageText = ''; messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false);
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔',
'😅', '😭', '😡', '😴', '🙃',
'👍', '👎', '👏', '🙏', '🤝',
'🎉', '🔥', '❤️', '💡', '✅',
'🚀', '👀', '📹', '📎', '💬',
'🌍', '⚡', '⭐', '🎵', '📷',
'🗑️', '⏩', '🛑', '🙌', '👌',
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser()); readonly currentUser = computed(() => this.session.currentUser());
@@ -30,6 +45,10 @@ export class ChatPageComponent {
.messages() .messages()
.filter((entry) => entry.peerId === this.peerId()), .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<ConnectionState>(() => { readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer(); const selectedPeer = this.peer();
@@ -85,6 +104,19 @@ export class ChatPageComponent {
await this.session.sendText(peerId, this.messageText); await this.session.sendText(peerId, this.messageText);
this.messageText = ''; this.messageText = '';
this.emojiPickerOpen.set(false);
this.composerSelectionStart = 0;
this.composerSelectionEnd = 0;
}
async requestGeneratedImage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.session.requestGeneratedImage(peerId, this.messageText);
} }
handleComposerEnter(event: Event): void { handleComposerEnter(event: Event): void {
@@ -106,6 +138,37 @@ export class ChatPageComponent {
this.session.notifyTypingActivity(peerId, text); 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<void> { async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0); const file = input.files?.item(0);
@@ -126,10 +189,64 @@ export class ChatPageComponent {
await this.session.deleteConversation(peerId); 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<void> {
if (!targetPeerId) {
return;
}
await this.session.forwardMessage(targetPeerId, entry);
select.value = '';
this.forwardingEntryId.set(null);
}
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
return;
}
await this.session.startCameraStream(peerId);
}
isImageEntry(entry: ChatEntry): boolean { isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); 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 { isIncomingJsonFileEntry(entry: ChatEntry): boolean {
return ( return (
entry.kind === 'file' && entry.kind === 'file' &&
@@ -160,11 +277,29 @@ export class ChatPageComponent {
return this.indicatorTone(this.webRtcState()) === 'offline'; 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<void> { async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) { if (!peerId || peerId === this.peerId()) {
return; return;
} }
this.forwardingEntryId.set(null);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId); this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]); await this.router.navigate(['/chat', peerId]);
} }

View File

@@ -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 { computed, Injectable, signal } from '@angular/core';
import type { HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { import {
AccessKeySummary, AccessKeySummary,
AdminUserSummary,
AuthenticationOptionsResponse, AuthenticationOptionsResponse,
AuthResponse, AuthResponse,
ChatEntry, ChatEntry,
@@ -24,6 +24,10 @@ type PeerBundle = {
pc: RTCPeerConnection; pc: RTCPeerConnection;
channel?: RTCDataChannel; channel?: RTCDataChannel;
pendingCandidates: RTCIceCandidateInit[]; pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean;
localCameraStream?: MediaStream;
cameraSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream;
}; };
type IncomingFileTransfer = { type IncomingFileTransfer = {
@@ -101,6 +105,10 @@ export class ChatSessionService {
private static readonly messageDatabaseName = 'privatechat'; private static readonly messageDatabaseName = 'privatechat';
private static readonly messageStoreName = 'conversation_messages'; private static readonly messageStoreName = 'conversation_messages';
private static readonly messageRetentionLimit = 256; 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 systemMessageLifetimeMs = 5000;
private static readonly typingIndicatorLifetimeMs = 1800; private static readonly typingIndicatorLifetimeMs = 1800;
private static readonly typingIdleMs = 1200; private static readonly typingIdleMs = 1200;
@@ -114,6 +122,7 @@ export class ChatSessionService {
readonly messages = signal<ChatEntry[]>([]); readonly messages = signal<ChatEntry[]>([]);
readonly unreadPeerIds = signal<string[]>([]); readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]); readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly signalingState = signal<ConnectionState>('disconnected'); readonly signalingState = signal<ConnectionState>('disconnected');
readonly status = signal('Disconnected from signaling server.'); readonly status = signal('Disconnected from signaling server.');
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
@@ -148,6 +157,14 @@ export class ChatSessionService {
private readonly outgoingTypingIdleTimeouts = new Map<string, number>(); private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>(); private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
private readonly messageStoreOperations = new Map<string, Promise<void>>(); private readonly messageStoreOperations = new Map<string, Promise<void>>();
private readonly pendingImageGenerationRequests = new Map<string, { peerId: string; prompt: string }>();
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(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 messageEncryptionKey: CryptoKey | null = null;
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null; private messageDatabasePromise: Promise<IDBDatabase | null> | null = null;
private websocket: WebSocket | null = null; private websocket: WebSocket | null = null;
@@ -325,14 +342,107 @@ export class ChatSessionService {
this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' }); this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' });
this.addSystemMessage(peerId, 'Opening WebRTC data channel.'); this.addSystemMessage(peerId, 'Opening WebRTC data channel.');
await this.negotiatePeer(peerId, bundle);
}
const offer = await bundle.pc.createOffer(); async startCameraStream(peerId: string): Promise<void> {
await bundle.pc.setLocalDescription(offer); if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set('This browser does not support webcam capture.');
return;
}
this.sendSignal(peerId, { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
type: 'sdp', this.error.set('You must be connected to signaling before starting webcam capture.');
description: bundle.pc.localDescription!.toJSON(), 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<void> {
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<void> { async registerAccessKey(label: string): Promise<void> {
@@ -397,26 +507,7 @@ export class ChatSessionService {
return; return;
} }
const envelope: DataEnvelope = { this.sendTextEnvelope(peerId, channel, trimmed);
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,
});
} }
async sendJson(peerId: string, rawPayload: string): Promise<void> { async sendJson(peerId: string, rawPayload: string): Promise<void> {
@@ -439,25 +530,7 @@ export class ChatSessionService {
return; return;
} }
const envelope: DataEnvelope = { this.sendJsonEnvelope(peerId, channel, parsedPayload);
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,
});
} }
async sendFile(peerId: string, file: File): Promise<void> { async sendFile(peerId: string, file: File): Promise<void> {
@@ -506,6 +579,51 @@ export class ChatSessionService {
}, file); }, file);
} }
async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise<void> {
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<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);
@@ -533,9 +651,61 @@ export class ChatSessionService {
this.status.set(`Authenticated as ${response.user.displayName}.`); this.status.set(`Authenticated as ${response.user.displayName}.`);
await this.loadPersistedMessages(response.user.id); await this.loadPersistedMessages(response.user.id);
await this.loadAccessKeys(); await this.loadAccessKeys();
this.startSessionKeepalive();
await this.connectWebSocket(); 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<PendingApprovalUser[]> { async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
const token = this.token(); const token = this.token();
@@ -568,6 +738,69 @@ export class ChatSessionService {
); );
} }
async loadAdminUsers(): Promise<AdminUserSummary[]> {
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<void> {
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<void> {
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<void> { private async loadAccessKeys(): Promise<void> {
const token = this.token(); const token = this.token();
@@ -596,6 +829,7 @@ export class ChatSessionService {
return; return;
} }
this.clearWebSocketReconnect();
this.disconnectWebSocket(); this.disconnectWebSocket();
this.resetPeerConnections(); this.resetPeerConnections();
@@ -607,32 +841,65 @@ export class ChatSessionService {
this.websocket = websocket; this.websocket = websocket;
websocket.onopen = () => { websocket.onopen = () => {
if (this.websocket !== websocket) {
return;
}
this.websocketReconnectAttempt = 0;
this.startWebSocketHeartbeat(websocket);
this.signalingState.set('connected'); this.signalingState.set('connected');
this.status.set('Connected to signaling server.'); this.status.set('Connected to signaling server.');
}; };
websocket.onmessage = (event) => { websocket.onmessage = (event) => {
if (this.websocket !== websocket) {
return;
}
const message = JSON.parse(event.data) as ServerEvent; const message = JSON.parse(event.data) as ServerEvent;
void this.handleServerEvent(message); void this.handleServerEvent(message);
}; };
websocket.onerror = () => { websocket.onerror = () => {
if (this.websocket !== websocket) {
return;
}
this.signalingState.set('failed'); this.signalingState.set('failed');
this.error.set('The signaling socket encountered an error.'); this.error.set('The signaling socket encountered an error.');
}; };
websocket.onclose = () => { websocket.onclose = () => {
const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect;
this.stopWebSocketHeartbeat();
this.signalingState.set('disconnected'); this.signalingState.set('disconnected');
this.status.set('Signaling connection closed.'); this.status.set('Signaling connection closed.');
this.websocket = null;
if (this.websocket === websocket) {
this.websocket = null;
}
this.peers.update((peers) => this.peers.update((peers) =>
peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })), peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })),
); );
if (this.suppressSocketReconnect) {
this.suppressSocketReconnect = false;
return;
}
if (shouldReconnect) {
this.scheduleWebSocketReconnect();
}
}; };
} }
private disconnectWebSocket(): void { private disconnectWebSocket(): void {
this.stopWebSocketHeartbeat();
if (this.websocket) { if (this.websocket) {
this.suppressSocketReconnect = true;
this.websocket.close(); this.websocket.close();
this.websocket = null; this.websocket = null;
} }
@@ -652,8 +919,9 @@ export class ChatSessionService {
case 'peer-left': case 'peer-left':
this.releasePeerBundle(event.peerId, false); this.releasePeerBundle(event.peerId, false);
this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId)); this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId));
this.clearUnreadPeer(event.peerId); this.clearUnreadPeer(event.peerId);
this.clearPeerTyping(event.peerId); this.clearPeerTyping(event.peerId);
this.clearRemoteVideoState(event.peerId);
if (this.activePeerId() === event.peerId) { if (this.activePeerId() === event.peerId) {
this.activePeerId.set(this.peers()[0]?.id ?? null); this.activePeerId.set(this.peers()[0]?.id ?? null);
} }
@@ -662,6 +930,14 @@ export class ChatSessionService {
case 'signal': case 'signal':
await this.handleSignal(event.from, event.signal); await this.handleSignal(event.from, event.signal);
break; break;
case 'image-generated':
this.handleGeneratedImage(event);
break;
case 'image-generation-error':
this.handleGeneratedImageError(event);
break;
case 'pong':
break;
case 'error': case 'error':
this.error.set(event.message); this.error.set(event.message);
if (/auth|session/i.test(event.message)) { if (/auth|session/i.test(event.message)) {
@@ -671,6 +947,44 @@ export class ChatSessionService {
} }
} }
private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): 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<ServerEvent, { type: 'image-generation-error' }>): 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<void> { private async restoreSession(): Promise<void> {
const token = this.token(); const token = this.token();
@@ -694,12 +1008,108 @@ export class ChatSessionService {
this.writeStorage('privatechat.user', JSON.stringify(response.user)); this.writeStorage('privatechat.user', JSON.stringify(response.user));
await this.loadPersistedMessages(response.user.id); await this.loadPersistedMessages(response.user.id);
await this.loadAccessKeys(); await this.loadAccessKeys();
this.startSessionKeepalive();
await this.connectWebSocket(); await this.connectWebSocket();
} catch { } catch {
this.clearLocalAuth('Saved session expired. Sign in again.'); 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<void> {
const token = this.token();
if (!token) {
this.stopSessionKeepalive();
return;
}
try {
await firstValueFrom(
this.http.get<SessionResponse>(`${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<UserProfile | PeerSummary>): void { private mergePresence(peers: Array<UserProfile | PeerSummary>): void {
const previous = new Map(this.peers().map((peer) => [peer.id, peer])); 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' }], iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
}), }),
pendingCandidates: [], pendingCandidates: [],
pendingNegotiation: false,
cameraSenders: [],
}; };
bundle.pc.onicecandidate = (event) => { 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) => { bundle.pc.ondatachannel = (event) => {
this.attachDataChannel(peerId, event.channel, bundle); 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) { if (initiator) {
const channel = bundle.pc.createDataChannel('privatechat'); const channel = bundle.pc.createDataChannel('privatechat');
this.attachDataChannel(peerId, channel, bundle); this.attachDataChannel(peerId, channel, bundle);
@@ -904,6 +1350,13 @@ export class ChatSessionService {
case 'typing': case 'typing':
this.setPeerTyping(peerId, envelope.active); this.setPeerTyping(peerId, envelope.active);
break; 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<void> {
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 { private sendSignal(peerId: string, signal: SignalPayload): void {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return; return;
@@ -975,22 +1457,63 @@ export class ChatSessionService {
return channel; return channel;
} }
private async ensureOpenChannel(peerId: string): Promise<RTCDataChannel | null> {
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<void> { private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> {
while (channel.bufferedAmount > threshold) { while (channel.bufferedAmount > threshold) {
await new Promise((resolve) => window.setTimeout(resolve, 25)); await new Promise((resolve) => window.setTimeout(resolve, 25));
} }
} }
private async waitForOpenChannel(peerId: string, timeoutMs = 8000): Promise<RTCDataChannel | null> {
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 { private releasePeerBundle(peerId: string, preservePeerState: boolean): void {
const bundle = this.peerBundles.get(peerId); const bundle = this.peerBundles.get(peerId);
this.clearPeerTyping(peerId); this.clearPeerTyping(peerId);
this.clearOutgoingTyping(peerId); this.clearOutgoingTyping(peerId);
this.clearRemoteVideoState(peerId);
if (!bundle) { if (!bundle) {
return; 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.channel?.close();
bundle.pc.close(); bundle.pc.close();
this.peerBundles.delete(peerId); this.peerBundles.delete(peerId);
@@ -1143,10 +1666,16 @@ export class ChatSessionService {
} }
private clearLocalAuth(statusMessage: string): void { private clearLocalAuth(statusMessage: string): void {
this.clearWebSocketReconnect();
this.disconnectWebSocket(); this.disconnectWebSocket();
this.resetPeerConnections(); this.resetPeerConnections();
this.stopSessionKeepalive();
this.clearSystemMessageTimeouts(); this.clearSystemMessageTimeouts();
this.clearTypingTimeouts(); this.clearTypingTimeouts();
this.pendingImageGenerationRequests.clear();
this.remoteVideoStreams.set([]);
this.remoteVideoModalPeerId.set(null);
this.activeCameraPeerId.set(null);
this.messageEncryptionKey = null; this.messageEncryptionKey = null;
this.revokeMessageDownloads(this.messages()); this.revokeMessageDownloads(this.messages());
this.currentUser.set(null); this.currentUser.set(null);
@@ -1576,6 +2105,28 @@ export class ChatSessionService {
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId)); 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 { private setPeerTyping(peerId: string, active: boolean): void {
const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId); 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 { private toWebSocketUrl(httpUrl: string, token: string): string {
const normalized = new URL(httpUrl); const normalized = new URL(httpUrl);
normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:'; normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:';

View File

@@ -261,6 +261,63 @@
} }
</div> </div>
</section> </section>
@if (session.isApprovalAdmin()) {
<section class="access-key-panel mt-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h3 class="h5 mb-1">User administration</h3>
<p class="small text-secondary mb-0">Delete any user account directly from SQLite.</p>
</div>
<button
class="btn btn-sm btn-outline-light"
type="button"
[disabled]="loadingAdminUsers()"
(click)="reloadAdminUsers()"
>
Refresh
</button>
</div>
@if (adminUsersError()) {
<div class="alert alert-danger mb-3">{{ adminUsersError() }}</div>
}
<div class="d-grid gap-2">
@if (loadingAdminUsers()) {
<div class="empty-state p-3 text-center text-secondary">Loading users...</div>
} @else if (adminUsers().length === 0) {
<div class="empty-state p-3 text-center text-secondary">No users found.</div>
} @else {
@for (user of adminUsers(); track user.id) {
<article class="access-key-card p-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">{{ user.displayName }}</div>
<div class="small text-secondary">@{{ user.username }}</div>
<div class="small text-secondary">
{{ user.isActive ? 'Approved' : 'Pending approval' }}
@if (user.approvedAt) {
· {{ user.approvedAt | date: 'short' }}
}
</div>
<div class="small text-secondary">Created: {{ user.createdAt | date: 'medium' }}</div>
</div>
<button
class="btn btn-sm btn-outline-danger"
type="button"
[disabled]="deletingUserId() === user.id"
(click)="deleteUser(user)"
>
{{ deletingUserId() === user.id ? 'Deleting...' : 'Delete user' }}
</button>
</div>
</article>
}
}
</div>
</section>
}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,9 +1,10 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service'; import { ChatSessionService } from './chat-session.service';
import type { AdminUserSummary } from './models';
import { ThemeService } from './theme.service'; import { ThemeService } from './theme.service';
@Component({ @Component({
@@ -23,6 +24,10 @@ export class HomePageComponent {
username = ''; username = '';
password = ''; password = '';
accessKeyLabel = ''; accessKeyLabel = '';
readonly adminUsers = signal<AdminUserSummary[]>([]);
readonly loadingAdminUsers = signal(false);
readonly deletingUserId = signal<string | null>(null);
readonly adminUsersError = signal<string | null>(null);
constructor(readonly session: ChatSessionService) { constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl(); this.serverUrl = session.serverUrl();
@@ -39,6 +44,19 @@ export class HomePageComponent {
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true }); 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<void> { async submitAuth(): Promise<void> {
@@ -80,6 +98,44 @@ export class HomePageComponent {
this.accessKeyLabel = ''; this.accessKeyLabel = '';
} }
async reloadAdminUsers(): Promise<void> {
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<void> {
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<void> { async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id; const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;

View File

@@ -1,11 +1,11 @@
:host { :host {
display: block; display: block;
max-width: 95%; max-width: min(95%, 320px);
} }
.json-viewer-shell { .json-viewer-shell {
width: 95%; width: min(95%, 480px);
max-width: 95%; max-width: min(95%, 480px);
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border-radius: 0.9rem; border-radius: 0.9rem;

View File

@@ -35,6 +35,15 @@ export interface PendingApprovalUser {
createdAt: string; createdAt: string;
} }
export interface AdminUserSummary {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
}
export interface AccessKeySummary { export interface AccessKeySummary {
id: string; id: string;
credentialId: string; credentialId: string;
@@ -105,6 +114,22 @@ export type ServerEvent =
| { type: 'peer-joined'; peer: UserProfile } | { type: 'peer-joined'; peer: UserProfile }
| { type: 'peer-left'; peerId: string } | { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload } | { 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: 'error'; message: string };
export type DataEnvelope = export type DataEnvelope =
@@ -141,4 +166,8 @@ export type DataEnvelope =
| { | {
type: 'typing'; type: 'typing';
active: boolean; active: boolean;
}
| {
type: 'camera-state';
active: boolean;
}; };

View File

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

View File

@@ -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) {
<div class="video-modal-backdrop" (click)="requestClose()">
<section class="video-modal-card" (click)="$event.stopPropagation()">
<div class="video-modal-header">
<div>
<h2 class="h5 mb-1">{{ title }}</h2>
<p class="small mb-0">Live webcam capture from your peer.</p>
</div>
<button class="video-modal-close" type="button" (click)="requestClose()" aria-label="Close live video">
×
</button>
</div>
<div class="video-modal-body">
<video #videoElement class="video-modal-player" autoplay playsinline></video>
</div>
</section>
</div>
}
`,
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<void>();
@ViewChild('videoElement')
set videoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.videoElement = value;
this.syncVideoSource();
}
private videoElement?: ElementRef<HTMLVideoElement>;
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;
}
}

261
server/dist/index.js vendored
View File

@@ -40,6 +40,9 @@ const verifyAccessKeyAuthenticationSchema = z.object({
const approvePendingUserParamsSchema = z.object({ const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
}); });
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const wsQuerySchema = z.object({ const wsQuerySchema = z.object({
token: z.string().min(1), 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 approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data'); const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite')); 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 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 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 sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60); 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 webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat'; const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION); const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
@@ -134,6 +152,11 @@ const selectPendingUsersStatement = database.prepare(`
WHERE is_active = 0 WHERE is_active = 0
ORDER BY created_at ASC 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(` const approveUserStatement = database.prepare(`
UPDATE users UPDATE users
SET is_active = 1, approved_at = ? SET is_active = 1, approved_at = ?
@@ -168,18 +191,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
FROM webauthn_credentials FROM webauthn_credentials
WHERE credential_id = ? WHERE credential_id = ?
`); `);
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
const updateAccessKeyStatement = database.prepare(` const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials UPDATE webauthn_credentials
SET encrypted_registration = ? SET encrypted_registration = ?
WHERE credential_id = ? WHERE credential_id = ?
`); `);
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex')); 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 redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
const socketsByUserId = new Map(); const socketsByUserId = new Map();
await redis.ping(); await redis.ping();
await app.register(cors, { await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true, origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false, credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
}); });
await app.register(jwt, { await app.register(jwt, {
secret: jwtSecret, secret: jwtSecret,
@@ -405,6 +440,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
user: toPublicUser(approvedUser), 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) => { app.post('/api/auth/logout', async (request, reply) => {
const authContext = await authenticateRequest(request, reply); const authContext = await authenticateRequest(request, reply);
if (!authContext) { if (!authContext) {
@@ -526,6 +596,11 @@ const port = Number(process.env.PORT ?? 16990);
await app.listen({ port, host: '0.0.0.0' }); await app.listen({ port, host: '0.0.0.0' });
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`); app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket, request) { 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); const query = wsQuerySchema.safeParse(request.query);
if (!query.success) { if (!query.success) {
send(socket, { type: 'error', message: 'Missing token.' }); 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.' }); send(socket, { type: 'error', message: 'Unsupported signaling message.' });
return; 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; let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to); const recipientSockets = socketsByUserId.get(parsed.to);
if (recipientSockets) { if (recipientSockets) {
@@ -683,6 +786,17 @@ function listPendingApprovalUsers() {
createdAt: row.created_at, 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) { function approveUser(userId) {
const approvedAt = new Date().toISOString(); const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId); const result = approveUserStatement.run(approvedAt, userId);
@@ -691,6 +805,19 @@ function approveUser(userId) {
} }
return findUserById(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) { function persistAccessKey(userId, input) {
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({ createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
credentialId: input.credentialId, credentialId: input.credentialId,
@@ -802,6 +929,32 @@ async function getSession(sessionId) {
async function destroySession(sessionId) { async function destroySession(sessionId) {
await redis.del(sessionKey(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) { function sessionKey(sessionId) {
return `privatechat:session:${sessionId}`; return `privatechat:session:${sessionId}`;
} }
@@ -889,12 +1042,87 @@ function parseClientMessage(rawMessage) {
if (!parsed.success) { if (!parsed.success) {
return 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 { return {
type: 'signal', type: 'signal',
to: parsed.data.to, to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal), 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) { function normalizeSignal(signal) {
if (signal.type === 'sdp') { if (signal.type === 'sdp') {
return { return {
@@ -1001,6 +1229,35 @@ function resolveStoragePath(targetPath) {
function resolveProjectPath(targetPath) { function resolveProjectPath(targetPath) {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, 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) { function resolveWebAuthnOrigin(request) {
const originHeader = request.headers.origin; const originHeader = request.headers.origin;
if (typeof originHeader === 'string' && originHeader.length > 0) { if (typeof originHeader === 'string' && originHeader.length > 0) {

View File

@@ -84,6 +84,15 @@ type PendingApprovalUser = {
createdAt: string; createdAt: string;
}; };
type AdminUserSummary = {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
};
type DatabaseAccessKeyRow = { type DatabaseAccessKeyRow = {
id: string; id: string;
user_id: string; user_id: string;
@@ -97,17 +106,43 @@ type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit } | { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit }; | { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
type ClientMessage = { type ClientMessage =
type: 'signal'; | {
to: string; type: 'signal';
signal: SignalPayload; to: string;
}; signal: SignalPayload;
}
| {
type: 'image-generation';
requestId: string;
peerId: string;
prompt: string;
}
| {
type: 'ping';
};
type ServerMessage = type ServerMessage =
| { type: 'presence'; self: PublicUser; peers: PublicUser[] } | { type: 'presence'; self: PublicUser; peers: PublicUser[] }
| { type: 'peer-joined'; peer: PublicUser } | { type: 'peer-joined'; peer: PublicUser }
| { type: 'peer-left'; peerId: string } | { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload } | { 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: 'error'; message: string };
type StoredCredentials = { type StoredCredentials = {
@@ -194,6 +229,10 @@ const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
}); });
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const wsQuerySchema = z.object({ const wsQuerySchema = z.object({
token: z.string().min(1), 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 approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data'); const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
@@ -236,8 +284,14 @@ const masterKeyPath = resolveStoragePath(
const frontendDistPath = resolveProjectPath( const frontendDistPath = resolveProjectPath(
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser', 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 sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60); 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 webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat'; const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification( const webAuthnUserVerification = resolveWebAuthnUserVerification(
@@ -304,6 +358,11 @@ const selectPendingUsersStatement = database.prepare(`
WHERE is_active = 0 WHERE is_active = 0
ORDER BY created_at ASC 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(` const approveUserStatement = database.prepare(`
UPDATE users UPDATE users
SET is_active = 1, approved_at = ? SET is_active = 1, approved_at = ?
@@ -338,11 +397,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
FROM webauthn_credentials FROM webauthn_credentials
WHERE credential_id = ? WHERE credential_id = ?
`); `);
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
const updateAccessKeyStatement = database.prepare(` const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials UPDATE webauthn_credentials
SET encrypted_registration = ? SET encrypted_registration = ?
WHERE credential_id = ? WHERE credential_id = ?
`); `);
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex')); 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 redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
@@ -351,8 +418,12 @@ const socketsByUserId = new Map<string, Map<string, WebSocket>>();
await redis.ping(); await redis.ping();
await app.register(cors, { await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true, origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false, credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
}); });
await app.register(jwt, { 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) => { app.post('/api/auth/logout', async (request, reply) => {
const authContext = await authenticateRequest(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}`); app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> { async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
if (!isAllowedRequestOrigin(request.headers.origin)) {
send(socket, { type: 'error', message: 'Origin not allowed.' });
socket.close();
return;
}
const query = wsQuerySchema.safeParse(request.query); const query = wsQuerySchema.safeParse(request.query);
if (!query.success) { if (!query.success) {
@@ -901,6 +1025,37 @@ async function handleSocketMessage(
return; 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; let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to); 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 { function approveUser(userId: string): UserRecord | null {
const approvedAt = new Date().toISOString(); const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId); const result = approveUserStatement.run(approvedAt, userId);
@@ -1067,6 +1235,25 @@ function approveUser(userId: string): UserRecord | null {
return findUserById(userId); return findUserById(userId);
} }
async function deleteUserAccount(userId: string): Promise<UserRecord | null> {
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( function persistAccessKey(
userId: string, userId: string,
input: { input: {
@@ -1248,6 +1435,39 @@ async function destroySession(sessionId: string): Promise<void> {
await redis.del(sessionKey(sessionId)); await redis.del(sessionKey(sessionId));
} }
async function destroyUserSessions(userId: string): Promise<void> {
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 { function sessionKey(sessionId: string): string {
return `privatechat:session:${sessionId}`; return `privatechat:session:${sessionId}`;
} }
@@ -1364,6 +1584,19 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
return 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 { return {
type: 'signal', type: 'signal',
to: parsed.data.to, 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<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
if (signal.type === 'sdp') { if (signal.type === 'sdp') {
return { return {
type: 'sdp', type: 'sdp',
@@ -1508,6 +1822,47 @@ function resolveProjectPath(targetPath: string): string {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath); return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
} }
function parseAllowedOrigins(value: string | undefined): Set<string> {
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 { function resolveWebAuthnOrigin(request: FastifyRequest): string {
const originHeader = request.headers.origin; const originHeader = request.headers.origin;