8 Commits
1.0 ... 3.0

Author SHA1 Message Date
f0e2b60f43 Transcription on mac 2026-03-11 03:08:27 +01:00
0da98bfd96 quick cosmetic fix 2026-03-11 00:44:36 +01:00
189f989c0d Dictation through AI 2026-03-11 00:26:49 +01:00
d2c4152ea7 Many features 2026-03-10 22:36:21 +01:00
df309d088c PWA 2026-03-10 04:13:32 +01:00
506a824401 minor fixes and improvments 2026-03-10 03:27:11 +01:00
61612b52d3 Many new functionalities 2026-03-10 02:49:27 +01:00
640d92d231 json viewer 2026-03-09 20:40:21 +01:00
36 changed files with 4537 additions and 194 deletions

1
.gitignore vendored
View File

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

View File

@@ -71,6 +71,13 @@ The repo also includes a multiplatform SwiftUI client in `apple-client/` for mac
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
The backend also exposes the latest Angular browser build through an API that native clients can sync into their local `WKWebView` bundle cache:
- `GET /api/web-app/manifest`: Returns a bundle manifest with a stable `bundleId`, latest `generatedAt` timestamp, and a file list containing relative paths, SHA-256 hashes, MIME types, sizes, and download URLs.
- `GET /api/web-app/files/<relative-path>`: Streams an individual file from `client/dist/client/browser` with `ETag` and `Last-Modified` headers for native caching.
If the Angular build does not exist yet, those endpoints return `404`.
## Backend environment
The backend accepts these environment variables:
@@ -83,7 +90,7 @@ The backend accepts these environment variables:
- `PRIVATECHAT_MASTER_KEY`: Optional master key for encrypting SQLite secret material and user credentials.
- `PRIVATECHAT_MASTER_KEY_PATH`: Optional file path for the generated master key.
- `PRIVATECHAT_WEB_DIST_DIR`: Directory containing the prebuilt Angular browser bundle. Default `client/dist/client/browser`.
- `CORS_ORIGIN`: Optional allowed browser origin. If omitted, the server reflects request origins.
- `CORS_ORIGIN`: Optional comma-separated browser-origin allowlist. If omitted, the server accepts request origins. The special `null` origin from embedded `file://` webviews is accepted.
- `WEBAUTHN_ORIGIN`: Browser origin allowed to register access keys. Default `http://localhost:4200`.
- `WEBAUTHN_RP_ID`: WebAuthn RP ID. Default hostname of `WEBAUTHN_ORIGIN`.
- `WEBAUTHN_RP_NAME`: Friendly RP name for browser access-key prompts. Default `PrivateChat`.

View File

@@ -53,6 +53,9 @@
],
"styles": [
"src/styles.scss"
],
"scripts": [
"src/jsonview.js"
]
},
"configurations": {
@@ -65,8 +68,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "10kB",
"maximumError": "12kB"
}
],
"outputHashing": "all"

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -0,0 +1,21 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="64" y1="40" x2="448" y2="472" gradientUnits="userSpaceOnUse">
<stop stop-color="#10233B"/>
<stop offset="1" stop-color="#06111D"/>
</linearGradient>
<linearGradient id="accent" x1="124" y1="124" x2="389" y2="389" gradientUnits="userSpaceOnUse">
<stop stop-color="#84F4D6"/>
<stop offset="1" stop-color="#56ABFF"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="120" fill="url(#bg)"/>
<circle cx="154" cy="148" r="96" fill="#8DF0DF" fill-opacity="0.16"/>
<circle cx="394" cy="118" r="78" fill="#58ABFF" fill-opacity="0.16"/>
<path d="M152 164C152 132.967 177.167 107.8 208.2 107.8H303.8C334.833 107.8 360 132.967 360 164V227.7C360 258.733 334.833 283.9 303.8 283.9H257.8L198.6 335.3C190.343 342.468 177.4 336.601 177.4 325.666V283.9H208.2C177.167 283.9 152 258.733 152 227.7V164Z" fill="url(#accent)"/>
<rect x="195" y="154" width="122" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
<rect x="195" y="196" width="86" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
<path d="M354.8 334.9C354.8 379.013 319.046 414.767 274.933 414.767C255.288 414.767 237.299 407.666 223.396 395.888L172.572 410.4C163.669 412.942 155.453 404.726 157.995 395.823L172.507 344.999C160.729 331.096 153.628 313.107 153.628 293.462C153.628 249.349 189.382 213.595 233.495 213.595C277.608 213.595 313.362 249.349 313.362 293.462C313.362 304.056 311.3 314.171 307.553 323.426L344.213 360.086C350.981 366.854 354.8 376.033 354.8 385.604V334.9Z" fill="#0F2540"/>
<circle cx="233.495" cy="293.462" r="52.895" fill="#E8F3FF"/>
<path d="M233.495 258.246C252.941 258.246 268.711 274.016 268.711 293.462C268.711 312.908 252.941 328.678 233.495 328.678C214.049 328.678 198.279 312.908 198.279 293.462C198.279 274.016 214.049 258.246 233.495 258.246Z" fill="#56ABFF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,40 @@
{
"id": "/",
"name": "PrivateChat",
"short_name": "PrivateChat",
"description": "Private peer-to-peer chat with Angular, Fastify, and WebRTC.",
"lang": "en",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#08111d",
"theme_color": "#08111d",
"icons": [
{
"src": "pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,77 @@
const APP_SHELL_CACHE = 'privatechat-app-shell-v1';
const APP_SHELL_FILES = [
'/',
'/index.html',
'/manifest.webmanifest',
'/favicon.ico',
'/apple-touch-icon.png',
'/pwa-192x192.png',
'/pwa-512x512.png',
'/maskable-192x192.png',
'/maskable-512x512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => Promise.all(
cacheNames
.filter((cacheName) => cacheName !== APP_SHELL_CACHE)
.map((cacheName) => caches.delete(cacheName)),
)),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname === '/ws') {
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const responseCopy = response.clone();
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put('/index.html', responseCopy));
return response;
})
.catch(async () => {
const cache = await caches.open(APP_SHELL_CACHE);
return cache.match('/index.html') || Response.error();
}),
);
return;
}
event.respondWith(
caches.match(request).then((cachedResponse) => {
const networkFetch = fetch(request)
.then((response) => {
if (response.ok) {
const responseCopy = response.clone();
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put(request, responseCopy));
}
return response;
})
.catch(() => cachedResponse || Response.error());
return cachedResponse || networkFetch;
}),
);
});

View File

@@ -9,6 +9,10 @@ export const routes: Routes = [
path: '',
component: HomePageComponent,
},
{
path: 'chat',
component: ChatPageComponent,
},
{
path: 'chat/:peerId',
component: ChatPageComponent,

View File

@@ -1,6 +1,43 @@
<main class="chat-shell py-4">
<div class="container-lg">
<section class="chat-page panel p-3 p-lg-4">
<app-peer-video-modal
[visible]="remoteVideoModalVisible()"
[stream]="remoteVideoStream()"
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
(closeRequested)="closeRemoteVideoModal()"
></app-peer-video-modal>
<audio #callAudioElement hidden autoplay playsinline></audio>
@if (incomingVoiceCallPeer(); as callingPeer) {
<div class="call-modal-backdrop">
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()">
<div class="mb-3">
<div>
<h2 class="h5 mb-1">Incoming voice call</h2>
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p>
</div>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-end">
<button
class="btn btn-success"
type="button"
(click)="acceptIncomingVoiceCall(callingPeer.id)"
>
Accept
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="rejectIncomingVoiceCall(callingPeer.id)"
>
Reject
</button>
</div>
</section>
</div>
}
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
<div>
<a class="back-link" routerLink="/">← Back to dashboard</a>
@@ -42,10 +79,14 @@
}
@for (connectedPeer of session.peers(); track connectedPeer.id) {
<button
class="peer-tile text-start"
type="button"
<article
class="peer-tile"
[class.peer-tile-active]="connectedPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
>
<button
class="peer-tile-main text-start"
type="button"
(click)="switchPeer(connectedPeer.id)"
>
<div class="peer-tile-row">
@@ -71,6 +112,16 @@
></span>
</div>
</button>
<button
class="peer-tile-delete"
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(connectedPeer.id, $event)"
>
🗑️
</button>
</article>
}
</div>
</aside>
@@ -90,8 +141,19 @@
[class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'"
>
@if (entry.direction !== 'system') {
<div class="bubble-actions">
<button
class="bubble-delete"
class="bubble-action"
type="button"
(click)="toggleForwardMenu(entry, $event)"
title="Forward message"
aria-label="Forward message"
>
</button>
<button
class="bubble-action bubble-delete"
type="button"
(click)="deleteMessage(entry)"
title="Delete message"
@@ -99,9 +161,21 @@
>
×
</button>
@if (isForwardMenuOpen(entry.id)) {
<div class="bubble-forward-menu">
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
<option value="">Forward to…</option>
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
}
</select>
</div>
}
</div>
}
<div class="bubble-meta">
<span>{{ entry.authorLabel }}</span>
<time>{{ entry.createdAt | date: 'shortTime' }}</time>
<span class="bubble-author">{{ entry.authorLabel }}</span>
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
</div>
@switch (entry.kind) {
@@ -121,6 +195,22 @@
/>
}
@if (isVideoEntry(entry)) {
<video
class="bubble-video"
[src]="entry.downloadUrl"
controls
autoplay
muted
playsinline
preload="metadata"
></video>
}
@if (isIncomingJsonFileEntry(entry)) {
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
}
<div>
<div class="fw-semibold">{{ entry.fileName }}</div>
@if (entry.fileSize) {
@@ -133,8 +223,28 @@
}
</div>
}
@case ('voice') {
<div class="voice-bubble">
<div class="voice-bubble-label">Voice message</div>
@if (entry.downloadUrl) {
<audio
class="voice-player"
[src]="entry.downloadUrl"
controls
preload="metadata"
></audio>
}
</div>
}
@default {
@if (entry.showSpinner) {
<div class="bubble-system-status">
<span class="bubble-spinner" aria-hidden="true"></span>
<p class="mb-0">{{ entry.text }}</p>
</div>
} @else {
<p class="mb-0">{{ entry.text }}</p>
}
}
}
</article>
@@ -142,7 +252,92 @@
</div>
<div class="composer">
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<div class="composer-toolbar">
@if (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="startVoiceCall(selectedPeer.id)"
title="Start voice call"
aria-label="Start voice call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End voice call"
aria-label="End voice call"
>
🛑
</button>
}
<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>
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[class.composer-voice-recording]="isRecordingVoice()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<button
class="composer-dictation"
type="button"
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
<input
#fileInput
class="composer-file-input"
@@ -162,15 +357,45 @@
</button>
}
<textarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
<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()"
placeholder="Write a text message to your peer"
></textarea>
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
@@ -184,6 +409,7 @@
</div>
</div>
</div>
</div>
</section>
</div>
</main>

View File

@@ -16,6 +16,23 @@
box-shadow: 0 20px 60px var(--shadow-color);
}
.chat-page {
width: min(100%, 800px);
margin-inline: auto;
overflow-x: hidden;
}
.call-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1250;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(3, 8, 14, 0.52);
backdrop-filter: blur(8px);
}
.back-link {
color: var(--link-color);
text-decoration: none;
@@ -77,12 +94,12 @@
.chat-layout {
display: grid;
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
gap: 1.25rem;
grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
gap:1.25rem;
}
.peer-sidebar {
padding: 1rem;
padding:1rem;
border-radius: 1.3rem;
border: 1px solid var(--surface-border-soft);
background: var(--panel-soft-background);
@@ -106,8 +123,12 @@
}
.peer-tile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.95rem 1rem;
padding: 0.8rem 0.85rem 0.8rem 1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: inherit;
@@ -115,6 +136,30 @@
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.peer-tile-main {
min-width: 0;
padding: 0;
border: 0;
color: inherit;
background: transparent;
}
.peer-tile-delete {
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
font-size: 1rem;
line-height: 1;
}
.peer-tile-delete:hover,
.peer-tile-delete:focus-visible {
background: var(--badge-background);
}
.peer-tile:hover,
.peer-tile:focus-visible,
.peer-tile-active {
@@ -123,6 +168,11 @@
background: var(--surface-hover-background);
}
.peer-tile-unread {
border-color: #c62828;
box-shadow: inset 0 0 0 2px #c62828;
}
.peer-tile-row {
display: flex;
align-items: center;
@@ -183,23 +233,55 @@
position: relative;
align-self: start;
max-width: min(75%, 34rem);
padding: 0.9rem 1rem;
padding: 0.9rem 3.4rem 0.9rem 1rem;
border-radius: 1.2rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
}
.bubble-delete {
.bubble-actions {
position: absolute;
top: 0.45rem;
right: 0.55rem;
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.bubble-action {
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: 0;
border-radius: 999px;
color: #fff;
background: var(--danger-background);
background: var(--badge-background);
line-height: 1;
font-size: 1rem;
font-size: 0.9rem;
}
.bubble-delete {
background: var(--danger-background);
}
.bubble-forward-menu {
position: absolute;
top: 1.9rem;
right: 0;
z-index: 2;
min-width: 12rem;
padding: 0.45rem;
border: 1px solid var(--surface-border);
border-radius: 0.85rem;
background: var(--surface-background);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
}
.bubble-forward-select {
width: 100%;
border: 1px solid var(--input-border);
border-radius: 0.65rem;
color: var(--page-text);
background: var(--input-background);
}
.bubble-incoming {
@@ -222,28 +304,64 @@
}
.bubble-meta {
display: flex;
justify-content: space-between;
gap: 1rem;
display: grid;
gap: 0.12rem;
margin-bottom: 0.35rem;
font-size: 0.78rem;
opacity: 0.7;
}
.bubble-time {
display: block;
}
.bubble-system-status {
display: inline-flex;
align-items: center;
gap: 0.7rem;
}
.bubble-spinner {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
border: 0.15rem solid currentColor;
border-right-color: transparent;
border-radius: 999px;
opacity: 0.8;
animation: bubble-spin 700ms linear infinite;
}
.composer {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.9rem;
align-items: end;
gap: 0.85rem;
padding-top: 1rem;
margin-top: 1rem;
border-top: 1px solid var(--surface-border-soft);
}
.composer-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.composer-emoji-picker-shell {
position: relative;
}
.composer-file-input {
display: none;
}
.composer-camera,
.composer-call,
.composer-dictation,
.composer-hangup,
.composer-voice,
.composer-image-generate,
.composer-emoji-trigger,
.composer-plus,
.send-emoji {
width: 3.25rem;
@@ -265,26 +383,117 @@
color: var(--placeholder-color);
}
.composer-textarea {
min-height: 7rem;
}
.composer-call {
color: var(--page-text);
background: linear-gradient(135deg, #bfe9ff, #96c3ff);
}
.composer-camera,
.composer-emoji-trigger,
.composer-plus {
color: var(--page-text);
background: var(--badge-background);
}
.composer-dictation {
color: var(--page-text);
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
}
.composer-dictation-active,
.composer-hangup,
.composer-voice-recording {
color: #fff;
background: linear-gradient(135deg, #ff7d63, #dc3e5d);
}
.composer-voice {
color: var(--page-text);
background: linear-gradient(135deg, #ffd8bf, #ff9b8a);
}
.composer-voice-recording {
box-shadow: 0 0 0 0.2rem rgba(220, 62, 93, 0.18);
}
.composer-image-generate {
color: var(--page-text);
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
}
.send-emoji {
background: linear-gradient(135deg, #def7dd, #9bd5ff);
}
.bubble-image {
.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-author,
.bubble-download,
.voice-bubble-label {
font-weight: 600;
}
.bubble-image,
.bubble-video {
width: 200px;
max-width: 100%;
height: auto;
border-radius: 1rem;
display: block;
border-radius: 1rem;
}
.bubble-download {
color: inherit;
font-weight: 600;
.bubble-video {
background: #000;
}
.bubble-download { color: inherit; }
.voice-bubble {
display: grid;
gap: 0.65rem;
}
.voice-bubble-label { font-size: 0.88rem; }
.voice-player {
display: block;
width: min(100%, 18rem);
}
.bubble-json {
@@ -324,6 +533,12 @@
}
}
@keyframes bubble-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767.98px) {
.chat-layout {
grid-template-columns: 1fr;
@@ -336,4 +551,8 @@
.bubble {
max-width: 88%;
}
.composer-toolbar {
justify-content: flex-start;
}
}

View File

@@ -1,34 +1,127 @@
import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject } from '@angular/core';
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { PeerVideoModalComponent } from './peer-video-modal.component';
import { ChatSessionService } from './chat-session.service';
import type { ChatEntry, ConnectionState } from './models';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink],
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
export class ChatPageComponent {
export class ChatPageComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly ngZone = inject(NgZone);
private readonly routeParamMap = toSignal(this.route.paramMap, {
initialValue: this.route.snapshot.paramMap,
});
private composerSelectionStart = 0;
private composerSelectionEnd = 0;
private voiceRecorder: MediaRecorder | null = null;
private voiceStream: MediaStream | null = null;
private voiceChunks: Blob[] = [];
private discardRecordedVoice = false;
private recordingPeerId: string | null = null;
private dictationRecorder: MediaRecorder | null = null;
private dictationStream: MediaStream | null = null;
private dictationChunks: Blob[] = [];
private dictationBaseText = '';
private discardRecordedDictation = false;
private dictationCompletionPromise: Promise<void> | null = null;
private resolveDictationCompletion: (() => void) | null = null;
private dictationApplyToken = 0;
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
messageText = '';
readonly forwardingEntryId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false);
readonly isTranscribingDictation = signal(false);
readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔',
'😅', '😭', '😡', '😴', '🙃',
'👍', '👎', '👏', '🙏', '🤝',
'🎉', '🔥', '❤️', '💡', '✅',
'🚀', '👀', '📹', '📎', '💬',
'🌍', '⚡', '⭐', '🎵', '📷',
'🗑️', '⏩', '🛑', '🙌', '👌',
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser());
readonly incomingVoiceCallPeer = computed(() => {
const peerId = this.session.incomingVoiceCallPeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly conversation = computed(() =>
this.session
.messages()
.filter((entry) => entry.peerId === this.peerId()),
);
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
);
readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
);
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.peerId();
if (!peerId) {
return 'idle';
}
if (this.session.activeVoiceCallPeerId() === peerId) {
return 'active';
}
if (this.session.outgoingVoiceCallPeerId() === peerId) {
return 'outgoing';
}
if (this.session.incomingVoiceCallPeerId() === peerId) {
return 'incoming';
}
return 'idle';
});
readonly canStartSelectedVoiceCall = computed(() => {
const selectedPeer = this.peer();
if (!selectedPeer || selectedPeer.channelState !== 'open') {
return false;
}
const activePeerId = this.session.activeVoiceCallPeerId();
const outgoingPeerId = this.session.outgoingVoiceCallPeerId();
const incomingPeerId = this.session.incomingVoiceCallPeerId();
return !activePeerId && !outgoingPeerId && !incomingPeerId;
});
readonly canEndSelectedVoiceCall = computed(() => {
const peerId = this.peerId();
return !!peerId && (
this.session.activeVoiceCallPeerId() === peerId ||
this.session.outgoingVoiceCallPeerId() === peerId
);
});
readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer();
@@ -62,6 +155,17 @@ export class ChatPageComponent {
this.session.selectPeer(peerId);
void this.session.connectToPeer(peerId);
});
effect(() => {
this.remoteCallAudioStream();
this.syncCallAudioSource();
});
}
ngOnDestroy(): void {
void this.stopDictation(true);
this.stopVoiceRecording(true);
this.detachCallAudioSource();
}
async ensureConnection(): Promise<void> {
@@ -82,8 +186,33 @@ export class ChatPageComponent {
return;
}
await this.stopDictation(false);
await this.session.sendText(peerId, this.messageText);
this.messageText = '';
this.emojiPickerOpen.set(false);
this.composerSelectionStart = 0;
this.composerSelectionEnd = 0;
}
async requestGeneratedImage(): Promise<void> {
const peerId = this.peerId();
if (!peerId) {
return;
}
await this.stopDictation(false);
const requested = await this.session.requestGeneratedImage(peerId, this.messageText);
if (!requested) {
return;
}
this.messageText = '';
this.handleMessageTextChange('');
this.emojiPickerOpen.set(false);
this.composerSelectionStart = 0;
this.composerSelectionEnd = 0;
}
handleComposerEnter(event: Event): void {
@@ -105,6 +234,37 @@ export class ChatPageComponent {
this.session.notifyTypingActivity(peerId, text);
}
trackComposerSelection(textarea: HTMLTextAreaElement): void {
this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length;
this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart;
}
toggleEmojiPicker(event?: Event): void {
event?.stopPropagation();
this.emojiPickerOpen.update((open) => !open);
}
insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void {
const selectionStart = textarea.selectionStart ?? this.composerSelectionStart;
const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd;
const before = this.messageText.slice(0, selectionStart);
const after = this.messageText.slice(selectionEnd);
this.messageText = `${before}${emoji}${after}`;
this.emojiPickerOpen.set(false);
this.handleMessageTextChange(this.messageText);
const nextSelection = selectionStart + emoji.length;
this.composerSelectionStart = nextSelection;
this.composerSelectionEnd = nextSelection;
queueMicrotask(() => {
textarea.focus();
textarea.setSelectionRange(nextSelection, nextSelection);
this.trackComposerSelection(textarea);
});
}
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
const file = input.files?.item(0);
@@ -116,18 +276,275 @@ export class ChatPageComponent {
input.value = '';
}
async toggleDictation(textarea: HTMLTextAreaElement): Promise<void> {
if (this.isDictating()) {
await this.stopDictation(false);
return;
}
if (this.isTranscribingDictation()) {
return;
}
const peerId = this.peerId();
if (!peerId) {
return;
}
if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') {
this.session.error.set('This browser does not support dictation recording.');
return;
}
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.session.error.set('This browser cannot access the microphone for dictation.');
return;
}
this.dictationBaseText = this.messageText;
this.discardRecordedDictation = false;
this.dictationApplyToken += 1;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMimeType = this.preferredVoiceMimeType();
const recorder = preferredMimeType
? new MediaRecorder(stream, { mimeType: preferredMimeType })
: new MediaRecorder(stream);
const applyToken = this.dictationApplyToken;
this.dictationChunks = [];
this.dictationStream = stream;
this.dictationRecorder = recorder;
this.dictationCompletionPromise = new Promise<void>((resolve) => {
this.resolveDictationCompletion = resolve;
});
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.dictationChunks.push(event.data);
}
};
recorder.onerror = () => {
this.ngZone.run(() => {
this.session.error.set('Could not record dictation audio.');
this.cleanupDictationRecorder();
this.finishDictationCompletion();
});
};
recorder.onstop = () => {
const shouldDiscard = this.discardRecordedDictation;
const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm';
const blob = new Blob(this.dictationChunks, { type: mimeType });
this.ngZone.run(() => {
this.cleanupDictationRecorder();
if (shouldDiscard || blob.size === 0) {
this.finishDictationCompletion();
return;
}
this.isTranscribingDictation.set(true);
void this.transcribeDictation(blob, textarea, applyToken);
});
};
recorder.start();
this.isDictating.set(true);
this.session.error.set(null);
} catch {
this.session.error.set('Could not start dictation recording.');
this.cleanupDictationRecorder();
this.finishDictationCompletion();
}
}
async toggleVoiceRecording(): Promise<void> {
if (this.isRecordingVoice()) {
this.stopVoiceRecording(false);
return;
}
const peerId = this.peerId();
if (!peerId) {
return;
}
if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') {
this.session.error.set('This browser does not support voice recording.');
return;
}
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.session.error.set('This browser cannot access the microphone.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMimeType = this.preferredVoiceMimeType();
const recorder = preferredMimeType
? new MediaRecorder(stream, { mimeType: preferredMimeType })
: new MediaRecorder(stream);
this.voiceChunks = [];
this.discardRecordedVoice = false;
this.recordingPeerId = peerId;
this.voiceStream = stream;
this.voiceRecorder = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.voiceChunks.push(event.data);
}
};
recorder.onerror = () => {
this.session.error.set('Could not record voice message.');
this.cleanupVoiceRecording();
};
recorder.onstop = () => {
const shouldDiscard = this.discardRecordedVoice;
const targetPeerId = this.recordingPeerId;
const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm';
const blob = new Blob(this.voiceChunks, { type: mimeType });
this.cleanupVoiceRecording();
if (shouldDiscard || !targetPeerId || blob.size === 0) {
return;
}
void this.session.sendVoiceMessage(targetPeerId, blob, mimeType);
};
recorder.start();
this.isRecordingVoice.set(true);
this.session.error.set(null);
} catch {
this.session.error.set('Could not start microphone recording.');
this.cleanupVoiceRecording();
}
}
async deleteMessage(entry: ChatEntry): Promise<void> {
await this.session.deleteMessage(entry);
}
async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation();
await this.session.deleteConversation(peerId);
}
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
event?.stopPropagation();
if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) {
this.forwardingEntryId.set(null);
return;
}
this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id));
}
isForwardMenuOpen(entryId: string): boolean {
return this.forwardingEntryId() === entryId;
}
forwardTargets(entry: ChatEntry): PeerSummary[] {
if (entry.kind === 'system' || entry.direction === 'system') {
return [];
}
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
}
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
if (!targetPeerId) {
return;
}
await this.session.forwardMessage(targetPeerId, entry);
select.value = '';
this.forwardingEntryId.set(null);
}
async toggleCameraStream(peerId: string): Promise<void> {
if (this.session.isStreamingCameraToPeer(peerId)) {
await this.session.stopCameraStream(peerId);
return;
}
await this.session.startCameraStream(peerId);
}
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId);
}
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
if (!peerId) {
return;
}
if (peerId !== this.peerId()) {
await this.router.navigate(['/chat', peerId]);
}
await this.session.acceptVoiceCall(peerId);
}
rejectIncomingVoiceCall(peerId: string): void {
if (!peerId) {
return;
}
this.session.rejectVoiceCall(peerId);
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
isVideoEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'file' || !entry.downloadUrl) {
return false;
}
if (entry.fileMimeType?.startsWith('video/')) {
return true;
}
return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? '');
}
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
entry.direction === 'incoming' &&
!!entry.downloadUrl &&
!!entry.fileName &&
entry.fileName.toLowerCase().endsWith('.json')
);
}
isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId);
}
isPeerUnread(peerId: string): boolean {
return this.session.unreadPeerIds().includes(peerId);
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') {
return 'ok';
@@ -144,12 +561,207 @@ export class ChatPageComponent {
return this.indicatorTone(this.webRtcState()) === 'offline';
}
isStreamingCameraToSelectedPeer(): boolean {
const peerId = this.peerId();
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
}
closeRemoteVideoModal(): void {
const peerId = this.peerId();
if (!peerId) {
return;
}
this.session.dismissRemoteVideoModal(peerId);
}
async switchPeer(peerId: string): Promise<void> {
if (!peerId || peerId === this.peerId()) {
return;
}
await this.stopDictation(true);
this.stopVoiceRecording(true);
this.forwardingEntryId.set(null);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
}
private stopVoiceRecording(discard: boolean): void {
const recorder = this.voiceRecorder;
if (!recorder) {
this.discardRecordedVoice = discard;
this.cleanupVoiceRecording();
return;
}
this.discardRecordedVoice = discard;
if (recorder.state !== 'inactive') {
recorder.stop();
return;
}
this.cleanupVoiceRecording();
}
private cleanupVoiceRecording(): void {
if (this.voiceStream) {
for (const track of this.voiceStream.getTracks()) {
track.stop();
}
}
this.voiceRecorder = null;
this.voiceStream = null;
this.voiceChunks = [];
this.recordingPeerId = null;
this.isRecordingVoice.set(false);
}
private preferredVoiceMimeType(): string {
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
return '';
}
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '';
}
private async stopDictation(discard: boolean): Promise<void> {
const completion = this.dictationCompletionPromise;
if (discard) {
this.dictationApplyToken += 1;
this.messageText = this.dictationBaseText || this.messageText;
this.handleMessageTextChange(this.messageText);
this.isTranscribingDictation.set(false);
} else {
this.dictationBaseText = this.messageText;
}
if (this.dictationRecorder) {
this.discardRecordedDictation = discard;
if (this.dictationRecorder.state !== 'inactive') {
this.dictationRecorder.stop();
} else {
this.cleanupDictationRecorder();
this.finishDictationCompletion();
}
} else if (!completion) {
this.dictationBaseText = '';
}
if (completion) {
await completion;
}
}
private cleanupDictationRecorder(): void {
if (this.dictationStream) {
for (const track of this.dictationStream.getTracks()) {
track.stop();
}
}
this.dictationRecorder = null;
this.dictationStream = null;
this.dictationChunks = [];
this.discardRecordedDictation = false;
this.isDictating.set(false);
}
private finishDictationCompletion(): void {
this.resolveDictationCompletion?.();
this.resolveDictationCompletion = null;
this.dictationCompletionPromise = null;
this.dictationBaseText = '';
}
private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise<void> {
try {
const transcript = await this.session.requestSpeechTranscription(blob);
if (applyToken !== this.dictationApplyToken) {
return;
}
this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea);
} catch {
if (applyToken === this.dictationApplyToken) {
this.session.error.set('Dictation transcription failed.');
}
} finally {
if (applyToken === this.dictationApplyToken) {
this.isTranscribingDictation.set(false);
}
this.finishDictationCompletion();
}
}
private mergeDictatedText(baseText: string, transcript: string): string {
const trimmedTranscript = transcript.trim();
if (!trimmedTranscript) {
return baseText;
}
if (!baseText.trim()) {
return trimmedTranscript;
}
return `${baseText.trimEnd()} ${trimmedTranscript}`;
}
private applyDictatedText(text: string, textarea: HTMLTextAreaElement): void {
this.messageText = text;
textarea.value = text;
this.composerSelectionStart = text.length;
this.composerSelectionEnd = text.length;
this.handleMessageTextChange(text);
queueMicrotask(() => {
textarea.focus();
textarea.setSelectionRange(text.length, text.length);
});
}
private syncCallAudioSource(): void {
const audio = this.callAudioElement?.nativeElement;
if (!audio) {
return;
}
const stream = this.remoteCallAudioStream();
audio.srcObject = stream;
if (stream) {
void audio.play().catch(() => {
// Autoplay may wait for a browser gesture.
});
return;
}
audio.pause();
}
private detachCallAudioSource(): void {
const audio = this.callAudioElement?.nativeElement;
if (!audio) {
return;
}
audio.pause();
audio.srcObject = null;
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,7 +22,8 @@
border-radius: 2rem;
}
.panel {
.panel,
.session-card {
border-radius: 1.5rem;
}
@@ -77,15 +78,12 @@
.theme-toggle:hover,
.theme-toggle:focus-visible {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
background: var(--surface-hover-background);
transform: translateY(-1px);
}
.session-card {
min-width: min(100%, 18rem);
border-radius: 1.5rem;
}
.session-card { min-width: min(100%, 18rem); }
.status-pill {
display: inline-flex;
@@ -94,15 +92,19 @@
background: var(--badge-background);
}
.btn-accent {
.btn-accent,
.btn-accent:hover,
.btn-accent:focus-visible {
color: #06111d;
border: 0;
}
.btn-accent {
background: var(--accent-gradient);
}
.btn-accent:hover,
.btn-accent:focus-visible {
color: #06111d;
background: var(--accent-gradient-hover);
}

View File

@@ -1,9 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject } from '@angular/core';
import { Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import type { AdminUserSummary } from './models';
import { ThemeService } from './theme.service';
@Component({
@@ -23,6 +24,10 @@ export class HomePageComponent {
username = '';
password = '';
accessKeyLabel = '';
readonly adminUsers = signal<AdminUserSummary[]>([]);
readonly loadingAdminUsers = signal(false);
readonly deletingUserId = signal<string | null>(null);
readonly adminUsersError = signal<string | null>(null);
constructor(readonly session: ChatSessionService) {
this.serverUrl = session.serverUrl();
@@ -39,6 +44,19 @@ export class HomePageComponent {
void this.router.navigate(['/chat', activePeerId], { replaceUrl: true });
});
}
effect(() => {
const currentUser = this.session.currentUser();
if (!currentUser || !this.session.isApprovalAdmin()) {
this.adminUsers.set([]);
this.adminUsersError.set(null);
this.loadingAdminUsers.set(false);
return;
}
void this.reloadAdminUsers();
});
}
async submitAuth(): Promise<void> {
@@ -80,20 +98,55 @@ export class HomePageComponent {
this.accessKeyLabel = '';
}
canOpenChatUi(): boolean {
return this.session.peers().length > 0;
async reloadAdminUsers(): Promise<void> {
this.loadingAdminUsers.set(true);
this.adminUsersError.set(null);
try {
this.adminUsers.set(await this.session.loadAdminUsers());
} catch (error) {
this.adminUsersError.set(
error instanceof Error ? error.message : 'Could not load users.',
);
} finally {
this.loadingAdminUsers.set(false);
}
}
async deleteUser(user: AdminUserSummary): Promise<void> {
if (
typeof window !== 'undefined' &&
!window.confirm(`Delete user ${user.username}? This removes the account from SQLite.`)
) {
return;
}
this.deletingUserId.set(user.id);
this.adminUsersError.set(null);
try {
await this.session.deleteUserAccount(user.id);
this.adminUsers.update((users) => users.filter((candidate) => candidate.id !== user.id));
} catch (error) {
this.adminUsersError.set(
error instanceof Error ? error.message : 'Could not delete that user.',
);
} finally {
this.deletingUserId.set(null);
}
}
async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
if (!peerId) {
this.session.error.set('No connected peers are available yet.');
if (peerId) {
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
this.session.error.set(null);
await this.router.navigate(['/chat']);
}
cycleTheme(): void {

View File

@@ -0,0 +1,29 @@
:host {
display: block;
max-width: min(95%, 320px);
}
.json-viewer-shell {
width: min(95%, 480px);
overflow: hidden;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.06);
}
.json-viewer-host {
max-height: 18rem;
max-width: 100%;
overflow-x: auto;
overflow-y: auto;
padding: 0.75rem;
}
.json-viewer-error {
padding: 0 0.75rem 0.75rem;
color: inherit;
opacity: 0.7;
}
:host ::ng-deep .json-viewer-host .json-container {
min-width: max-content;
}

View File

@@ -0,0 +1,101 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
import type { ChatEntry } from './models';
type JsonViewTree = object;
type JsonViewApi = {
renderJSON(value: unknown, container: HTMLElement): JsonViewTree;
expand?(tree: JsonViewTree): void;
destroy?(tree: JsonViewTree): void;
};
declare global {
interface Window {
jsonview?: JsonViewApi;
}
}
@Component({
selector: 'app-json-file-viewer',
imports: [CommonModule],
template: `
<div class="json-viewer-shell">
<div #host class="json-viewer-host"></div>
@if (errorMessage) {
<p class="json-viewer-error mb-0">{{ errorMessage }}</p>
}
</div>
`,
styleUrl: './json-file-viewer.component.scss',
})
export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input({ required: true }) entry!: ChatEntry;
@ViewChild('host') private readonly host?: ElementRef<HTMLDivElement>;
errorMessage: string | null = null;
private renderedTree: JsonViewTree | null = null;
private renderVersion = 0;
ngAfterViewInit(): void {
void this.renderJsonTree();
}
ngOnChanges(): void {
void this.renderJsonTree();
}
ngOnDestroy(): void {
this.destroyRenderedTree();
}
private async renderJsonTree(): Promise<void> {
const host = this.host?.nativeElement;
if (!host || !this.entry.downloadUrl) {
return;
}
const renderVersion = ++this.renderVersion;
const jsonview = window.jsonview;
this.destroyRenderedTree();
host.replaceChildren();
this.errorMessage = null;
if (!jsonview) {
this.errorMessage = 'JSON viewer unavailable.';
return;
}
try {
const response = await fetch(this.entry.downloadUrl);
const content = await response.text();
const parsed = JSON.parse(content);
if (renderVersion !== this.renderVersion) {
return;
}
const tree = jsonview.renderJSON(parsed, host);
jsonview.expand?.(tree);
this.renderedTree = tree;
} catch {
if (renderVersion !== this.renderVersion) {
return;
}
this.errorMessage = 'Could not render JSON preview.';
}
}
private destroyRenderedTree(): void {
if (!this.renderedTree) {
return;
}
window.jsonview?.destroy?.(this.renderedTree);
this.renderedTree = null;
}
}

View File

@@ -35,6 +35,15 @@ export interface PendingApprovalUser {
createdAt: string;
}
export interface AdminUserSummary {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
}
export interface AccessKeySummary {
id: string;
credentialId: string;
@@ -85,9 +94,10 @@ export interface ChatEntry {
id: string;
peerId: string;
direction: 'incoming' | 'outgoing' | 'system';
kind: 'text' | 'json' | 'file' | 'system';
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number;
authorLabel: string;
showSpinner?: boolean;
text?: string;
payload?: unknown;
fileName?: string;
@@ -105,6 +115,32 @@ export type ServerEvent =
| { type: 'peer-joined'; peer: UserProfile }
| { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload }
| {
type: 'image-generated';
requestId: string;
peerId: string;
prompt: string;
createdAt: number;
mimeType: string;
imageBase64: string;
}
| {
type: 'image-generation-error';
requestId: string;
peerId: string;
message: string;
}
| {
type: 'speech-transcribed';
requestId: string;
text: string;
}
| {
type: 'speech-transcription-error';
requestId: string;
message: string;
}
| { type: 'pong' }
| { type: 'error'; message: string };
export type DataEnvelope =
@@ -130,6 +166,7 @@ export type DataEnvelope =
name: string;
mimeType: string;
size: number;
attachmentKind?: 'file' | 'voice';
authorId: string;
authorName: string;
sentAt: number;
@@ -141,4 +178,18 @@ export type DataEnvelope =
| {
type: 'typing';
active: boolean;
}
| {
type: 'camera-state';
active: boolean;
}
| {
type: 'voice-call-offer';
}
| {
type: 'voice-call-response';
accepted: boolean;
}
| {
type: 'voice-call-ended';
};

View File

@@ -0,0 +1,54 @@
:host {
display: contents;
}
.video-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(3, 8, 14, 0.72);
backdrop-filter: blur(10px);
}
.video-modal-card {
width: min(100%, 56rem);
border: 1px solid var(--surface-border);
border-radius: 1.5rem;
background: var(--panel-background);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
.video-modal-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
padding: 1rem 1rem 0;
}
.video-modal-close {
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 999px;
color: var(--page-text);
background: var(--badge-background);
font-size: 1.35rem;
line-height: 1;
}
.video-modal-body {
padding: 1rem;
}
.video-modal-player {
width: 100%;
display: block;
border-radius: 1rem;
background: #000;
aspect-ratio: 16 / 9;
object-fit: cover;
}

View File

@@ -0,0 +1,86 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core';
@Component({
selector: 'app-peer-video-modal',
imports: [CommonModule],
template: `
@if (visible) {
<div class="video-modal-backdrop" (click)="requestClose()">
<section class="video-modal-card" (click)="$event.stopPropagation()">
<div class="video-modal-header">
<div>
<h2 class="h5 mb-1">{{ title }}</h2>
<p class="small mb-0">Live webcam capture from your peer.</p>
</div>
<button class="video-modal-close" type="button" (click)="requestClose()" aria-label="Close live video">
×
</button>
</div>
<div class="video-modal-body">
<video #videoElement class="video-modal-player" autoplay playsinline></video>
</div>
</section>
</div>
}
`,
styleUrl: './peer-video-modal.component.scss',
})
export class PeerVideoModalComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() visible = false;
@Input() stream: MediaStream | null = null;
@Input() title = 'Live webcam';
@Output() readonly closeRequested = new EventEmitter<void>();
@ViewChild('videoElement')
set videoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
this.videoElement = value;
this.syncVideoSource();
}
private videoElement?: ElementRef<HTMLVideoElement>;
ngAfterViewInit(): void {
this.syncVideoSource();
}
ngOnChanges(): void {
this.syncVideoSource();
}
ngOnDestroy(): void {
this.detachVideoSource();
}
requestClose(): void {
this.closeRequested.emit();
}
private syncVideoSource(): void {
const video = this.videoElement?.nativeElement;
if (!video) {
return;
}
video.muted = true;
video.srcObject = this.visible ? this.stream : null;
if (this.visible && this.stream) {
void video.play().catch(() => {
// Autoplay may be delayed until user interaction depending on platform policy.
});
}
}
private detachVideoSource(): void {
const video = this.videoElement?.nativeElement;
if (!video) {
return;
}
video.pause();
video.srcObject = null;
}
}

View File

@@ -5,6 +5,13 @@
<title>PrivateChat</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#08111d">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="PrivateChat">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">

1
client/src/jsonview.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -2,5 +2,13 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) {
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/service-worker.js').catch((error) => {
console.error('Service worker registration failed.', error);
});
});
}
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));

View File

@@ -101,10 +101,6 @@
color-scheme: dark;
}
:root[data-theme='light'] {
color-scheme: light;
}
html,
body {
min-height: 100dvh;
@@ -138,27 +134,30 @@ textarea {
background: var(--badge-background) !important;
}
.btn-outline-light {
.btn-outline-light,
.btn-outline-light:hover,
.btn-outline-light:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
}
.btn-outline-light:hover,
.btn-outline-light:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}
.btn-outline-light,
.btn-outline-secondary {
border-color: var(--surface-border);
}
.btn-outline-secondary {
color: var(--page-text-muted);
border-color: var(--surface-border);
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background);
}

475
server/dist/index.js vendored
View File

@@ -13,6 +13,7 @@ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthe
import Fastify from 'fastify';
import { Redis } from 'ioredis';
import { z } from 'zod';
import { SpeechTranscriber } from './speech-transcriber.js';
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
const registerSchema = z.object({
@@ -40,6 +41,12 @@ const verifyAccessKeyAuthenticationSchema = z.object({
const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1),
});
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -66,20 +73,49 @@ 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'),
}),
z.object({
type: z.literal('speech-transcription'),
requestId: z.string().uuid(),
mimeType: z.string().trim().min(1).max(128),
audioBase64: z.string().min(1).max(32_000_000),
}),
]);
const app = Fastify({ logger: true });
const app = Fastify({ logger: true, trustProxy: true });
const approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
const sqlitePath = resolveStoragePath(process.env.SQLITE_PATH ?? path.join(dataDirectory, 'privatechat.sqlite'));
const masterKeyPath = resolveStoragePath(process.env.PRIVATECHAT_MASTER_KEY_PATH ?? path.join(dataDirectory, 'master.key'));
const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser');
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
const corsMethods = ['GET', 'POST', 'OPTIONS'];
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const speechTranscriber = new SpeechTranscriber({
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
requestTimeoutMs: speechTranscriptionTimeoutMs,
}, app.log);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
@@ -134,6 +170,11 @@ const selectPendingUsersStatement = database.prepare(`
WHERE is_active = 0
ORDER BY created_at ASC
`);
const selectAllUsersStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
ORDER BY created_at DESC
`);
const approveUserStatement = database.prepare(`
UPDATE users
SET is_active = 1, approved_at = ?
@@ -168,18 +209,30 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
FROM webauthn_credentials
WHERE credential_id = ?
`);
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials
SET encrypted_registration = ?
WHERE credential_id = ?
`);
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
const socketsByUserId = new Map();
await redis.ping();
await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
});
await app.register(jwt, {
secret: jwtSecret,
@@ -205,6 +258,45 @@ else {
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
}
app.get('/api/health', async () => ({ ok: true }));
app.get('/api/web-app/manifest', async (request, reply) => {
const manifest = getFrontendBundleManifest();
if (!manifest) {
return reply.code(404).send({
message: 'Angular frontend build not found.',
frontendDistPath,
});
}
const etag = `"${manifest.bundleId}"`;
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
return manifest;
});
app.get('/api/web-app/files/*', async (request, reply) => {
const parsed = webBundleFileParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid web bundle asset path.',
issues: parsed.error.flatten(),
});
}
const asset = resolveFrontendBundleAsset(parsed.data['*']);
if (!asset) {
return reply.code(404).send({ message: 'Frontend asset not found.' });
}
const etag = `W/"${asset.etag}"`;
reply.header('Cache-Control', 'public, max-age=300');
reply.header('ETag', etag);
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
reply.header('Content-Length', String(asset.size));
reply.type(asset.contentType);
return reply.send(fs.createReadStream(asset.absolutePath));
});
app.post('/api/auth/register', async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
@@ -405,6 +497,41 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
user: toPublicUser(approvedUser),
};
});
app.get('/api/admin/users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
}
return {
users: listAdminUsers(),
};
});
app.delete('/api/admin/users/:userId', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
}
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid user deletion request.',
issues: parsed.error.flatten(),
});
}
const deletedUser = await deleteUserAccount(parsed.data.userId);
if (!deletedUser) {
return reply.code(404).send({ message: 'User not found.' });
}
return {
user: toPublicUser(deletedUser),
};
});
app.post('/api/auth/logout', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
@@ -526,6 +653,11 @@ const port = Number(process.env.PORT ?? 16990);
await app.listen({ port, host: '0.0.0.0' });
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket, request) {
if (!isAllowedRequestOrigin(request.headers.origin)) {
send(socket, { type: 'error', message: 'Origin not allowed.' });
socket.close();
return;
}
const query = wsQuerySchema.safeParse(request.query);
if (!query.success) {
send(socket, { type: 'error', message: 'Missing token.' });
@@ -574,6 +706,53 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
send(socket, { type: 'error', message: 'Unsupported signaling message.' });
return;
}
if (parsed.type === 'ping') {
send(socket, { type: 'pong' });
return;
}
if (parsed.type === 'image-generation') {
try {
const generatedImage = await generateImageFromPrompt(parsed.prompt);
send(socket, {
type: 'image-generated',
requestId: parsed.requestId,
peerId: parsed.peerId,
prompt: parsed.prompt,
createdAt: Date.now(),
mimeType: generatedImage.mimeType,
imageBase64: generatedImage.imageBase64,
});
}
catch (error) {
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
send(socket, {
type: 'image-generation-error',
requestId: parsed.requestId,
peerId: parsed.peerId,
message: error instanceof Error ? error.message : 'Image generation failed.',
});
}
return;
}
if (parsed.type === 'speech-transcription') {
try {
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
send(socket, {
type: 'speech-transcribed',
requestId: parsed.requestId,
text,
});
}
catch (error) {
app.log.warn({ err: error, userId }, 'Speech transcription failed');
send(socket, {
type: 'speech-transcription-error',
requestId: parsed.requestId,
message: error instanceof Error ? error.message : 'Speech transcription failed.',
});
}
return;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
if (recipientSockets) {
@@ -683,6 +862,17 @@ function listPendingApprovalUsers() {
createdAt: row.created_at,
}));
}
function listAdminUsers() {
const rows = selectAllUsersStatement.all();
return rows.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
isActive: row.is_active === 1,
createdAt: row.created_at,
approvedAt: row.approved_at,
}));
}
function approveUser(userId) {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
@@ -691,6 +881,19 @@ function approveUser(userId) {
}
return findUserById(userId);
}
async function deleteUserAccount(userId) {
const user = findUserById(userId);
if (!user) {
return null;
}
deleteAccessKeysByUserStatement.run(userId);
const result = deleteUserStatement.run(userId);
if (result.changes === 0) {
return null;
}
await destroyUserSessions(userId);
return user;
}
function persistAccessKey(userId, input) {
createAccessKeyStatement.run(crypto.randomUUID(), userId, input.credentialId, input.label, encryptJson({
credentialId: input.credentialId,
@@ -802,6 +1005,32 @@ async function getSession(sessionId) {
async function destroySession(sessionId) {
await redis.del(sessionKey(sessionId));
}
async function destroyUserSessions(userId) {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
cursor = nextCursor;
for (const key of keys) {
const payload = await redis.get(key);
if (!payload) {
continue;
}
let session = null;
try {
session = JSON.parse(payload);
}
catch {
session = null;
}
if (!session || session.userId !== userId) {
continue;
}
await destroySession(session.sessionId);
await clearPendingRegistration(session.sessionId);
closeSocketSession(userId, session.sessionId);
}
} while (cursor !== '0');
}
function sessionKey(sessionId) {
return `privatechat:session:${sessionId}`;
}
@@ -889,12 +1118,225 @@ function parseClientMessage(rawMessage) {
if (!parsed.success) {
return null;
}
if (parsed.data.type === 'ping') {
return { type: 'ping' };
}
if (parsed.data.type === 'image-generation') {
return {
type: 'image-generation',
requestId: parsed.data.requestId,
peerId: parsed.data.peerId,
prompt: parsed.data.prompt,
};
}
if (parsed.data.type === 'speech-transcription') {
return {
type: 'speech-transcription',
requestId: parsed.data.requestId,
mimeType: parsed.data.mimeType,
audioBase64: parsed.data.audioBase64,
};
}
return {
type: 'signal',
to: parsed.data.to,
signal: normalizeSignal(parsed.data.signal),
};
}
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
}
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 getFrontendBundleManifest() {
if (!fs.existsSync(frontendIndexPath)) {
return null;
}
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
const stats = fs.statSync(absolutePath);
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
return {
path: relativePath,
size: stats.size,
sha256,
lastModified: stats.mtime.toISOString(),
contentType: detectBundleContentType(relativePath),
href: bundleAssetHref(relativePath),
};
});
files.sort((left, right) => left.path.localeCompare(right.path));
const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString());
const bundleId = files.reduce((hash, file) => {
hash.update(file.path);
hash.update(file.sha256);
hash.update(String(file.size));
return hash;
}, crypto.createHash('sha256')).digest('hex');
return {
bundleId,
generatedAt,
indexPath: 'index.html',
files,
};
}
function resolveFrontendBundleAsset(relativeAssetPath) {
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
return null;
}
const normalizedPath = toBundleRelativePath(relativeAssetPath);
if (normalizedPath.length === 0 ||
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath.startsWith('/')) {
return null;
}
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
return null;
}
const stats = fs.statSync(absolutePath);
if (!stats.isFile()) {
return null;
}
return {
absolutePath,
contentType: detectBundleContentType(normalizedPath),
size: stats.size,
lastModifiedMs: stats.mtimeMs,
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
};
}
function listBundleFilePaths(rootPath) {
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
return listBundleFilePaths(entryPath);
}
if (!entry.isFile()) {
return [];
}
return [entryPath];
});
}
function bundleAssetHref(relativePath) {
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
}
function toBundleRelativePath(inputPath) {
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
}
function detectBundleContentType(assetPath) {
const extension = path.extname(assetPath).toLowerCase();
switch (extension) {
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
case '.css':
return 'text/css; charset=utf-8';
case '.html':
return 'text/html; charset=utf-8';
case '.ico':
return 'image/x-icon';
case '.jpeg':
case '.jpg':
return 'image/jpeg';
case '.js':
return 'text/javascript; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.map':
return 'application/json; charset=utf-8';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml; charset=utf-8';
case '.txt':
return 'text/plain; charset=utf-8';
case '.webp':
return 'image/webp';
case '.webmanifest':
return 'application/manifest+json; charset=utf-8';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
function requestMatchesEtag(headerValue, etag) {
if (!headerValue) {
return false;
}
const incomingEtags = Array.isArray(headerValue)
? headerValue
: headerValue.split(',').map((value) => value.trim());
return incomingEtags.includes(etag) || incomingEtags.includes('*');
}
function normalizeSignal(signal) {
if (signal.type === 'sdp') {
return {
@@ -1001,6 +1443,35 @@ function resolveStoragePath(targetPath) {
function resolveProjectPath(targetPath) {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
}
function parseAllowedOrigins(value) {
if (!value) {
return new Set();
}
return new Set(value
.split(',')
.map((origin) => normalizeOrigin(origin))
.filter((origin) => origin.length > 0 && origin !== 'null'));
}
function normalizeOrigin(origin) {
const trimmed = origin.trim();
if (trimmed === 'null') {
return trimmed;
}
return trimmed.replace(/\/+$/, '');
}
function isAllowedRequestOrigin(originHeader) {
if (!originHeader) {
return true;
}
const origin = normalizeOrigin(originHeader);
if (origin === 'null') {
return true;
}
if (allowedCorsOrigins.size === 0) {
return true;
}
return allowedCorsOrigins.has(origin);
}
function resolveWebAuthnOrigin(request) {
const originHeader = request.headers.origin;
if (typeof originHeader === 'string' && originHeader.length > 0) {

124
server/dist/speech-transcriber.js vendored Normal file
View File

@@ -0,0 +1,124 @@
import WebSocket from 'ws';
export class SpeechTranscriber {
options;
logger;
constructor(options, logger) {
this.options = options;
this.logger = logger;
}
async transcribe(requestId, audioBase64, mimeType) {
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
return await new Promise((resolve, reject) => {
let settled = false;
const socket = new WebSocket(this.options.serviceUrl);
const finish = (handler) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
socket.removeAllListeners();
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
socket.close();
}
handler();
};
const timeout = setTimeout(() => {
finish(() => {
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
});
}, this.options.requestTimeoutMs);
socket.on('open', () => {
try {
socket.send(JSON.stringify({
type: 'transcribe',
id: requestId,
language: this.options.language,
audio,
}));
}
catch (error) {
finish(() => {
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
});
}
});
socket.on('message', (payload) => {
const event = this.parseEvent(payload);
if (!event) {
return;
}
if (event.id && event.id !== requestId) {
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
return;
}
if (event.type === 'start') {
this.logger.info({ requestId, model: event.model, language: event.language }, 'Speech transcription started');
return;
}
if (event.type === 'delta') {
return;
}
if (event.type === 'done') {
finish(() => {
resolve(event.text.trim());
});
return;
}
finish(() => {
reject(new Error(event.message));
});
});
socket.on('error', (error) => {
finish(() => {
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
});
});
socket.on('close', (code, reasonBuffer) => {
if (settled) {
return;
}
const reason = reasonBuffer.toString().trim();
const detail = reason
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
: `The transcription service closed the connection unexpectedly (code=${code}).`;
finish(() => {
reject(new Error(detail));
});
});
});
}
normalizeAudioPayload(audioBase64, mimeType) {
const trimmedAudio = audioBase64.trim();
if (trimmedAudio.startsWith('data:')) {
return trimmedAudio;
}
const normalizedMimeType = mimeType.trim() || 'audio/webm';
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
}
parseEvent(payload) {
const message = this.rawDataToString(payload).trim();
if (!message) {
return null;
}
try {
return JSON.parse(message);
}
catch {
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
return null;
}
}
rawDataToString(payload) {
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof ArrayBuffer) {
return Buffer.from(payload).toString('utf8');
}
if (Array.isArray(payload)) {
return Buffer.concat(payload).toString('utf8');
}
return payload.toString('utf8');
}
}

View File

@@ -16,6 +16,7 @@
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -17,6 +17,7 @@
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"ioredis": "^5.10.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -23,6 +23,8 @@ import { Redis } from 'ioredis';
import type WebSocket from 'ws';
import { z } from 'zod';
import { SpeechTranscriber } from './speech-transcriber.js';
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
@@ -84,6 +86,15 @@ type PendingApprovalUser = {
createdAt: string;
};
type AdminUserSummary = {
id: string;
username: string;
displayName: string;
isActive: boolean;
createdAt: string;
approvedAt: string | null;
};
type DatabaseAccessKeyRow = {
id: string;
user_id: string;
@@ -97,17 +108,59 @@ type SignalPayload =
| { type: 'sdp'; description: RTCSessionDescriptionInit }
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
type ClientMessage = {
type ClientMessage =
| {
type: 'signal';
to: string;
signal: SignalPayload;
};
}
| {
type: 'image-generation';
requestId: string;
peerId: string;
prompt: string;
}
| {
type: 'ping';
}
| {
type: 'speech-transcription';
requestId: string;
mimeType: string;
audioBase64: string;
};
type ServerMessage =
| { type: 'presence'; self: PublicUser; peers: PublicUser[] }
| { type: 'peer-joined'; peer: PublicUser }
| { type: 'peer-left'; peerId: string }
| { type: 'signal'; from: string; signal: SignalPayload }
| {
type: 'image-generated';
requestId: string;
peerId: string;
prompt: string;
createdAt: number;
mimeType: string;
imageBase64: string;
}
| {
type: 'image-generation-error';
requestId: string;
peerId: string;
message: string;
}
| {
type: 'speech-transcribed';
requestId: string;
text: string;
}
| {
type: 'speech-transcription-error';
requestId: string;
message: string;
}
| { type: 'pong' }
| { type: 'error'; message: string };
type StoredCredentials = {
@@ -136,6 +189,22 @@ type AccessKeySummary = {
createdAt: string;
};
type WebBundleFileEntry = {
path: string;
size: number;
sha256: string;
lastModified: string;
contentType: string;
href: string;
};
type WebBundleManifest = {
bundleId: string;
generatedAt: string;
indexPath: string;
files: WebBundleFileEntry[];
};
type PendingRegistration = {
challenge: string;
label: string;
@@ -194,6 +263,14 @@ const approvePendingUserParamsSchema = z.object({
userId: z.string().min(1),
});
const adminDeleteUserParamsSchema = z.object({
userId: z.string().min(1),
});
const webBundleFileParamsSchema = z.object({
'*': z.string().min(1),
});
const wsQuerySchema = z.object({
token: z.string().min(1),
});
@@ -221,9 +298,24 @@ 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'),
}),
z.object({
type: z.literal('speech-transcription'),
requestId: z.string().uuid(),
mimeType: z.string().trim().min(1).max(128),
audioBase64: z.string().min(1).max(32_000_000),
}),
]);
const app = Fastify({ logger: true });
const app = Fastify({ logger: true, trustProxy: true });
const approvalAdminUsername = 'ladparis';
const dataDirectory = resolveStoragePath(process.env.PRIVATECHAT_DATA_DIR ?? 'server/data');
@@ -236,8 +328,17 @@ const masterKeyPath = resolveStoragePath(
const frontendDistPath = resolveProjectPath(
process.env.PRIVATECHAT_WEB_DIST_DIR ?? 'client/dist/client/browser',
);
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
const corsAllowedHeaders = ['Authorization', 'Content-Type'];
const corsMethods = ['GET', 'POST', 'OPTIONS'];
const webAuthnOrigin = process.env.WEBAUTHN_ORIGIN ?? 'http://localhost:4200';
const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
const webAuthnUserVerification = resolveWebAuthnUserVerification(
@@ -246,6 +347,15 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
const speechTranscriber = new SpeechTranscriber(
{
serviceUrl: speechTranscriptionServiceUrl,
language: speechTranscriptionLanguage,
requestTimeoutMs: speechTranscriptionTimeoutMs,
},
app.log,
);
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
@@ -304,6 +414,11 @@ const selectPendingUsersStatement = database.prepare(`
WHERE is_active = 0
ORDER BY created_at ASC
`);
const selectAllUsersStatement = database.prepare(`
SELECT id, username, display_name, encrypted_credentials, is_active, created_at, approved_at
FROM users
ORDER BY created_at DESC
`);
const approveUserStatement = database.prepare(`
UPDATE users
SET is_active = 1, approved_at = ?
@@ -338,11 +453,19 @@ const selectAccessKeyByCredentialIdStatement = database.prepare(`
FROM webauthn_credentials
WHERE credential_id = ?
`);
const deleteAccessKeysByUserStatement = database.prepare(`
DELETE FROM webauthn_credentials
WHERE user_id = ?
`);
const updateAccessKeyStatement = database.prepare(`
UPDATE webauthn_credentials
SET encrypted_registration = ?
WHERE credential_id = ?
`);
const deleteUserStatement = database.prepare(`
DELETE FROM users
WHERE id = ?
`);
const jwtSecret = loadOrCreateSecret('jwt-secret', () => crypto.randomBytes(64).toString('hex'));
const redis = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379/0');
@@ -351,8 +474,12 @@ const socketsByUserId = new Map<string, Map<string, WebSocket>>();
await redis.ping();
await app.register(cors, {
origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : true,
origin(origin, callback) {
callback(null, isAllowedRequestOrigin(origin));
},
credentials: false,
allowedHeaders: corsAllowedHeaders,
methods: corsMethods,
});
await app.register(jwt, {
@@ -390,6 +517,57 @@ if (hasFrontendBuild) {
app.get('/api/health', async () => ({ ok: true }));
app.get('/api/web-app/manifest', async (request, reply) => {
const manifest = getFrontendBundleManifest();
if (!manifest) {
return reply.code(404).send({
message: 'Angular frontend build not found.',
frontendDistPath,
});
}
const etag = `"${manifest.bundleId}"`;
reply.header('Cache-Control', 'no-cache');
reply.header('ETag', etag);
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
return manifest;
});
app.get('/api/web-app/files/*', async (request, reply) => {
const parsed = webBundleFileParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid web bundle asset path.',
issues: parsed.error.flatten(),
});
}
const asset = resolveFrontendBundleAsset(parsed.data['*']);
if (!asset) {
return reply.code(404).send({ message: 'Frontend asset not found.' });
}
const etag = `W/"${asset.etag}"`;
reply.header('Cache-Control', 'public, max-age=300');
reply.header('ETag', etag);
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
return reply.code(304).send();
}
reply.header('Content-Length', String(asset.size));
reply.type(asset.contentType);
return reply.send(fs.createReadStream(asset.absolutePath));
});
app.post('/api/auth/register', async (request, reply) => {
const parsed = registerSchema.safeParse(request.body);
@@ -664,6 +842,53 @@ app.post('/api/admin/pending-users/:userId/approve', async (request, reply) => {
};
});
app.get('/api/admin/users', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
}
return {
users: listAdminUsers(),
};
});
app.delete('/api/admin/users/:userId', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
if (!authContext) {
return;
}
if (!isApprovalAdmin(authContext.user)) {
return reply.code(403).send({ message: 'Only ladparis can delete users.' });
}
const parsed = adminDeleteUserParamsSchema.safeParse(request.params);
if (!parsed.success) {
return reply.code(400).send({
message: 'Invalid user deletion request.',
issues: parsed.error.flatten(),
});
}
const deletedUser = await deleteUserAccount(parsed.data.userId);
if (!deletedUser) {
return reply.code(404).send({ message: 'User not found.' });
}
return {
user: toPublicUser(deletedUser),
};
});
app.post('/api/auth/logout', async (request, reply) => {
const authContext = await authenticateRequest(request, reply);
@@ -829,6 +1054,12 @@ await app.listen({ port, host: '0.0.0.0' });
app.log.info(`PrivateChat signaling server listening on http://localhost:${port}`);
async function openSocket(socket: WebSocket, request: FastifyRequest): Promise<void> {
if (!isAllowedRequestOrigin(request.headers.origin)) {
send(socket, { type: 'error', message: 'Origin not allowed.' });
socket.close();
return;
}
const query = wsQuerySchema.safeParse(request.query);
if (!query.success) {
@@ -901,6 +1132,58 @@ async function handleSocketMessage(
return;
}
if (parsed.type === 'ping') {
send(socket, { type: 'pong' });
return;
}
if (parsed.type === 'image-generation') {
try {
const generatedImage = await generateImageFromPrompt(parsed.prompt);
send(socket, {
type: 'image-generated',
requestId: parsed.requestId,
peerId: parsed.peerId,
prompt: parsed.prompt,
createdAt: Date.now(),
mimeType: generatedImage.mimeType,
imageBase64: generatedImage.imageBase64,
});
} catch (error) {
app.log.warn({ err: error, userId, peerId: parsed.peerId }, 'Ollama image generation failed');
send(socket, {
type: 'image-generation-error',
requestId: parsed.requestId,
peerId: parsed.peerId,
message: error instanceof Error ? error.message : 'Image generation failed.',
});
}
return;
}
if (parsed.type === 'speech-transcription') {
try {
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
send(socket, {
type: 'speech-transcribed',
requestId: parsed.requestId,
text,
});
} catch (error) {
app.log.warn({ err: error, userId }, 'Speech transcription failed');
send(socket, {
type: 'speech-transcription-error',
requestId: parsed.requestId,
message: error instanceof Error ? error.message : 'Speech transcription failed.',
});
}
return;
}
let delivered = 0;
const recipientSockets = socketsByUserId.get(parsed.to);
@@ -1056,6 +1339,19 @@ function listPendingApprovalUsers(): PendingApprovalUser[] {
}));
}
function listAdminUsers(): AdminUserSummary[] {
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
return rows.map((row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
isActive: row.is_active === 1,
createdAt: row.created_at,
approvedAt: row.approved_at,
}));
}
function approveUser(userId: string): UserRecord | null {
const approvedAt = new Date().toISOString();
const result = approveUserStatement.run(approvedAt, userId);
@@ -1067,6 +1363,25 @@ function approveUser(userId: string): UserRecord | null {
return findUserById(userId);
}
async function deleteUserAccount(userId: string): Promise<UserRecord | null> {
const user = findUserById(userId);
if (!user) {
return null;
}
deleteAccessKeysByUserStatement.run(userId);
const result = deleteUserStatement.run(userId);
if (result.changes === 0) {
return null;
}
await destroyUserSessions(userId);
return user;
}
function persistAccessKey(
userId: string,
input: {
@@ -1248,6 +1563,39 @@ async function destroySession(sessionId: string): Promise<void> {
await redis.del(sessionKey(sessionId));
}
async function destroyUserSessions(userId: string): Promise<void> {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', `${sessionKey('*')}`, 'COUNT', 100);
cursor = nextCursor;
for (const key of keys) {
const payload = await redis.get(key);
if (!payload) {
continue;
}
let session: SessionRecord | null = null;
try {
session = JSON.parse(payload) as SessionRecord;
} catch {
session = null;
}
if (!session || session.userId !== userId) {
continue;
}
await destroySession(session.sessionId);
await clearPendingRegistration(session.sessionId);
closeSocketSession(userId, session.sessionId);
}
} while (cursor !== '0');
}
function sessionKey(sessionId: string): string {
return `privatechat:session:${sessionId}`;
}
@@ -1364,6 +1712,28 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
return null;
}
if (parsed.data.type === 'ping') {
return { type: 'ping' };
}
if (parsed.data.type === 'image-generation') {
return {
type: 'image-generation',
requestId: parsed.data.requestId,
peerId: parsed.data.peerId,
prompt: parsed.data.prompt,
};
}
if (parsed.data.type === 'speech-transcription') {
return {
type: 'speech-transcription',
requestId: parsed.data.requestId,
mimeType: parsed.data.mimeType,
audioBase64: parsed.data.audioBase64,
};
}
return {
type: 'signal',
to: parsed.data.to,
@@ -1371,7 +1741,255 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
};
}
function normalizeSignal(signal: ClientMessage['signal']): SignalPayload {
async function transcribeAudioPayload(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
}
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 getFrontendBundleManifest(): WebBundleManifest | null {
if (!fs.existsSync(frontendIndexPath)) {
return null;
}
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
const stats = fs.statSync(absolutePath);
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
return {
path: relativePath,
size: stats.size,
sha256,
lastModified: stats.mtime.toISOString(),
contentType: detectBundleContentType(relativePath),
href: bundleAssetHref(relativePath),
} satisfies WebBundleFileEntry;
});
files.sort((left, right) => left.path.localeCompare(right.path));
const generatedAt = files.reduce(
(latest, file) => (file.lastModified > latest ? file.lastModified : latest),
new Date(0).toISOString(),
);
const bundleId = files.reduce((hash, file) => {
hash.update(file.path);
hash.update(file.sha256);
hash.update(String(file.size));
return hash;
}, crypto.createHash('sha256')).digest('hex');
return {
bundleId,
generatedAt,
indexPath: 'index.html',
files,
};
}
function resolveFrontendBundleAsset(relativeAssetPath: string): {
absolutePath: string;
contentType: string;
size: number;
lastModifiedMs: number;
etag: string;
} | null {
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
return null;
}
const normalizedPath = toBundleRelativePath(relativeAssetPath);
if (
normalizedPath.length === 0 ||
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath.startsWith('/')
) {
return null;
}
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
return null;
}
const stats = fs.statSync(absolutePath);
if (!stats.isFile()) {
return null;
}
return {
absolutePath,
contentType: detectBundleContentType(normalizedPath),
size: stats.size,
lastModifiedMs: stats.mtimeMs,
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
};
}
function listBundleFilePaths(rootPath: string): string[] {
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(rootPath, entry.name);
if (entry.isDirectory()) {
return listBundleFilePaths(entryPath);
}
if (!entry.isFile()) {
return [];
}
return [entryPath];
});
}
function bundleAssetHref(relativePath: string): string {
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
}
function toBundleRelativePath(inputPath: string): string {
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
}
function detectBundleContentType(assetPath: string): string {
const extension = path.extname(assetPath).toLowerCase();
switch (extension) {
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
case '.css':
return 'text/css; charset=utf-8';
case '.html':
return 'text/html; charset=utf-8';
case '.ico':
return 'image/x-icon';
case '.jpeg':
case '.jpg':
return 'image/jpeg';
case '.js':
return 'text/javascript; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.map':
return 'application/json; charset=utf-8';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml; charset=utf-8';
case '.txt':
return 'text/plain; charset=utf-8';
case '.webp':
return 'image/webp';
case '.webmanifest':
return 'application/manifest+json; charset=utf-8';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
function requestMatchesEtag(headerValue: string | string[] | undefined, etag: string): boolean {
if (!headerValue) {
return false;
}
const incomingEtags = Array.isArray(headerValue)
? headerValue
: headerValue.split(',').map((value) => value.trim());
return incomingEtags.includes(etag) || incomingEtags.includes('*');
}
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
if (signal.type === 'sdp') {
return {
type: 'sdp',
@@ -1508,6 +2126,47 @@ function resolveProjectPath(targetPath: string): string {
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRootPath, targetPath);
}
function parseAllowedOrigins(value: string | undefined): Set<string> {
if (!value) {
return new Set();
}
return new Set(
value
.split(',')
.map((origin) => normalizeOrigin(origin))
.filter((origin) => origin.length > 0 && origin !== 'null'),
);
}
function normalizeOrigin(origin: string): string {
const trimmed = origin.trim();
if (trimmed === 'null') {
return trimmed;
}
return trimmed.replace(/\/+$/, '');
}
function isAllowedRequestOrigin(originHeader: string | undefined): boolean {
if (!originHeader) {
return true;
}
const origin = normalizeOrigin(originHeader);
if (origin === 'null') {
return true;
}
if (allowedCorsOrigins.size === 0) {
return true;
}
return allowedCorsOrigins.has(origin);
}
function resolveWebAuthnOrigin(request: FastifyRequest): string {
const originHeader = request.headers.origin;

View File

@@ -0,0 +1,173 @@
import WebSocket, { type RawData } from 'ws';
type LoggerLike = {
info: (payload: unknown, message?: string) => void;
warn: (payload: unknown, message?: string) => void;
error: (payload: unknown, message?: string) => void;
};
type SpeechTranscriberOptions = {
serviceUrl: string;
language: string;
requestTimeoutMs: number;
};
type ServiceEvent =
| { type: 'start'; id: string; model: string; language: string }
| { type: 'delta'; id: string; text: string; fullText: string }
| { type: 'done'; id: string; text: string }
| { type: 'error'; id?: string; message: string };
export class SpeechTranscriber {
constructor(
private readonly options: SpeechTranscriberOptions,
private readonly logger: LoggerLike,
) {}
async transcribe(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
return await new Promise<string>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(this.options.serviceUrl);
const finish = (handler: () => void): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
socket.removeAllListeners();
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
socket.close();
}
handler();
};
const timeout = setTimeout(() => {
finish(() => {
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
});
}, this.options.requestTimeoutMs);
socket.on('open', () => {
try {
socket.send(
JSON.stringify({
type: 'transcribe',
id: requestId,
language: this.options.language,
audio,
}),
);
} catch (error) {
finish(() => {
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
});
}
});
socket.on('message', (payload) => {
const event = this.parseEvent(payload);
if (!event) {
return;
}
if (event.id && event.id !== requestId) {
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
return;
}
if (event.type === 'start') {
this.logger.info(
{ requestId, model: event.model, language: event.language },
'Speech transcription started',
);
return;
}
if (event.type === 'delta') {
return;
}
if (event.type === 'done') {
finish(() => {
resolve(event.text.trim());
});
return;
}
finish(() => {
reject(new Error(event.message));
});
});
socket.on('error', (error) => {
finish(() => {
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
});
});
socket.on('close', (code, reasonBuffer) => {
if (settled) {
return;
}
const reason = reasonBuffer.toString().trim();
const detail = reason
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
: `The transcription service closed the connection unexpectedly (code=${code}).`;
finish(() => {
reject(new Error(detail));
});
});
});
}
private normalizeAudioPayload(audioBase64: string, mimeType: string): string {
const trimmedAudio = audioBase64.trim();
if (trimmedAudio.startsWith('data:')) {
return trimmedAudio;
}
const normalizedMimeType = mimeType.trim() || 'audio/webm';
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
}
private parseEvent(payload: RawData): ServiceEvent | null {
const message = this.rawDataToString(payload).trim();
if (!message) {
return null;
}
try {
return JSON.parse(message) as ServiceEvent;
} catch {
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
return null;
}
}
private rawDataToString(payload: RawData): string {
if (typeof payload === 'string') {
return payload;
}
if (payload instanceof ArrayBuffer) {
return Buffer.from(payload).toString('utf8');
}
if (Array.isArray(payload)) {
return Buffer.concat(payload).toString('utf8');
}
return payload.toString('utf8');
}
}