Many new functionalities
This commit is contained in:
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/privatechat.sqlite-wal
|
||||||
server/server/data/master.key
|
server/server/data/master.key
|
||||||
client/dist/*
|
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`: Optional master key for encrypting SQLite secret material and user credentials.
|
||||||
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
|
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
|
||||||
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
|
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
|
||||||
- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins.
|
- `CORS_ORIGIN`: Optional comma-separated browser-origin allowlist. If omitted, the server accepts request origins. The special `null` origin from embedded `file://` webviews is accepted.
|
||||||
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
|
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
|
||||||
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
|
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
|
||||||
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
|
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
window.__PRIVATECHAT_ENV__ = {
|
window.__PRIVATECHAT_ENV__ = {
|
||||||
"PRIVATECHAT_CLIENT_SERVER_URL": "http://chatter.dubertrand.fr"
|
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
<main class="chat-shell py-4">
|
<main class="chat-shell py-4">
|
||||||
<div class="container-lg">
|
<div class="container-lg">
|
||||||
<section class="chat-page panel p-3 p-lg-4">
|
<section class="chat-page panel p-3 p-lg-4">
|
||||||
|
<app-peer-video-modal
|
||||||
|
[visible]="remoteVideoModalVisible()"
|
||||||
|
[stream]="remoteVideoStream()"
|
||||||
|
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
||||||
|
(closeRequested)="closeRemoteVideoModal()"
|
||||||
|
></app-peer-video-modal>
|
||||||
|
|
||||||
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||||
@@ -100,8 +107,19 @@
|
|||||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||||
[class.bubble-system]="entry.direction === 'system'"
|
[class.bubble-system]="entry.direction === 'system'"
|
||||||
>
|
>
|
||||||
|
@if (entry.direction !== 'system') {
|
||||||
|
<div class="bubble-actions">
|
||||||
<button
|
<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"
|
type="button"
|
||||||
(click)="deleteMessage(entry)"
|
(click)="deleteMessage(entry)"
|
||||||
title="Delete message"
|
title="Delete message"
|
||||||
@@ -109,9 +127,21 @@
|
|||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
@if (isForwardMenuOpen(entry.id)) {
|
||||||
|
<div class="bubble-forward-menu">
|
||||||
|
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||||
|
<option value="">Forward to…</option>
|
||||||
|
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||||
|
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="bubble-meta">
|
<div class="bubble-meta">
|
||||||
<span>{{ entry.authorLabel }}</span>
|
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||||
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
|
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@switch (entry.kind) {
|
@switch (entry.kind) {
|
||||||
@@ -131,6 +161,18 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (isVideoEntry(entry)) {
|
||||||
|
<video
|
||||||
|
class="bubble-video"
|
||||||
|
[src]="entry.downloadUrl"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
></video>
|
||||||
|
}
|
||||||
|
|
||||||
@if (isIncomingJsonFileEntry(entry)) {
|
@if (isIncomingJsonFileEntry(entry)) {
|
||||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||||
}
|
}
|
||||||
@@ -157,6 +199,18 @@
|
|||||||
|
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
@if (peer(); as selectedPeer) {
|
@if (peer(); as selectedPeer) {
|
||||||
|
<div class="composer-actions">
|
||||||
|
<button
|
||||||
|
class="composer-camera"
|
||||||
|
type="button"
|
||||||
|
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
||||||
|
(click)="toggleCameraStream(selectedPeer.id)"
|
||||||
|
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||||
|
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||||
|
>
|
||||||
|
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
class="composer-file-input"
|
class="composer-file-input"
|
||||||
@@ -174,17 +228,63 @@
|
|||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
#composerTextarea
|
||||||
class="form-control composer-textarea"
|
class="form-control composer-textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
[(ngModel)]="messageText"
|
[(ngModel)]="messageText"
|
||||||
(ngModelChange)="handleMessageTextChange($event)"
|
(ngModelChange)="handleMessageTextChange($event)"
|
||||||
(keydown.enter)="handleComposerEnter($event)"
|
(keydown.enter)="handleComposerEnter($event)"
|
||||||
|
(click)="trackComposerSelection(composerTextarea)"
|
||||||
|
(keyup)="trackComposerSelection(composerTextarea)"
|
||||||
|
(select)="trackComposerSelection(composerTextarea)"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
placeholder="Write a text message to your peer"
|
placeholder="Write a text message to your peer"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
|
<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
|
<button
|
||||||
class="send-emoji"
|
class="send-emoji"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -198,6 +298,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -211,23 +211,55 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
max-width: min(75%, 34rem);
|
max-width: min(75%, 34rem);
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.9rem 3.4rem 0.9rem 1rem;
|
||||||
border-radius: 1.2rem;
|
border-radius: 1.2rem;
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-delete {
|
.bubble-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.45rem;
|
top: 0.45rem;
|
||||||
right: 0.55rem;
|
right: 0.55rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--danger-background);
|
background: var(--badge-background);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-delete {
|
||||||
|
background: var(--danger-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-forward-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.9rem;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: 12rem;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: var(--surface-background);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-forward-select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--input-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-incoming {
|
.bubble-incoming {
|
||||||
@@ -250,14 +282,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bubble-meta {
|
.bubble-meta {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
gap: 0.12rem;
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-author {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.composer {
|
.composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
@@ -268,10 +307,27 @@
|
|||||||
border-top: 1px solid var(--surface-border-soft);
|
border-top: 1px solid var(--surface-border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-send {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-picker-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-file-input {
|
.composer-file-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-camera,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
.composer-plus,
|
.composer-plus,
|
||||||
.send-emoji {
|
.send-emoji {
|
||||||
width: 3.25rem;
|
width: 3.25rem;
|
||||||
@@ -293,6 +349,21 @@
|
|||||||
color: var(--placeholder-color);
|
color: var(--placeholder-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-camera {
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-generate {
|
||||||
|
color: var(--page-text);
|
||||||
|
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger {
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--badge-background);
|
||||||
|
}
|
||||||
|
|
||||||
.composer-plus {
|
.composer-plus {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
@@ -302,6 +373,41 @@
|
|||||||
background: linear-gradient(135deg, #def7dd, #9bd5ff);
|
background: linear-gradient(135deg, #def7dd, #9bd5ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-emoji-picker {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 0.65rem);
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: min(14rem, 70vw);
|
||||||
|
max-height: 10.35rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-option {
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--surface-background);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-option:hover,
|
||||||
|
.composer-emoji-option:focus-visible {
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
.bubble-image {
|
.bubble-image {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -310,6 +416,15 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-video {
|
||||||
|
width: 200px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble-download {
|
.bubble-download {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, computed, effect, inject } from '@angular/core';
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||||
import type { ChatEntry, ConnectionState } from './models';
|
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-page',
|
selector: 'app-chat-page',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent],
|
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
@@ -20,8 +21,22 @@ export class ChatPageComponent {
|
|||||||
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||||
initialValue: this.route.snapshot.paramMap,
|
initialValue: this.route.snapshot.paramMap,
|
||||||
});
|
});
|
||||||
|
private composerSelectionStart = 0;
|
||||||
|
private composerSelectionEnd = 0;
|
||||||
|
|
||||||
messageText = '';
|
messageText = '';
|
||||||
|
readonly forwardingEntryId = signal<string | null>(null);
|
||||||
|
readonly emojiPickerOpen = signal(false);
|
||||||
|
readonly emojiOptions = [
|
||||||
|
'😀', '😁', '😂', '🤣', '😊',
|
||||||
|
'😉', '😍', '😘', '😎', '🤔',
|
||||||
|
'😅', '😭', '😡', '😴', '🙃',
|
||||||
|
'👍', '👎', '👏', '🙏', '🤝',
|
||||||
|
'🎉', '🔥', '❤️', '💡', '✅',
|
||||||
|
'🚀', '👀', '📹', '📎', '💬',
|
||||||
|
'🌍', '⚡', '⭐', '🎵', '📷',
|
||||||
|
'🗑️', '⏩', '🛑', '🙌', '👌',
|
||||||
|
];
|
||||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||||
readonly currentUser = computed(() => this.session.currentUser());
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
@@ -30,6 +45,10 @@ export class ChatPageComponent {
|
|||||||
.messages()
|
.messages()
|
||||||
.filter((entry) => entry.peerId === this.peerId()),
|
.filter((entry) => entry.peerId === this.peerId()),
|
||||||
);
|
);
|
||||||
|
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
||||||
|
readonly remoteVideoModalVisible = computed(
|
||||||
|
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
||||||
|
);
|
||||||
readonly webRtcState = computed<ConnectionState>(() => {
|
readonly webRtcState = computed<ConnectionState>(() => {
|
||||||
const selectedPeer = this.peer();
|
const selectedPeer = this.peer();
|
||||||
|
|
||||||
@@ -85,6 +104,19 @@ export class ChatPageComponent {
|
|||||||
|
|
||||||
await this.session.sendText(peerId, this.messageText);
|
await this.session.sendText(peerId, this.messageText);
|
||||||
this.messageText = '';
|
this.messageText = '';
|
||||||
|
this.emojiPickerOpen.set(false);
|
||||||
|
this.composerSelectionStart = 0;
|
||||||
|
this.composerSelectionEnd = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestGeneratedImage(): Promise<void> {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.requestGeneratedImage(peerId, this.messageText);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleComposerEnter(event: Event): void {
|
handleComposerEnter(event: Event): void {
|
||||||
@@ -106,6 +138,37 @@ export class ChatPageComponent {
|
|||||||
this.session.notifyTypingActivity(peerId, text);
|
this.session.notifyTypingActivity(peerId, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackComposerSelection(textarea: HTMLTextAreaElement): void {
|
||||||
|
this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length;
|
||||||
|
this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEmojiPicker(event?: Event): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
this.emojiPickerOpen.update((open) => !open);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void {
|
||||||
|
const selectionStart = textarea.selectionStart ?? this.composerSelectionStart;
|
||||||
|
const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd;
|
||||||
|
const before = this.messageText.slice(0, selectionStart);
|
||||||
|
const after = this.messageText.slice(selectionEnd);
|
||||||
|
|
||||||
|
this.messageText = `${before}${emoji}${after}`;
|
||||||
|
this.emojiPickerOpen.set(false);
|
||||||
|
this.handleMessageTextChange(this.messageText);
|
||||||
|
|
||||||
|
const nextSelection = selectionStart + emoji.length;
|
||||||
|
this.composerSelectionStart = nextSelection;
|
||||||
|
this.composerSelectionEnd = nextSelection;
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(nextSelection, nextSelection);
|
||||||
|
this.trackComposerSelection(textarea);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
||||||
const file = input.files?.item(0);
|
const file = input.files?.item(0);
|
||||||
|
|
||||||
@@ -126,10 +189,64 @@ export class ChatPageComponent {
|
|||||||
await this.session.deleteConversation(peerId);
|
await this.session.deleteConversation(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
|
||||||
|
event?.stopPropagation();
|
||||||
|
|
||||||
|
if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) {
|
||||||
|
this.forwardingEntryId.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
isForwardMenuOpen(entryId: string): boolean {
|
||||||
|
return this.forwardingEntryId() === entryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardTargets(entry: ChatEntry): PeerSummary[] {
|
||||||
|
if (entry.kind === 'system' || entry.direction === 'system') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||||
|
if (!targetPeerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.forwardMessage(targetPeerId, entry);
|
||||||
|
select.value = '';
|
||||||
|
this.forwardingEntryId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleCameraStream(peerId: string): Promise<void> {
|
||||||
|
if (this.session.isStreamingCameraToPeer(peerId)) {
|
||||||
|
await this.session.stopCameraStream(peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.startCameraStream(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
isImageEntry(entry: ChatEntry): boolean {
|
isImageEntry(entry: ChatEntry): boolean {
|
||||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isVideoEntry(entry: ChatEntry): boolean {
|
||||||
|
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.fileMimeType?.startsWith('video/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
|
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
|
||||||
return (
|
return (
|
||||||
entry.kind === 'file' &&
|
entry.kind === 'file' &&
|
||||||
@@ -160,11 +277,29 @@ export class ChatPageComponent {
|
|||||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
return this.indicatorTone(this.webRtcState()) === 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isStreamingCameraToSelectedPeer(): boolean {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeRemoteVideoModal(): void {
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session.dismissRemoteVideoModal(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
async switchPeer(peerId: string): Promise<void> {
|
async switchPeer(peerId: string): Promise<void> {
|
||||||
if (!peerId || peerId === this.peerId()) {
|
if (!peerId || peerId === this.peerId()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.forwardingEntryId.set(null);
|
||||||
|
this.emojiPickerOpen.set(false);
|
||||||
this.session.selectPeer(peerId);
|
this.session.selectPeer(peerId);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat', peerId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { computed, Injectable, signal } from '@angular/core';
|
import { computed, Injectable, signal } from '@angular/core';
|
||||||
import type { HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AccessKeySummary,
|
AccessKeySummary,
|
||||||
|
AdminUserSummary,
|
||||||
AuthenticationOptionsResponse,
|
AuthenticationOptionsResponse,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
ChatEntry,
|
ChatEntry,
|
||||||
@@ -24,6 +24,10 @@ type PeerBundle = {
|
|||||||
pc: RTCPeerConnection;
|
pc: RTCPeerConnection;
|
||||||
channel?: RTCDataChannel;
|
channel?: RTCDataChannel;
|
||||||
pendingCandidates: RTCIceCandidateInit[];
|
pendingCandidates: RTCIceCandidateInit[];
|
||||||
|
pendingNegotiation: boolean;
|
||||||
|
localCameraStream?: MediaStream;
|
||||||
|
cameraSenders: RTCRtpSender[];
|
||||||
|
remoteCameraStream?: MediaStream;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IncomingFileTransfer = {
|
type IncomingFileTransfer = {
|
||||||
@@ -101,6 +105,10 @@ export class ChatSessionService {
|
|||||||
private static readonly messageDatabaseName = 'privatechat';
|
private static readonly messageDatabaseName = 'privatechat';
|
||||||
private static readonly messageStoreName = 'conversation_messages';
|
private static readonly messageStoreName = 'conversation_messages';
|
||||||
private static readonly messageRetentionLimit = 256;
|
private static readonly messageRetentionLimit = 256;
|
||||||
|
private static readonly sessionKeepaliveMs = 5 * 60 * 1000;
|
||||||
|
private static readonly signalingHeartbeatMs = 25 * 1000;
|
||||||
|
private static readonly signalingReconnectBaseMs = 1000;
|
||||||
|
private static readonly signalingReconnectMaxMs = 10 * 1000;
|
||||||
private static readonly systemMessageLifetimeMs = 5000;
|
private static readonly systemMessageLifetimeMs = 5000;
|
||||||
private static readonly typingIndicatorLifetimeMs = 1800;
|
private static readonly typingIndicatorLifetimeMs = 1800;
|
||||||
private static readonly typingIdleMs = 1200;
|
private static readonly typingIdleMs = 1200;
|
||||||
@@ -114,6 +122,7 @@ export class ChatSessionService {
|
|||||||
readonly messages = signal<ChatEntry[]>([]);
|
readonly messages = signal<ChatEntry[]>([]);
|
||||||
readonly unreadPeerIds = signal<string[]>([]);
|
readonly unreadPeerIds = signal<string[]>([]);
|
||||||
readonly typingPeerIds = signal<string[]>([]);
|
readonly typingPeerIds = signal<string[]>([]);
|
||||||
|
readonly remoteVideoModalPeerId = signal<string | null>(null);
|
||||||
readonly signalingState = signal<ConnectionState>('disconnected');
|
readonly signalingState = signal<ConnectionState>('disconnected');
|
||||||
readonly status = signal('Disconnected from signaling server.');
|
readonly status = signal('Disconnected from signaling server.');
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
@@ -148,6 +157,14 @@ export class ChatSessionService {
|
|||||||
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
||||||
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
||||||
private readonly messageStoreOperations = new Map<string, Promise<void>>();
|
private readonly messageStoreOperations = new Map<string, Promise<void>>();
|
||||||
|
private readonly pendingImageGenerationRequests = new Map<string, { peerId: string; prompt: string }>();
|
||||||
|
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
|
||||||
|
private readonly activeCameraPeerId = signal<string | null>(null);
|
||||||
|
private sessionKeepaliveIntervalId: number | null = null;
|
||||||
|
private websocketHeartbeatIntervalId: number | null = null;
|
||||||
|
private websocketReconnectTimeoutId: number | null = null;
|
||||||
|
private websocketReconnectAttempt = 0;
|
||||||
|
private suppressSocketReconnect = false;
|
||||||
private messageEncryptionKey: CryptoKey | null = null;
|
private messageEncryptionKey: CryptoKey | null = null;
|
||||||
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null;
|
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null;
|
||||||
private websocket: WebSocket | null = null;
|
private websocket: WebSocket | null = null;
|
||||||
@@ -325,14 +342,107 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' });
|
this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' });
|
||||||
this.addSystemMessage(peerId, 'Opening WebRTC data channel.');
|
this.addSystemMessage(peerId, 'Opening WebRTC data channel.');
|
||||||
|
await this.negotiatePeer(peerId, bundle);
|
||||||
|
}
|
||||||
|
|
||||||
const offer = await bundle.pc.createOffer();
|
async startCameraStream(peerId: string): Promise<void> {
|
||||||
await bundle.pc.setLocalDescription(offer);
|
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
||||||
|
this.error.set('This browser does not support webcam capture.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.sendSignal(peerId, {
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
type: 'sdp',
|
this.error.set('You must be connected to signaling before starting webcam capture.');
|
||||||
description: bundle.pc.localDescription!.toJSON(),
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCameraPeerId = this.activeCameraPeerId();
|
||||||
|
|
||||||
|
if (activeCameraPeerId && activeCameraPeerId !== peerId) {
|
||||||
|
await this.stopCameraStream(activeCameraPeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = this.ensurePeerBundle(peerId, true);
|
||||||
|
|
||||||
|
if (bundle.localCameraStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: true,
|
||||||
|
audio: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bundle.localCameraStream = stream;
|
||||||
|
bundle.cameraSenders = stream.getTracks().map((track) => {
|
||||||
|
track.onended = () => {
|
||||||
|
void this.stopCameraStream(peerId, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return bundle.pc.addTrack(track, stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeCameraPeerId.set(peerId);
|
||||||
|
this.sendCameraState(peerId, true);
|
||||||
|
this.addSystemMessage(peerId, 'Sharing webcam capture.');
|
||||||
|
await this.negotiatePeer(peerId, bundle);
|
||||||
|
} catch {
|
||||||
|
this.error.set('Could not start webcam capture.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopCameraStream(peerId: string, notifyPeer = true): Promise<void> {
|
||||||
|
const bundle = this.peerBundles.get(peerId);
|
||||||
|
|
||||||
|
if (!bundle?.localCameraStream && this.activeCameraPeerId() !== peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle) {
|
||||||
|
for (const sender of bundle.cameraSenders) {
|
||||||
|
bundle.pc.removeTrack(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.cameraSenders = [];
|
||||||
|
|
||||||
|
if (bundle.localCameraStream) {
|
||||||
|
for (const track of bundle.localCameraStream.getTracks()) {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.localCameraStream = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeCameraPeerId() === peerId) {
|
||||||
|
this.activeCameraPeerId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifyPeer) {
|
||||||
|
this.sendCameraState(peerId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addSystemMessage(peerId, 'Stopped webcam capture.');
|
||||||
|
|
||||||
|
if (bundle) {
|
||||||
|
await this.negotiatePeer(peerId, bundle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isStreamingCameraToPeer(peerId: string): boolean {
|
||||||
|
return this.activeCameraPeerId() === peerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteVideoStreamForPeer(peerId: string): MediaStream | null {
|
||||||
|
return this.remoteVideoStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissRemoteVideoModal(peerId: string): void {
|
||||||
|
if (this.remoteVideoModalPeerId() === peerId) {
|
||||||
|
this.remoteVideoModalPeerId.set(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerAccessKey(label: string): Promise<void> {
|
async registerAccessKey(label: string): Promise<void> {
|
||||||
@@ -397,26 +507,7 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope: DataEnvelope = {
|
this.sendTextEnvelope(peerId, channel, trimmed);
|
||||||
type: 'text',
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
body: trimmed,
|
|
||||||
authorId: this.currentUser()!.id,
|
|
||||||
authorName: this.currentUser()!.displayName,
|
|
||||||
sentAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
channel.send(JSON.stringify(envelope));
|
|
||||||
this.sendTypingState(peerId, false);
|
|
||||||
this.pushMessage({
|
|
||||||
id: envelope.id,
|
|
||||||
peerId,
|
|
||||||
direction: 'outgoing',
|
|
||||||
kind: 'text',
|
|
||||||
createdAt: envelope.sentAt,
|
|
||||||
authorLabel: 'You',
|
|
||||||
text: trimmed,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendJson(peerId: string, rawPayload: string): Promise<void> {
|
async sendJson(peerId: string, rawPayload: string): Promise<void> {
|
||||||
@@ -439,25 +530,7 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope: DataEnvelope = {
|
this.sendJsonEnvelope(peerId, channel, parsedPayload);
|
||||||
type: 'json',
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
body: parsedPayload,
|
|
||||||
authorId: this.currentUser()!.id,
|
|
||||||
authorName: this.currentUser()!.displayName,
|
|
||||||
sentAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
channel.send(JSON.stringify(envelope));
|
|
||||||
this.pushMessage({
|
|
||||||
id: envelope.id,
|
|
||||||
peerId,
|
|
||||||
direction: 'outgoing',
|
|
||||||
kind: 'json',
|
|
||||||
createdAt: envelope.sentAt,
|
|
||||||
authorLabel: 'You',
|
|
||||||
payload: parsedPayload,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendFile(peerId: string, file: File): Promise<void> {
|
async sendFile(peerId: string, file: File): Promise<void> {
|
||||||
@@ -506,6 +579,51 @@ export class ChatSessionService {
|
|||||||
}, file);
|
}, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise<void> {
|
||||||
|
if (entry.kind === 'system' || entry.direction === 'system') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = await this.ensureOpenChannel(targetPeerId);
|
||||||
|
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (entry.kind) {
|
||||||
|
case 'text':
|
||||||
|
if (!entry.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendTextEnvelope(targetPeerId, channel, entry.text);
|
||||||
|
return;
|
||||||
|
case 'json':
|
||||||
|
this.sendJsonEnvelope(targetPeerId, channel, entry.payload);
|
||||||
|
return;
|
||||||
|
case 'file':
|
||||||
|
if (!entry.downloadUrl) {
|
||||||
|
this.error.set('This file cannot be forwarded because its data is unavailable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(entry.downloadUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], entry.fileName || 'attachment', {
|
||||||
|
type: entry.fileMimeType || blob.type || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sendFile(targetPeerId, file);
|
||||||
|
} catch {
|
||||||
|
this.error.set('Could not forward this file.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
|
private async authenticate(path: string, payload: Record<string, unknown>): Promise<void> {
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.notice.set(null);
|
this.notice.set(null);
|
||||||
@@ -533,9 +651,61 @@ export class ChatSessionService {
|
|||||||
this.status.set(`Authenticated as ${response.user.displayName}.`);
|
this.status.set(`Authenticated as ${response.user.displayName}.`);
|
||||||
await this.loadPersistedMessages(response.user.id);
|
await this.loadPersistedMessages(response.user.id);
|
||||||
await this.loadAccessKeys();
|
await this.loadAccessKeys();
|
||||||
|
this.startSessionKeepalive();
|
||||||
await this.connectWebSocket();
|
await this.connectWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendTextEnvelope(peerId: string, channel: RTCDataChannel, text: string): void {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope: DataEnvelope = {
|
||||||
|
type: 'text',
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
body: trimmed,
|
||||||
|
authorId: this.currentUser()!.id,
|
||||||
|
authorName: this.currentUser()!.displayName,
|
||||||
|
sentAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.send(JSON.stringify(envelope));
|
||||||
|
this.sendTypingState(peerId, false);
|
||||||
|
this.pushMessage({
|
||||||
|
id: envelope.id,
|
||||||
|
peerId,
|
||||||
|
direction: 'outgoing',
|
||||||
|
kind: 'text',
|
||||||
|
createdAt: envelope.sentAt,
|
||||||
|
authorLabel: 'You',
|
||||||
|
text: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendJsonEnvelope(peerId: string, channel: RTCDataChannel, payload: unknown): void {
|
||||||
|
const envelope: DataEnvelope = {
|
||||||
|
type: 'json',
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
body: payload,
|
||||||
|
authorId: this.currentUser()!.id,
|
||||||
|
authorName: this.currentUser()!.displayName,
|
||||||
|
sentAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.send(JSON.stringify(envelope));
|
||||||
|
this.pushMessage({
|
||||||
|
id: envelope.id,
|
||||||
|
peerId,
|
||||||
|
direction: 'outgoing',
|
||||||
|
kind: 'json',
|
||||||
|
createdAt: envelope.sentAt,
|
||||||
|
authorLabel: 'You',
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
|
async loadPendingApprovalUsers(): Promise<PendingApprovalUser[]> {
|
||||||
const token = this.token();
|
const token = this.token();
|
||||||
|
|
||||||
@@ -568,6 +738,69 @@ export class ChatSessionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadAdminUsers(): Promise<AdminUserSummary[]> {
|
||||||
|
const token = this.token();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.get<{ users: AdminUserSummary[] }>(`${this.serverUrl()}/api/admin/users`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUserAccount(userId: string): Promise<void> {
|
||||||
|
const token = this.token();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await firstValueFrom(
|
||||||
|
this.http.delete(`${this.serverUrl()}/api/admin/users/${encodeURIComponent(userId)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.currentUser()?.id === userId) {
|
||||||
|
this.clearLocalAuth('User deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestGeneratedImage(peerId: string, prompt: string): Promise<void> {
|
||||||
|
const trimmedPrompt = prompt.trim();
|
||||||
|
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
this.error.set('Enter a text prompt before requesting an image.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.error.set('You must be connected to signaling before requesting an image.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
this.pendingImageGenerationRequests.set(requestId, {
|
||||||
|
peerId,
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
});
|
||||||
|
this.error.set(null);
|
||||||
|
this.addSystemMessage(peerId, 'Generating image from prompt.');
|
||||||
|
this.websocket.send(JSON.stringify({
|
||||||
|
type: 'image-generation',
|
||||||
|
requestId,
|
||||||
|
peerId,
|
||||||
|
prompt: trimmedPrompt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async loadAccessKeys(): Promise<void> {
|
private async loadAccessKeys(): Promise<void> {
|
||||||
const token = this.token();
|
const token = this.token();
|
||||||
|
|
||||||
@@ -596,6 +829,7 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearWebSocketReconnect();
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
this.resetPeerConnections();
|
this.resetPeerConnections();
|
||||||
|
|
||||||
@@ -607,32 +841,65 @@ export class ChatSessionService {
|
|||||||
this.websocket = websocket;
|
this.websocket = websocket;
|
||||||
|
|
||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
|
if (this.websocket !== websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.websocketReconnectAttempt = 0;
|
||||||
|
this.startWebSocketHeartbeat(websocket);
|
||||||
this.signalingState.set('connected');
|
this.signalingState.set('connected');
|
||||||
this.status.set('Connected to signaling server.');
|
this.status.set('Connected to signaling server.');
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
|
if (this.websocket !== websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const message = JSON.parse(event.data) as ServerEvent;
|
const message = JSON.parse(event.data) as ServerEvent;
|
||||||
void this.handleServerEvent(message);
|
void this.handleServerEvent(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onerror = () => {
|
websocket.onerror = () => {
|
||||||
|
if (this.websocket !== websocket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.signalingState.set('failed');
|
this.signalingState.set('failed');
|
||||||
this.error.set('The signaling socket encountered an error.');
|
this.error.set('The signaling socket encountered an error.');
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = () => {
|
||||||
|
const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect;
|
||||||
|
|
||||||
|
this.stopWebSocketHeartbeat();
|
||||||
this.signalingState.set('disconnected');
|
this.signalingState.set('disconnected');
|
||||||
this.status.set('Signaling connection closed.');
|
this.status.set('Signaling connection closed.');
|
||||||
|
|
||||||
|
if (this.websocket === websocket) {
|
||||||
this.websocket = null;
|
this.websocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.peers.update((peers) =>
|
this.peers.update((peers) =>
|
||||||
peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })),
|
peers.map((peer) => ({ ...peer, connectionState: 'disconnected', channelState: 'closed' })),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.suppressSocketReconnect) {
|
||||||
|
this.suppressSocketReconnect = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReconnect) {
|
||||||
|
this.scheduleWebSocketReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private disconnectWebSocket(): void {
|
private disconnectWebSocket(): void {
|
||||||
|
this.stopWebSocketHeartbeat();
|
||||||
|
|
||||||
if (this.websocket) {
|
if (this.websocket) {
|
||||||
|
this.suppressSocketReconnect = true;
|
||||||
this.websocket.close();
|
this.websocket.close();
|
||||||
this.websocket = null;
|
this.websocket = null;
|
||||||
}
|
}
|
||||||
@@ -654,6 +921,7 @@ export class ChatSessionService {
|
|||||||
this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId));
|
this.peers.update((peers) => peers.filter((peer) => peer.id !== event.peerId));
|
||||||
this.clearUnreadPeer(event.peerId);
|
this.clearUnreadPeer(event.peerId);
|
||||||
this.clearPeerTyping(event.peerId);
|
this.clearPeerTyping(event.peerId);
|
||||||
|
this.clearRemoteVideoState(event.peerId);
|
||||||
if (this.activePeerId() === event.peerId) {
|
if (this.activePeerId() === event.peerId) {
|
||||||
this.activePeerId.set(this.peers()[0]?.id ?? null);
|
this.activePeerId.set(this.peers()[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
@@ -662,6 +930,14 @@ export class ChatSessionService {
|
|||||||
case 'signal':
|
case 'signal':
|
||||||
await this.handleSignal(event.from, event.signal);
|
await this.handleSignal(event.from, event.signal);
|
||||||
break;
|
break;
|
||||||
|
case 'image-generated':
|
||||||
|
this.handleGeneratedImage(event);
|
||||||
|
break;
|
||||||
|
case 'image-generation-error':
|
||||||
|
this.handleGeneratedImageError(event);
|
||||||
|
break;
|
||||||
|
case 'pong':
|
||||||
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
this.error.set(event.message);
|
this.error.set(event.message);
|
||||||
if (/auth|session/i.test(event.message)) {
|
if (/auth|session/i.test(event.message)) {
|
||||||
@@ -671,6 +947,44 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleGeneratedImage(event: Extract<ServerEvent, { type: 'image-generated' }>): void {
|
||||||
|
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
|
||||||
|
|
||||||
|
if (pendingRequest) {
|
||||||
|
this.pendingImageGenerationRequests.delete(event.requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerId = pendingRequest?.peerId ?? event.peerId;
|
||||||
|
const imageBlob = this.base64ToBlob(event.imageBase64, event.mimeType);
|
||||||
|
const extension = this.fileExtensionForMimeType(event.mimeType);
|
||||||
|
const fileName = `generated-image-${event.requestId.slice(0, 8)}.${extension}`;
|
||||||
|
|
||||||
|
this.pushMessage({
|
||||||
|
id: event.requestId,
|
||||||
|
peerId,
|
||||||
|
direction: 'outgoing',
|
||||||
|
kind: 'file',
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
authorLabel: 'You',
|
||||||
|
text: pendingRequest?.prompt ?? event.prompt,
|
||||||
|
fileName,
|
||||||
|
fileSize: imageBlob.size,
|
||||||
|
fileMimeType: event.mimeType,
|
||||||
|
downloadUrl: URL.createObjectURL(imageBlob),
|
||||||
|
}, imageBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleGeneratedImageError(event: Extract<ServerEvent, { type: 'image-generation-error' }>): void {
|
||||||
|
const pendingRequest = this.pendingImageGenerationRequests.get(event.requestId);
|
||||||
|
|
||||||
|
if (pendingRequest) {
|
||||||
|
this.pendingImageGenerationRequests.delete(event.requestId);
|
||||||
|
this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set(event.message);
|
||||||
|
}
|
||||||
|
|
||||||
private async restoreSession(): Promise<void> {
|
private async restoreSession(): Promise<void> {
|
||||||
const token = this.token();
|
const token = this.token();
|
||||||
|
|
||||||
@@ -694,12 +1008,108 @@ export class ChatSessionService {
|
|||||||
this.writeStorage('privatechat.user', JSON.stringify(response.user));
|
this.writeStorage('privatechat.user', JSON.stringify(response.user));
|
||||||
await this.loadPersistedMessages(response.user.id);
|
await this.loadPersistedMessages(response.user.id);
|
||||||
await this.loadAccessKeys();
|
await this.loadAccessKeys();
|
||||||
|
this.startSessionKeepalive();
|
||||||
await this.connectWebSocket();
|
await this.connectWebSocket();
|
||||||
} catch {
|
} catch {
|
||||||
this.clearLocalAuth('Saved session expired. Sign in again.');
|
this.clearLocalAuth('Saved session expired. Sign in again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startSessionKeepalive(): void {
|
||||||
|
this.stopSessionKeepalive();
|
||||||
|
|
||||||
|
if (typeof window === 'undefined' || !this.token()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessionKeepaliveIntervalId = window.setInterval(() => {
|
||||||
|
void this.refreshSessionLease();
|
||||||
|
}, ChatSessionService.sessionKeepaliveMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSessionKeepalive(): void {
|
||||||
|
if (this.sessionKeepaliveIntervalId === null || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearInterval(this.sessionKeepaliveIntervalId);
|
||||||
|
this.sessionKeepaliveIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshSessionLease(): Promise<void> {
|
||||||
|
const token = this.token();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.stopSessionKeepalive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await firstValueFrom(
|
||||||
|
this.http.get<SessionResponse>(`${this.serverUrl()}/api/auth/session`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpErrorResponse && (error.status === 401 || error.status === 403)) {
|
||||||
|
this.clearLocalAuth('Session expired. Sign in again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startWebSocketHeartbeat(websocket: WebSocket): void {
|
||||||
|
this.stopWebSocketHeartbeat();
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.websocketHeartbeatIntervalId = window.setInterval(() => {
|
||||||
|
if (this.websocket !== websocket || websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.stopWebSocketHeartbeat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websocket.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
}, ChatSessionService.signalingHeartbeatMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWebSocketHeartbeat(): void {
|
||||||
|
if (this.websocketHeartbeatIntervalId === null || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearInterval(this.websocketHeartbeatIntervalId);
|
||||||
|
this.websocketHeartbeatIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleWebSocketReconnect(): void {
|
||||||
|
if (typeof window === 'undefined' || this.websocketReconnectTimeoutId !== null || !this.token() || !this.currentUser()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
ChatSessionService.signalingReconnectBaseMs * 2 ** this.websocketReconnectAttempt,
|
||||||
|
ChatSessionService.signalingReconnectMaxMs,
|
||||||
|
);
|
||||||
|
this.websocketReconnectAttempt += 1;
|
||||||
|
this.status.set(`Reconnecting to signaling server in ${Math.round(delay / 1000)}s.`);
|
||||||
|
|
||||||
|
this.websocketReconnectTimeoutId = window.setTimeout(() => {
|
||||||
|
this.websocketReconnectTimeoutId = null;
|
||||||
|
void this.connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearWebSocketReconnect(): void {
|
||||||
|
if (this.websocketReconnectTimeoutId === null || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(this.websocketReconnectTimeoutId);
|
||||||
|
this.websocketReconnectTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
private mergePresence(peers: Array<UserProfile | PeerSummary>): void {
|
private mergePresence(peers: Array<UserProfile | PeerSummary>): void {
|
||||||
const previous = new Map(this.peers().map((peer) => [peer.id, peer]));
|
const previous = new Map(this.peers().map((peer) => [peer.id, peer]));
|
||||||
|
|
||||||
@@ -792,6 +1202,8 @@ export class ChatSessionService {
|
|||||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||||
}),
|
}),
|
||||||
pendingCandidates: [],
|
pendingCandidates: [],
|
||||||
|
pendingNegotiation: false,
|
||||||
|
cameraSenders: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
bundle.pc.onicecandidate = (event) => {
|
bundle.pc.onicecandidate = (event) => {
|
||||||
@@ -816,10 +1228,44 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bundle.pc.onsignalingstatechange = () => {
|
||||||
|
if (bundle.pc.signalingState === 'stable' && bundle.pendingNegotiation) {
|
||||||
|
bundle.pendingNegotiation = false;
|
||||||
|
void this.negotiatePeer(peerId, bundle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
bundle.pc.ondatachannel = (event) => {
|
bundle.pc.ondatachannel = (event) => {
|
||||||
this.attachDataChannel(peerId, event.channel, bundle);
|
this.attachDataChannel(peerId, event.channel, bundle);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bundle.pc.ontrack = (event) => {
|
||||||
|
const [stream] = event.streams;
|
||||||
|
const remoteStream = stream ?? bundle.remoteCameraStream ?? new MediaStream();
|
||||||
|
|
||||||
|
if (!stream) {
|
||||||
|
remoteStream.addTrack(event.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.remoteCameraStream = remoteStream;
|
||||||
|
this.upsertRemoteVideoStream(peerId, remoteStream);
|
||||||
|
this.remoteVideoModalPeerId.set(peerId);
|
||||||
|
|
||||||
|
event.track.onended = () => {
|
||||||
|
if (!bundle.remoteCameraStream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingLiveTracks = bundle.remoteCameraStream
|
||||||
|
.getVideoTracks()
|
||||||
|
.filter((track) => track.readyState === 'live' && track !== event.track);
|
||||||
|
|
||||||
|
if (remainingLiveTracks.length === 0) {
|
||||||
|
this.clearRemoteVideoState(peerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
if (initiator) {
|
if (initiator) {
|
||||||
const channel = bundle.pc.createDataChannel('privatechat');
|
const channel = bundle.pc.createDataChannel('privatechat');
|
||||||
this.attachDataChannel(peerId, channel, bundle);
|
this.attachDataChannel(peerId, channel, bundle);
|
||||||
@@ -904,6 +1350,13 @@ export class ChatSessionService {
|
|||||||
case 'typing':
|
case 'typing':
|
||||||
this.setPeerTyping(peerId, envelope.active);
|
this.setPeerTyping(peerId, envelope.active);
|
||||||
break;
|
break;
|
||||||
|
case 'camera-state':
|
||||||
|
if (envelope.active) {
|
||||||
|
this.remoteVideoModalPeerId.set(peerId);
|
||||||
|
} else {
|
||||||
|
this.clearRemoteVideoState(peerId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,6 +1409,35 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async negotiatePeer(peerId: string, bundle: PeerBundle): Promise<void> {
|
||||||
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bundle.pc.signalingState !== 'stable') {
|
||||||
|
bundle.pendingNegotiation = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = await bundle.pc.createOffer();
|
||||||
|
await bundle.pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
this.sendSignal(peerId, {
|
||||||
|
type: 'sdp',
|
||||||
|
description: bundle.pc.localDescription!.toJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendCameraState(peerId: string, active: boolean): void {
|
||||||
|
const channel = this.peerBundles.get(peerId)?.channel;
|
||||||
|
|
||||||
|
if (!channel || channel.readyState !== 'open') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope));
|
||||||
|
}
|
||||||
|
|
||||||
private sendSignal(peerId: string, signal: SignalPayload): void {
|
private sendSignal(peerId: string, signal: SignalPayload): void {
|
||||||
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
@@ -975,22 +1457,63 @@ export class ChatSessionService {
|
|||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureOpenChannel(peerId: string): Promise<RTCDataChannel | null> {
|
||||||
|
const openChannel = this.requireOpenChannel(peerId);
|
||||||
|
|
||||||
|
if (openChannel) {
|
||||||
|
return openChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.connectToPeer(peerId);
|
||||||
|
|
||||||
|
return this.waitForOpenChannel(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> {
|
private async waitForBufferedAmount(channel: RTCDataChannel, threshold: number): Promise<void> {
|
||||||
while (channel.bufferedAmount > threshold) {
|
while (channel.bufferedAmount > threshold) {
|
||||||
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
await new Promise((resolve) => window.setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForOpenChannel(peerId: string, timeoutMs = 8000): Promise<RTCDataChannel | null> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const channel = this.peerBundles.get(peerId)?.channel;
|
||||||
|
|
||||||
|
if (channel?.readyState === 'open') {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('Could not open a peer channel for forwarding.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private releasePeerBundle(peerId: string, preservePeerState: boolean): void {
|
private releasePeerBundle(peerId: string, preservePeerState: boolean): void {
|
||||||
const bundle = this.peerBundles.get(peerId);
|
const bundle = this.peerBundles.get(peerId);
|
||||||
|
|
||||||
this.clearPeerTyping(peerId);
|
this.clearPeerTyping(peerId);
|
||||||
this.clearOutgoingTyping(peerId);
|
this.clearOutgoingTyping(peerId);
|
||||||
|
this.clearRemoteVideoState(peerId);
|
||||||
|
|
||||||
if (!bundle) {
|
if (!bundle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bundle.localCameraStream) {
|
||||||
|
for (const track of bundle.localCameraStream.getTracks()) {
|
||||||
|
track.onended = null;
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeCameraPeerId() === peerId) {
|
||||||
|
this.activeCameraPeerId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
bundle.channel?.close();
|
bundle.channel?.close();
|
||||||
bundle.pc.close();
|
bundle.pc.close();
|
||||||
this.peerBundles.delete(peerId);
|
this.peerBundles.delete(peerId);
|
||||||
@@ -1143,10 +1666,16 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearLocalAuth(statusMessage: string): void {
|
private clearLocalAuth(statusMessage: string): void {
|
||||||
|
this.clearWebSocketReconnect();
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
this.resetPeerConnections();
|
this.resetPeerConnections();
|
||||||
|
this.stopSessionKeepalive();
|
||||||
this.clearSystemMessageTimeouts();
|
this.clearSystemMessageTimeouts();
|
||||||
this.clearTypingTimeouts();
|
this.clearTypingTimeouts();
|
||||||
|
this.pendingImageGenerationRequests.clear();
|
||||||
|
this.remoteVideoStreams.set([]);
|
||||||
|
this.remoteVideoModalPeerId.set(null);
|
||||||
|
this.activeCameraPeerId.set(null);
|
||||||
this.messageEncryptionKey = null;
|
this.messageEncryptionKey = null;
|
||||||
this.revokeMessageDownloads(this.messages());
|
this.revokeMessageDownloads(this.messages());
|
||||||
this.currentUser.set(null);
|
this.currentUser.set(null);
|
||||||
@@ -1576,6 +2105,28 @@ export class ChatSessionService {
|
|||||||
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
|
this.unreadPeerIds.update((peerIds) => peerIds.filter((id) => id !== peerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private upsertRemoteVideoStream(peerId: string, stream: MediaStream): void {
|
||||||
|
this.remoteVideoStreams.update((entries) => {
|
||||||
|
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
|
||||||
|
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [...entries, { peerId, stream }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntries = [...entries];
|
||||||
|
nextEntries[existingIndex] = { peerId, stream };
|
||||||
|
return nextEntries;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearRemoteVideoState(peerId: string): void {
|
||||||
|
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
|
||||||
|
|
||||||
|
if (this.remoteVideoModalPeerId() === peerId) {
|
||||||
|
this.remoteVideoModalPeerId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setPeerTyping(peerId: string, active: boolean): void {
|
private setPeerTyping(peerId: string, active: boolean): void {
|
||||||
const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId);
|
const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId);
|
||||||
|
|
||||||
@@ -1694,6 +2245,32 @@ export class ChatSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private base64ToBlob(value: string, mimeType: string): Blob {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
private fileExtensionForMimeType(mimeType: string): string {
|
||||||
|
switch (mimeType) {
|
||||||
|
case 'image/png':
|
||||||
|
return 'png';
|
||||||
|
case 'image/jpeg':
|
||||||
|
return 'jpg';
|
||||||
|
case 'image/webp':
|
||||||
|
return 'webp';
|
||||||
|
case 'image/gif':
|
||||||
|
return 'gif';
|
||||||
|
default:
|
||||||
|
return 'bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private toWebSocketUrl(httpUrl: string, token: string): string {
|
private toWebSocketUrl(httpUrl: string, token: string): string {
|
||||||
const normalized = new URL(httpUrl);
|
const normalized = new URL(httpUrl);
|
||||||
normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:';
|
normalized.protocol = normalized.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
|||||||
@@ -261,6 +261,63 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (session.isApprovalAdmin()) {
|
||||||
|
<section class="access-key-panel mt-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">User administration</h3>
|
||||||
|
<p class="small text-secondary mb-0">Delete any user account directly from SQLite.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-light"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loadingAdminUsers()"
|
||||||
|
(click)="reloadAdminUsers()"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (adminUsersError()) {
|
||||||
|
<div class="alert alert-danger mb-3">{{ adminUsersError() }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
@if (loadingAdminUsers()) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary">Loading users...</div>
|
||||||
|
} @else if (adminUsers().length === 0) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary">No users found.</div>
|
||||||
|
} @else {
|
||||||
|
@for (user of adminUsers(); track user.id) {
|
||||||
|
<article class="access-key-card p-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">{{ user.displayName }}</div>
|
||||||
|
<div class="small text-secondary">@{{ user.username }}</div>
|
||||||
|
<div class="small text-secondary">
|
||||||
|
{{ user.isActive ? 'Approved' : 'Pending approval' }}
|
||||||
|
@if (user.approvedAt) {
|
||||||
|
· {{ user.approvedAt | date: 'short' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="small text-secondary">Created: {{ user.createdAt | date: 'medium' }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
type="button"
|
||||||
|
[disabled]="deletingUserId() === user.id"
|
||||||
|
(click)="deleteUser(user)"
|
||||||
|
>
|
||||||
|
{{ deletingUserId() === user.id ? 'Deleting...' : 'Delete user' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, effect, inject } from '@angular/core';
|
import { Component, effect, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { AdminUserSummary } from './models';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -23,6 +24,10 @@ export class HomePageComponent {
|
|||||||
username = '';
|
username = '';
|
||||||
password = '';
|
password = '';
|
||||||
accessKeyLabel = '';
|
accessKeyLabel = '';
|
||||||
|
readonly adminUsers = signal<AdminUserSummary[]>([]);
|
||||||
|
readonly loadingAdminUsers = signal(false);
|
||||||
|
readonly deletingUserId = signal<string | null>(null);
|
||||||
|
readonly adminUsersError = signal<string | null>(null);
|
||||||
|
|
||||||
constructor(readonly session: ChatSessionService) {
|
constructor(readonly session: ChatSessionService) {
|
||||||
this.serverUrl = session.serverUrl();
|
this.serverUrl = session.serverUrl();
|
||||||
@@ -39,6 +44,19 @@ export class HomePageComponent {
|
|||||||
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
|
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUser = this.session.currentUser();
|
||||||
|
|
||||||
|
if (!currentUser || !this.session.isApprovalAdmin()) {
|
||||||
|
this.adminUsers.set([]);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
this.loadingAdminUsers.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reloadAdminUsers();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitAuth(): Promise<void> {
|
async submitAuth(): Promise<void> {
|
||||||
@@ -80,6 +98,44 @@ export class HomePageComponent {
|
|||||||
this.accessKeyLabel = '';
|
this.accessKeyLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadAdminUsers(): Promise<void> {
|
||||||
|
this.loadingAdminUsers.set(true);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.adminUsers.set(await this.session.loadAdminUsers());
|
||||||
|
} catch (error) {
|
||||||
|
this.adminUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not load users.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loadingAdminUsers.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(user: AdminUserSummary): Promise<void> {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!window.confirm(`Delete user ${user.username}? This removes the account from SQLite.`)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletingUserId.set(user.id);
|
||||||
|
this.adminUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.session.deleteUserAccount(user.id);
|
||||||
|
this.adminUsers.update((users) => users.filter((candidate) => candidate.id !== user.id));
|
||||||
|
} catch (error) {
|
||||||
|
this.adminUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not delete that user.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.deletingUserId.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async openChatUi(): Promise<void> {
|
async openChatUi(): Promise<void> {
|
||||||
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 95%;
|
max-width: min(95%, 320px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.json-viewer-shell {
|
.json-viewer-shell {
|
||||||
width: 95%;
|
width: min(95%, 480px);
|
||||||
max-width: 95%;
|
max-width: min(95%, 480px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.9rem;
|
border-radius: 0.9rem;
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ export interface PendingApprovalUser {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSummary {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
approvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessKeySummary {
|
export interface AccessKeySummary {
|
||||||
id: string;
|
id: string;
|
||||||
credentialId: string;
|
credentialId: string;
|
||||||
@@ -105,6 +114,22 @@ export type ServerEvent =
|
|||||||
| { type: 'peer-joined'; peer: UserProfile }
|
| { type: 'peer-joined'; peer: UserProfile }
|
||||||
| { type: 'peer-left'; peerId: string }
|
| { type: 'peer-left'; peerId: string }
|
||||||
| { type: 'signal'; from: string; signal: SignalPayload }
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
||||||
|
| {
|
||||||
|
type: 'image-generated';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
mimeType: string;
|
||||||
|
imageBase64: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation-error';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
export type DataEnvelope =
|
export type DataEnvelope =
|
||||||
@@ -141,4 +166,8 @@ export type DataEnvelope =
|
|||||||
| {
|
| {
|
||||||
type: 'typing';
|
type: 'typing';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'camera-state';
|
||||||
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
261
server/dist/index.js
vendored
261
server/dist/index.js
vendored
@@ -40,6 +40,9 @@ const verifyAccessKeyAuthenticationSchema = z.object({
|
|||||||
const approvePendingUserParamsSchema = z.object({
|
const approvePendingUserParamsSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const adminDeleteUserParamsSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -66,15 +69,30 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('image-generation'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
peerId: z.string().min(1),
|
||||||
|
prompt: z.string().trim().min(1).max(4000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ping'),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
||||||
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
|
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
|
||||||
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
|
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
|
||||||
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
|
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
|
||||||
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
|
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
|
||||||
|
const corsMethods = ['GET', 'POST', 'OPTIONS'];
|
||||||
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
||||||
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
@@ -134,6 +152,11 @@ const selectPendingUsersStatement = database.prepare(`
|
|||||||
WHERE is_active = 0
|
WHERE is_active = 0
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`);
|
`);
|
||||||
|
const selectAllUsersStatement = database.prepare(`
|
||||||
|
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
const approveUserStatement = database.prepare(`
|
const approveUserStatement = database.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = 1, approved_at = ?
|
SET is_active = 1, approved_at = ?
|
||||||
@@ -168,18 +191,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
|
|||||||
FROM webauthn_credentials
|
FROM webauthn_credentials
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteAccessKeysByUserStatement = database.prepare(`
|
||||||
|
DELETE FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
const updateAccessKeyStatement = database.prepare(`
|
const updateAccessKeyStatement = database.prepare(`
|
||||||
UPDATE webauthn_credentials
|
UPDATE webauthn_credentials
|
||||||
SET encrypted_registration = ?
|
SET encrypted_registration = ?
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUserStatement = database.prepare(`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
||||||
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
||||||
const socketsByUserId = new Map();
|
const socketsByUserId = new Map();
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
|
origin(origin, callback) {
|
||||||
|
callback(null, isAllowedRequestOrigin(origin));
|
||||||
|
},
|
||||||
credentials: false,
|
credentials: false,
|
||||||
|
allowedHeaders: corsAllowedHeaders,
|
||||||
|
methods: corsMethods,
|
||||||
});
|
});
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
secret: jwtSecret,
|
secret: jwtSecret,
|
||||||
@@ -405,6 +440,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
|
|||||||
user: toPublicUser(approvedUser),
|
user: toPublicUser(approvedUser),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
app.get('/api/admin/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
users: listAdminUsers(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
app.delete('/api/admin/users/:userId', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid user deletion request.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deletedUser = await deleteUserAccount(parsed.data.userId);
|
||||||
|
if (!deletedUser) {
|
||||||
|
return reply.code(404).send({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: toPublicUser(deletedUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
@@ -526,6 +596,11 @@ const port = Number(process.env.PORT ?? 16990);
|
|||||||
await app.listen({ port, host: '0.0.0.0' });
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
||||||
async function openSocket(socket, request) {
|
async function openSocket(socket, request) {
|
||||||
|
if (!isAllowedRequestOrigin(request.headers.origin)) {
|
||||||
|
send(socket, { type: 'error', message: 'Origin not allowed.' });
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const query = wsQuerySchema.safeParse(request.query);
|
const query = wsQuerySchema.safeParse(request.query);
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
send(socket, { type: 'error', message: 'Missing token.' });
|
send(socket, { type: 'error', message: 'Missing token.' });
|
||||||
@@ -574,6 +649,34 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
|
|||||||
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
|
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
send(socket, { type: 'pong' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.type === 'image-generation') {
|
||||||
|
try {
|
||||||
|
const generatedImage = await generateImageFromPrompt(parsed.prompt);
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generated',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
prompt: parsed.prompt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
mimeType: generatedImage.mimeType,
|
||||||
|
imageBase64: generatedImage.imageBase64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generation-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
message: error instanceof Error ? error.message : 'Image generation failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
if (recipientSockets) {
|
if (recipientSockets) {
|
||||||
@@ -683,6 +786,17 @@ function listPendingApprovalUsers() {
|
|||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
function listAdminUsers() {
|
||||||
|
const rows = selectAllUsersStatement.all();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isActive: row.is_active === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
approvedAt: row.approved_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
function approveUser(userId) {
|
function approveUser(userId) {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -691,6 +805,19 @@ function approveUser(userId) {
|
|||||||
}
|
}
|
||||||
return findUserById(userId);
|
return findUserById(userId);
|
||||||
}
|
}
|
||||||
|
async function deleteUserAccount(userId) {
|
||||||
|
const user = findUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
deleteAccessKeysByUserStatement.run(userId);
|
||||||
|
const result = deleteUserStatement.run(userId);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await destroyUserSessions(userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
function persistAccessKey(userId, input) {
|
function persistAccessKey(userId, input) {
|
||||||
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
|
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
|
||||||
credentialId: input.credentialId,
|
credentialId: input.credentialId,
|
||||||
@@ -802,6 +929,32 @@ async function getSession(sessionId) {
|
|||||||
async function destroySession(sessionId) {
|
async function destroySession(sessionId) {
|
||||||
await redis.del(sessionKey(sessionId));
|
await redis.del(sessionKey(sessionId));
|
||||||
}
|
}
|
||||||
|
async function destroyUserSessions(userId) {
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
|
||||||
|
cursor = nextCursor;
|
||||||
|
for (const key of keys) {
|
||||||
|
const payload = await redis.get(key);
|
||||||
|
if (!payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let session = null;
|
||||||
|
try {
|
||||||
|
session = JSON.parse(payload);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
if (!session || session.userId !== userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await destroySession(session.sessionId);
|
||||||
|
await clearPendingRegistration(session.sessionId);
|
||||||
|
closeSocketSession(userId, session.sessionId);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
function sessionKey(sessionId) {
|
function sessionKey(sessionId) {
|
||||||
return `privatechat:session:${sessionId}`;
|
return `privatechat:session:${sessionId}`;
|
||||||
}
|
}
|
||||||
@@ -889,12 +1042,87 @@ function parseClientMessage(rawMessage) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (parsed.data.type === 'ping') {
|
||||||
|
return { type: 'ping' };
|
||||||
|
}
|
||||||
|
if (parsed.data.type === 'image-generation') {
|
||||||
|
return {
|
||||||
|
type: 'image-generation',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
peerId: parsed.data.peerId,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
signal: normalizeSignal(parsed.data.signal),
|
signal: normalizeSignal(parsed.data.signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function generateImageFromPrompt(prompt) {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: ollamaImageModel,
|
||||||
|
prompt,
|
||||||
|
size: ollamaImageSize,
|
||||||
|
response_format: 'b64_json',
|
||||||
|
n: 1,
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: payload.error?.message;
|
||||||
|
throw new Error(errorMessage || 'Ollama image generation request failed.');
|
||||||
|
}
|
||||||
|
const imageBase64 = payload.data?.[0]?.b64_json?.trim();
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new Error('Ollama did not return image data.');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imageBase64,
|
||||||
|
mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Ollama image generation timed out.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function inferImageMimeType(imageBuffer) {
|
||||||
|
if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 12 &&
|
||||||
|
imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP') {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
if (imageBuffer.length >= 6) {
|
||||||
|
const header = imageBuffer.subarray(0, 6).toString('ascii');
|
||||||
|
if (header === 'GIF87a' || header === 'GIF89a') {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
function normalizeSignal(signal) {
|
function normalizeSignal(signal) {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
@@ -1001,6 +1229,35 @@ function resolveStoragePath(targetPath) {
|
|||||||
function resolveProjectPath(targetPath) {
|
function resolveProjectPath(targetPath) {
|
||||||
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
||||||
}
|
}
|
||||||
|
function parseAllowedOrigins(value) {
|
||||||
|
if (!value) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(value
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => normalizeOrigin(origin))
|
||||||
|
.filter((origin) => origin.length > 0 && origin !== 'null'));
|
||||||
|
}
|
||||||
|
function normalizeOrigin(origin) {
|
||||||
|
const trimmed = origin.trim();
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
function isAllowedRequestOrigin(originHeader) {
|
||||||
|
if (!originHeader) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
|
if (origin === 'null') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (allowedCorsOrigins.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return allowedCorsOrigins.has(origin);
|
||||||
|
}
|
||||||
function resolveWebAuthnOrigin(request) {
|
function resolveWebAuthnOrigin(request) {
|
||||||
const originHeader = request.headers.origin;
|
const originHeader = request.headers.origin;
|
||||||
if (typeof originHeader === 'string' && originHeader.length > 0) {
|
if (typeof originHeader === 'string' && originHeader.length > 0) {
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ type PendingApprovalUser = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminUserSummary = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
approvedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type DatabaseAccessKeyRow = {
|
type DatabaseAccessKeyRow = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -97,17 +106,43 @@ type SignalPayload =
|
|||||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
|
|
||||||
type ClientMessage = {
|
type ClientMessage =
|
||||||
|
| {
|
||||||
type: 'signal';
|
type: 'signal';
|
||||||
to: string;
|
to: string;
|
||||||
signal: SignalPayload;
|
signal: SignalPayload;
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'ping';
|
||||||
|
};
|
||||||
|
|
||||||
type ServerMessage =
|
type ServerMessage =
|
||||||
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
|
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
|
||||||
| { type: 'peer-joined'; peer: PublicUser }
|
| { type: 'peer-joined'; peer: PublicUser }
|
||||||
| { type: 'peer-left'; peerId: string }
|
| { type: 'peer-left'; peerId: string }
|
||||||
| { type: 'signal'; from: string; signal: SignalPayload }
|
| { type: 'signal'; from: string; signal: SignalPayload }
|
||||||
|
| {
|
||||||
|
type: 'image-generated';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
mimeType: string;
|
||||||
|
imageBase64: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'image-generation-error';
|
||||||
|
requestId: string;
|
||||||
|
peerId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
type StoredCredentials = {
|
type StoredCredentials = {
|
||||||
@@ -194,6 +229,10 @@ const approvePendingUserParamsSchema = z.object({
|
|||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const adminDeleteUserParamsSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -221,9 +260,18 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('image-generation'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
peerId: z.string().min(1),
|
||||||
|
prompt: z.string().trim().min(1).max(4000),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('ping'),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
|
|
||||||
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
|
||||||
@@ -236,8 +284,14 @@ const masterKeyPath = resolveStoragePath(
|
|||||||
const frontendDistPath = resolveProjectPath(
|
const frontendDistPath = resolveProjectPath(
|
||||||
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser',
|
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser',
|
||||||
);
|
);
|
||||||
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
|
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
|
||||||
|
const corsMethods = ['GET', 'POST', 'OPTIONS'];
|
||||||
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
|
||||||
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
||||||
@@ -304,6 +358,11 @@ const selectPendingUsersStatement = database.prepare(`
|
|||||||
WHERE is_active = 0
|
WHERE is_active = 0
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`);
|
`);
|
||||||
|
const selectAllUsersStatement = database.prepare(`
|
||||||
|
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
const approveUserStatement = database.prepare(`
|
const approveUserStatement = database.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_active = 1, approved_at = ?
|
SET is_active = 1, approved_at = ?
|
||||||
@@ -338,11 +397,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
|
|||||||
FROM webauthn_credentials
|
FROM webauthn_credentials
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteAccessKeysByUserStatement = database.prepare(`
|
||||||
|
DELETE FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
`);
|
||||||
const updateAccessKeyStatement = database.prepare(`
|
const updateAccessKeyStatement = database.prepare(`
|
||||||
UPDATE webauthn_credentials
|
UPDATE webauthn_credentials
|
||||||
SET encrypted_registration = ?
|
SET encrypted_registration = ?
|
||||||
WHERE credential_id = ?
|
WHERE credential_id = ?
|
||||||
`);
|
`);
|
||||||
|
const deleteUserStatement = database.prepare(`
|
||||||
|
DELETE FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
|
||||||
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
|
||||||
@@ -351,8 +418,12 @@ const socketsByUserId = new Map<string, Map<string, WebSocket>>();
|
|||||||
await redis.ping();
|
await redis.ping();
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
|
origin(origin, callback) {
|
||||||
|
callback(null, isAllowedRequestOrigin(origin));
|
||||||
|
},
|
||||||
credentials: false,
|
credentials: false,
|
||||||
|
allowedHeaders: corsAllowedHeaders,
|
||||||
|
methods: corsMethods,
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.register(jwt, {
|
await app.register(jwt, {
|
||||||
@@ -664,6 +735,53 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: listAdminUsers(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/users/:userId', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isApprovalAdmin(authContext.user)) {
|
||||||
|
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid user deletion request.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedUser = await deleteUserAccount(parsed.data.userId);
|
||||||
|
|
||||||
|
if (!deletedUser) {
|
||||||
|
return reply.code(404).send({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: toPublicUser(deletedUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
@@ -829,6 +947,12 @@ await app.listen({ port, host: '0.0.0.0' });
|
|||||||
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
|
||||||
|
|
||||||
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
|
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
|
||||||
|
if (!isAllowedRequestOrigin(request.headers.origin)) {
|
||||||
|
send(socket, { type: 'error', message: 'Origin not allowed.' });
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const query = wsQuerySchema.safeParse(request.query);
|
const query = wsQuerySchema.safeParse(request.query);
|
||||||
|
|
||||||
if (!query.success) {
|
if (!query.success) {
|
||||||
@@ -901,6 +1025,37 @@ async function handleSocketMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'ping') {
|
||||||
|
send(socket, { type: 'pong' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'image-generation') {
|
||||||
|
try {
|
||||||
|
const generatedImage = await generateImageFromPrompt(parsed.prompt);
|
||||||
|
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generated',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
prompt: parsed.prompt,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
mimeType: generatedImage.mimeType,
|
||||||
|
imageBase64: generatedImage.imageBase64,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'image-generation-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
peerId: parsed.peerId,
|
||||||
|
message: error instanceof Error ? error.message : 'Image generation failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
|
|
||||||
@@ -1056,6 +1211,19 @@ function listPendingApprovalUsers(): PendingApprovalUser[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listAdminUsers(): AdminUserSummary[] {
|
||||||
|
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
isActive: row.is_active === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
approvedAt: row.approved_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function approveUser(userId: string): UserRecord | null {
|
function approveUser(userId: string): UserRecord | null {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -1067,6 +1235,25 @@ function approveUser(userId: string): UserRecord | null {
|
|||||||
return findUserById(userId);
|
return findUserById(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteUserAccount(userId: string): Promise<UserRecord | null> {
|
||||||
|
const user = findUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAccessKeysByUserStatement.run(userId);
|
||||||
|
|
||||||
|
const result = deleteUserStatement.run(userId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await destroyUserSessions(userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
function persistAccessKey(
|
function persistAccessKey(
|
||||||
userId: string,
|
userId: string,
|
||||||
input: {
|
input: {
|
||||||
@@ -1248,6 +1435,39 @@ async function destroySession(sessionId: string): Promise<void> {
|
|||||||
await redis.del(sessionKey(sessionId));
|
await redis.del(sessionKey(sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function destroyUserSessions(userId: string): Promise<void> {
|
||||||
|
let cursor = '0';
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
|
||||||
|
cursor = nextCursor;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const payload = await redis.get(key);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: SessionRecord | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
session = JSON.parse(payload) as SessionRecord;
|
||||||
|
} catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || session.userId !== userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await destroySession(session.sessionId);
|
||||||
|
await clearPendingRegistration(session.sessionId);
|
||||||
|
closeSocketSession(userId, session.sessionId);
|
||||||
|
}
|
||||||
|
} while (cursor !== '0');
|
||||||
|
}
|
||||||
|
|
||||||
function sessionKey(sessionId: string): string {
|
function sessionKey(sessionId: string): string {
|
||||||
return `privatechat:session:${sessionId}`;
|
return `privatechat:session:${sessionId}`;
|
||||||
}
|
}
|
||||||
@@ -1364,6 +1584,19 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === 'ping') {
|
||||||
|
return { type: 'ping' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === 'image-generation') {
|
||||||
|
return {
|
||||||
|
type: 'image-generation',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
peerId: parsed.data.peerId,
|
||||||
|
prompt: parsed.data.prompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
@@ -1371,7 +1604,88 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSignal(signal: ClientMessage['signal']): SignalPayload {
|
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${ollamaServerUrl}/v1/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: ollamaImageModel,
|
||||||
|
prompt,
|
||||||
|
size: ollamaImageSize,
|
||||||
|
response_format: 'b64_json',
|
||||||
|
n: 1,
|
||||||
|
}),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json() as {
|
||||||
|
error?: { message?: string } | string;
|
||||||
|
data?: Array<{ b64_json?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = typeof payload.error === 'string'
|
||||||
|
? payload.error
|
||||||
|
: payload.error?.message;
|
||||||
|
throw new Error(errorMessage || 'Ollama image generation request failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBase64 = payload.data?.[0]?.b64_json?.trim();
|
||||||
|
|
||||||
|
if (!imageBase64) {
|
||||||
|
throw new Error('Ollama did not return image data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageBase64,
|
||||||
|
mimeType: inferImageMimeType(Buffer.from(imageBase64, 'base64')),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new Error('Ollama image generation timed out.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferImageMimeType(imageBuffer: Buffer): string {
|
||||||
|
if (imageBuffer.length >= 8 && imageBuffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
||||||
|
return 'image/png';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer.length >= 3 && imageBuffer.subarray(0, 3).equals(Buffer.from([255, 216, 255]))) {
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
imageBuffer.length >= 12 &&
|
||||||
|
imageBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||||
|
imageBuffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
) {
|
||||||
|
return 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageBuffer.length >= 6) {
|
||||||
|
const header = imageBuffer.subarray(0, 6).toString('ascii');
|
||||||
|
|
||||||
|
if (header === 'GIF87a' || header === 'GIF89a') {
|
||||||
|
return 'image/gif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
type: 'sdp',
|
type: 'sdp',
|
||||||
@@ -1508,6 +1822,47 @@ function resolveProjectPath(targetPath: string): string {
|
|||||||
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAllowedOrigins(value: string | undefined): Set<string> {
|
||||||
|
if (!value) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => normalizeOrigin(origin))
|
||||||
|
.filter((origin) => origin.length > 0 && origin !== 'null'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(origin: string): string {
|
||||||
|
const trimmed = origin.trim();
|
||||||
|
|
||||||
|
if (trimmed === 'null') {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedRequestOrigin(originHeader: string | undefined): boolean {
|
||||||
|
if (!originHeader) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
|
|
||||||
|
if (origin === 'null') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedCorsOrigins.size === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowedCorsOrigins.has(origin);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveWebAuthnOrigin(request: FastifyRequest): string {
|
function resolveWebAuthnOrigin(request: FastifyRequest): string {
|
||||||
const originHeader = request.headers.origin;
|
const originHeader = request.headers.origin;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user