Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61612b52d3 | |||
| 640d92d231 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/**
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [
|
||||
"src/jsonview.js"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
window.__PRIVATECHAT_ENV__ = {
|
||||
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
||||
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
|
||||
};
|
||||
|
||||
@@ -9,6 +9,10 @@ export const routes: Routes = [
|
||||
path: '',
|
||||
component: HomePageComponent,
|
||||
},
|
||||
{
|
||||
path: 'chat',
|
||||
component: ChatPageComponent,
|
||||
},
|
||||
{
|
||||
path: 'chat/:peerId',
|
||||
component: ChatPageComponent,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<main class="chat-shell py-4">
|
||||
<div class="container-lg">
|
||||
<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>
|
||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||
@@ -42,10 +49,10 @@
|
||||
}
|
||||
|
||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()">
|
||||
<button
|
||||
class="peer-tile text-start"
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||
(click)="switchPeer(connectedPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
@@ -71,6 +78,16 @@
|
||||
></span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="peer-tile-delete"
|
||||
type="button"
|
||||
title="Delete conversation"
|
||||
aria-label="Delete conversation"
|
||||
(click)="deleteConversation(connectedPeer.id, $event)"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -90,8 +107,19 @@
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<div class="bubble-actions">
|
||||
<button
|
||||
class="bubble-delete"
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="toggleForwardMenu(entry, $event)"
|
||||
title="Forward message"
|
||||
aria-label="Forward message"
|
||||
>
|
||||
⏩
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action bubble-delete"
|
||||
type="button"
|
||||
(click)="deleteMessage(entry)"
|
||||
title="Delete message"
|
||||
@@ -99,9 +127,21 @@
|
||||
>
|
||||
×
|
||||
</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">
|
||||
<span>{{ entry.authorLabel }}</span>
|
||||
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</div>
|
||||
|
||||
@switch (entry.kind) {
|
||||
@@ -121,6 +161,22 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isVideoEntry(entry)) {
|
||||
<video
|
||||
class="bubble-video"
|
||||
[src]="entry.downloadUrl"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
}
|
||||
|
||||
@if (isIncomingJsonFileEntry(entry)) {
|
||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||
@if (entry.fileSize) {
|
||||
@@ -143,6 +199,18 @@
|
||||
|
||||
<div class="composer">
|
||||
@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
|
||||
#fileInput
|
||||
class="composer-file-input"
|
||||
@@ -160,17 +228,63 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<textarea
|
||||
#composerTextarea
|
||||
class="form-control composer-textarea"
|
||||
rows="3"
|
||||
[(ngModel)]="messageText"
|
||||
(ngModelChange)="handleMessageTextChange($event)"
|
||||
(keydown.enter)="handleComposerEnter($event)"
|
||||
(click)="trackComposerSelection(composerTextarea)"
|
||||
(keyup)="trackComposerSelection(composerTextarea)"
|
||||
(select)="trackComposerSelection(composerTextarea)"
|
||||
[disabled]="!session.isSelectedPeerReady()"
|
||||
placeholder="Write a text message to your peer"
|
||||
></textarea>
|
||||
|
||||
<div class="composer-send">
|
||||
<button
|
||||
class="composer-image-generate"
|
||||
type="button"
|
||||
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
||||
(click)="requestGeneratedImage()"
|
||||
title="Generate image from prompt"
|
||||
aria-label="Generate image from prompt"
|
||||
>
|
||||
🖼️
|
||||
</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"
|
||||
@@ -184,6 +298,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -106,8 +106,12 @@
|
||||
}
|
||||
|
||||
.peer-tile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.95rem 1rem;
|
||||
padding: 0.8rem 0.85rem 0.8rem 1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
color: inherit;
|
||||
@@ -115,6 +119,30 @@
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.peer-tile-main {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.peer-tile-delete {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.peer-tile-delete:hover,
|
||||
.peer-tile-delete:focus-visible {
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.peer-tile:hover,
|
||||
.peer-tile:focus-visible,
|
||||
.peer-tile-active {
|
||||
@@ -183,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 {
|
||||
@@ -222,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;
|
||||
@@ -240,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;
|
||||
@@ -265,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);
|
||||
@@ -274,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%;
|
||||
@@ -282,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;
|
||||
|
||||
@@ -1,15 +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 type { ChatEntry, ConnectionState } from './models';
|
||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-page',
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
@@ -19,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<string | null>(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());
|
||||
@@ -29,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<ConnectionState>(() => {
|
||||
const selectedPeer = this.peer();
|
||||
|
||||
@@ -84,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<void> {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.session.requestGeneratedImage(peerId, this.messageText);
|
||||
}
|
||||
|
||||
handleComposerEnter(event: Event): void {
|
||||
@@ -105,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<void> {
|
||||
const file = input.files?.item(0);
|
||||
|
||||
@@ -120,10 +184,79 @@ export class ChatPageComponent {
|
||||
await this.session.deleteMessage(entry);
|
||||
}
|
||||
|
||||
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||
event?.stopPropagation();
|
||||
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 {
|
||||
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' &&
|
||||
entry.direction === 'incoming' &&
|
||||
!!entry.downloadUrl &&
|
||||
!!entry.fileName &&
|
||||
entry.fileName.toLowerCase().endsWith('.json')
|
||||
);
|
||||
}
|
||||
|
||||
isPeerTyping(peerId: string): boolean {
|
||||
return this.session.typingPeerIds().includes(peerId);
|
||||
}
|
||||
@@ -144,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<void> {
|
||||
if (!peerId || peerId === this.peerId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.forwardingEntryId.set(null);
|
||||
this.emojiPickerOpen.set(false);
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
}
|
||||
|
||||
@@ -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<ChatEntry[]>([]);
|
||||
readonly unreadPeerIds = signal<string[]>([]);
|
||||
readonly typingPeerIds = signal<string[]>([]);
|
||||
readonly remoteVideoModalPeerId = signal<string | null>(null);
|
||||
readonly signalingState = signal<ConnectionState>('disconnected');
|
||||
readonly status = signal('Disconnected from signaling server.');
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -148,6 +157,14 @@ export class ChatSessionService {
|
||||
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
||||
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
||||
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 messageDatabasePromise: Promise<IDBDatabase | null> | 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<void> {
|
||||
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<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> {
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
@@ -506,6 +579,51 @@ export class ChatSessionService {
|
||||
}, 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> {
|
||||
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<PendingApprovalUser[]> {
|
||||
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> {
|
||||
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.');
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -654,6 +921,7 @@ export class ChatSessionService {
|
||||
this.peers.update((peers) => peers.filter((peer) => peer.id !== 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<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> {
|
||||
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<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 {
|
||||
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<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 {
|
||||
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
@@ -975,22 +1457,63 @@ export class ChatSessionService {
|
||||
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> {
|
||||
while (channel.bufferedAmount > threshold) {
|
||||
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 {
|
||||
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);
|
||||
@@ -1060,6 +1583,51 @@ export class ChatSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(peerId: string): Promise<void> {
|
||||
const entries = this.messages().filter((entry) => entry.peerId === peerId);
|
||||
|
||||
for (const entry of entries) {
|
||||
this.removeMessageById(entry.id);
|
||||
}
|
||||
|
||||
this.clearUnreadPeer(peerId);
|
||||
this.clearPeerTyping(peerId);
|
||||
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const conversationKey = this.conversationStorageKey(currentUserId, peerId);
|
||||
await this.queueMessageStoreOperation(conversationKey, async () => {
|
||||
const database = await this.openMessageDatabase();
|
||||
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite');
|
||||
const store = transaction.objectStore(ChatSessionService.messageStoreName);
|
||||
const conversationIndex = store.index('conversationKeyCreatedAt');
|
||||
const rows = await this.waitForRequest(
|
||||
conversationIndex.getAll(
|
||||
IDBKeyRange.bound([conversationKey, 0], [conversationKey, Number.MAX_SAFE_INTEGER]),
|
||||
),
|
||||
) as PersistedChatEntry[];
|
||||
|
||||
for (const row of rows) {
|
||||
store.delete(row.storageKey);
|
||||
}
|
||||
|
||||
await this.waitForTransaction(transaction);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not delete chat conversation.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private addSystemMessage(peerId: string, text: string): void {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
@@ -1098,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);
|
||||
@@ -1531,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);
|
||||
|
||||
@@ -1649,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:';
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="h4 mb-1">{{ user.displayName }}</div>
|
||||
<div class="text-secondary mb-3">{{ user.username }}</div>
|
||||
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
|
||||
<button class="btn btn-accent w-100 mb-2" type="button" (click)="openChatUi()">
|
||||
Open chat UI
|
||||
</button>
|
||||
@if (session.isApprovalAdmin()) {
|
||||
@@ -261,6 +261,63 @@
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -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<AdminUserSummary[]>([]);
|
||||
readonly loadingAdminUsers = signal(false);
|
||||
readonly deletingUserId = signal<string | null>(null);
|
||||
readonly adminUsersError = signal<string | null>(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<void> {
|
||||
@@ -80,20 +98,55 @@ export class HomePageComponent {
|
||||
this.accessKeyLabel = '';
|
||||
}
|
||||
|
||||
canOpenChatUi(): boolean {
|
||||
return this.session.peers().length > 0;
|
||||
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> {
|
||||
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||
|
||||
if (!peerId) {
|
||||
this.session.error.set('No connected peers are available yet.');
|
||||
if (peerId) {
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
this.session.error.set(null);
|
||||
await this.router.navigate(['/chat']);
|
||||
}
|
||||
|
||||
cycleTheme(): void {
|
||||
|
||||
31
client/src/app/json-file-viewer.component.scss
Normal file
31
client/src/app/json-file-viewer.component.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: min(95%, 320px);
|
||||
}
|
||||
|
||||
.json-viewer-shell {
|
||||
width: min(95%, 480px);
|
||||
max-width: min(95%, 480px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.json-viewer-host {
|
||||
max-height: 18rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.json-viewer-error {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:host ::ng-deep .json-viewer-host .json-container {
|
||||
min-width: max-content;
|
||||
}
|
||||
101
client/src/app/json-file-viewer.component.ts
Normal file
101
client/src/app/json-file-viewer.component.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
|
||||
|
||||
import type { ChatEntry } from './models';
|
||||
|
||||
type JsonViewTree = object;
|
||||
|
||||
type JsonViewApi = {
|
||||
renderJSON(value: unknown, container: HTMLElement): JsonViewTree;
|
||||
expand?(tree: JsonViewTree): void;
|
||||
destroy?(tree: JsonViewTree): void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
jsonview?: JsonViewApi;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-json-file-viewer',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="json-viewer-shell">
|
||||
<div #host class="json-viewer-host"></div>
|
||||
@if (errorMessage) {
|
||||
<p class="json-viewer-error mb-0">{{ errorMessage }}</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styleUrl: './json-file-viewer.component.scss',
|
||||
})
|
||||
export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input({ required: true }) entry!: ChatEntry;
|
||||
@ViewChild('host') private readonly host?: ElementRef<HTMLDivElement>;
|
||||
|
||||
errorMessage: string | null = null;
|
||||
private renderedTree: JsonViewTree | null = null;
|
||||
private renderVersion = 0;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
void this.renderJsonTree();
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
void this.renderJsonTree();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyRenderedTree();
|
||||
}
|
||||
|
||||
private async renderJsonTree(): Promise<void> {
|
||||
const host = this.host?.nativeElement;
|
||||
|
||||
if (!host || !this.entry.downloadUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderVersion = ++this.renderVersion;
|
||||
const jsonview = window.jsonview;
|
||||
|
||||
this.destroyRenderedTree();
|
||||
host.replaceChildren();
|
||||
this.errorMessage = null;
|
||||
|
||||
if (!jsonview) {
|
||||
this.errorMessage = 'JSON viewer unavailable.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.entry.downloadUrl);
|
||||
const content = await response.text();
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (renderVersion !== this.renderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tree = jsonview.renderJSON(parsed, host);
|
||||
jsonview.expand?.(tree);
|
||||
this.renderedTree = tree;
|
||||
} catch {
|
||||
if (renderVersion !== this.renderVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = 'Could not render JSON preview.';
|
||||
}
|
||||
}
|
||||
|
||||
private destroyRenderedTree(): void {
|
||||
if (!this.renderedTree) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.jsonview?.destroy?.(this.renderedTree);
|
||||
this.renderedTree = null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
54
client/src/app/peer-video-modal.component.scss
Normal file
54
client/src/app/peer-video-modal.component.scss
Normal 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;
|
||||
}
|
||||
86
client/src/app/peer-video-modal.component.ts
Normal file
86
client/src/app/peer-video-modal.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
client/src/jsonview.js
Normal file
1
client/src/jsonview.js
Normal file
File diff suppressed because one or more lines are too long
261
server/dist/index.js
vendored
261
server/dist/index.js
vendored
@@ -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) {
|
||||
|
||||
@@ -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,10 +106,20 @@ type SignalPayload =
|
||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||
|
||||
type ClientMessage = {
|
||||
type ClientMessage =
|
||||
| {
|
||||
type: 'signal';
|
||||
to: string;
|
||||
signal: SignalPayload;
|
||||
}
|
||||
| {
|
||||
type: 'image-generation';
|
||||
requestId: string;
|
||||
peerId: string;
|
||||
prompt: string;
|
||||
}
|
||||
| {
|
||||
type: 'ping';
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
@@ -108,6 +127,22 @@ type ServerMessage =
|
||||
| { 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<string, Map<string, WebSocket>>();
|
||||
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<void> {
|
||||
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<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(
|
||||
userId: string,
|
||||
input: {
|
||||
@@ -1248,6 +1435,39 @@ async function destroySession(sessionId: string): Promise<void> {
|
||||
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 {
|
||||
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<ClientMessage, { type: 'signal' }>['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<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 {
|
||||
const originHeader = request.headers.origin;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user