summaryrefslogtreecommitdiff
path: root/crates/chat-server/web/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/web/app.js')
-rw-r--r--crates/chat-server/web/app.js421
1 files changed, 0 insertions, 421 deletions
diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js
deleted file mode 100644
index 4b0da63..0000000
--- a/crates/chat-server/web/app.js
+++ /dev/null
@@ -1,421 +0,0 @@
-// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
-
-const element = id => document.getElementById(id);
-const show = (id, visible = true) => { element(id).hidden = !visible; };
-const status = message => { element('status').textContent = message; };
-let config;
-let wasm;
-let credentials;
-let session;
-let selected;
-let before = 0;
-let pendingFile;
-let setupToken;
-let pendingSend;
-let generation = 0;
-let busy = false;
-const workers = new Map();
-const blobs = new Set();
-
-async function api(request) {
- const current = generation;
- const response = await fetch('/api', {
- method: 'POST', credentials: 'omit', cache: 'no-store', redirect: 'error',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...request, token: session?.token }),
- signal: AbortSignal.timeout(20000),
- });
- const result = await response.json();
- if (current !== generation) throw new Error('Session locked');
- if (!response.ok || result.error) throw new Error(result.error || 'Server request failed');
- return result;
-}
-
-async function cryptoOperation(request) {
- const current = generation;
- const bytes = await wasm;
- if (current !== generation) throw new Error('Session locked');
- return new Promise((resolve, reject) => {
- const worker = new Worker('/worker.js');
-
- const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); };
- const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000);
- workers.set(worker, () => { stop(); reject(new Error('Session locked')); });
- worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); };
- worker.onmessage = event => {
- stop();
- if (current !== generation) { reject(new Error('Session locked')); return; }
- if (event.data.error) reject(new Error(event.data.error)); else resolve(event.data.result);
- };
- worker.postMessage({ bytes, request });
- });
-}
-
-function protectRequest(op, fields) {
- if (!credentials) throw new Error('Unlock your identity file first');
- return { op, ...credentials, origin: location.origin, ...fields };
-}
-
-function clearState() {
- generation += 1;
- busy = false;
- for (const button of document.querySelectorAll('button')) button.disabled = false;
- for (const cancel of workers.values()) cancel();
- workers.clear();
- for (const url of blobs) URL.revokeObjectURL(url);
- blobs.clear();
- credentials = undefined;
- session = undefined;
- selected = undefined;
- pendingFile = undefined;
- pendingSend = undefined;
- setupToken = undefined;
- before = 0;
- element('history-count').max = '0';
- element('history-range').textContent = 'Latest messages (0-0)';
- for (const input of document.querySelectorAll('input,textarea')) {
- if (input.id === 'history-count') input.value = '0'; else input.value = '';
- }
- for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren();
- for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false);
- show('entry', Boolean(config?.owner));
- show('owner-entry');
- status('Locked. Select your file and enter its password to return.');
-}
-
-function action(id, callback) {
- element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => {
- event.preventDefault();
- if (busy) return;
- busy = true;
- status('Working locally...');
- const current = generation;
- for (const button of document.querySelectorAll('button:not(#lock)')) button.disabled = true;
- try { await callback(); }
- catch (error) { if (generation === current) status(error.message); }
- finally {
- if (generation === current) {
- busy = false;
- for (const button of document.querySelectorAll('button')) button.disabled = false;
- }
- }
- });
-}
-
-function download(file) {
- const url = URL.createObjectURL(new Blob([file], { type: 'application/octet-stream' }));
- blobs.add(url);
- const link = document.createElement('a');
- link.href = url;
- link.download = 'stack-chat-identity.chat';
- link.click();
- setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000);
-}
-
-async function createIdentity() {
- const password = element('new-password').value;
- if (password !== element('confirm-password').value) throw new Error('Passwords do not match');
- if (!config.owner && element('setup-label').hidden) throw new Error('Owner setup is required first');
- setupToken = !config.owner ? element('setup-token').value : undefined;
- status('Generating your purpose-specific identity and protecting its file...');
- const result = await cryptoOperation({ op: 'create', origin: location.origin, name: element('name').value, password, owner: config.owner || '' });
- pendingFile = result.file;
- download(result.file);
- element('create-form').reset();
- show('create', false);
- show('unlock');
- element('reopen-note').textContent = 'Your file was downloaded. Reopen that exact saved file and enter its password before any message can be sent. Keep a safe backup.';
- status('Reopen your downloaded identity file to verify recovery.');
-}
-
-async function unlockIdentity() {
- const file = element('file').files[0];
- if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB');
- const fileText = await file.text();
- if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded');
- const candidate = { file: fileText, password: element('password').value };
- element('unlock-form').reset();
- status('Unlocking locally and verifying your identity...');
- const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin });
- if (config.owner && identity.owner !== config.owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.');
- const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken });
- const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge });
- const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature });
- if (authenticated.id !== identity.fingerprint || typeof authenticated.owner !== 'boolean') throw new Error('Authenticated identity mismatch');
- credentials = candidate;
- session = { ...identity, ...authenticated };
- pendingFile = undefined;
- setupToken = undefined;
- config = await api({ op: 'config' });
- show('entry', false);
- show('owner-entry', false);
- show('owner-access', false);
- show('unlock', false);
- show('chat');
- element('who').textContent = `${identity.name} [${session.owner === true ? 'OWNER' : 'VISITOR'}]`;
- element('fingerprint').textContent = `Identity: ${identity.fingerprint}`;
- if (authenticated.owner) {
- session.owner = true;
- show('inbox');
- await inbox();
- } else {
- session.owner = false;
- const profile = await cryptoOperation(protectRequest('encrypt', { recipient: config.owner, payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: session.id, sender: session.id, name: identity.name }) }));
- await api({ op: 'profile', conversation: session.id, ciphertext: profile.ciphertext });
- selected = { id: session.id, public: identity.public };
- element('conversation-fingerprint').textContent = `Conversation: ${session.id}`;
- show('thread');
- element('thread-name').textContent = 'You <-> Doan';
- await loadHistory(0);
- }
- status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.');
-}
-
-function checkedPayload(payload, kind, conversation, sender) {
- const value = JSON.parse(payload);
- if (value.v !== 1 || value.kind !== kind || value.origin !== location.origin || value.conversation !== conversation || value.sender !== sender) throw new Error('Signed message binding mismatch');
- return value;
-}
-
-async function inbox() {
- const result = await api({ op: 'inbox' });
- element('conversations').replaceChildren();
- for (const conversation of result.conversations) {
- const button = document.createElement('button');
- button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`;
- button.addEventListener('click', async () => {
- if (busy) return;
- if (pendingSend) { status('Retry the pending message, or lock before switching conversations.'); return; }
- busy = true;
- const current = generation;
- status('Opening conversation...');
- try {
- selected = conversation;
- element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`;
- element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`;
- pendingSend = undefined;
- element('text').value = '';
- show('retry', false);
- element('messages').replaceChildren();
- show('thread');
- let name = conversation.id.slice(0, 12);
- if (conversation.profile) {
- const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile }));
- if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch');
- const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id);
- if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile');
- name = profile.name;
- }
- element('thread-name').textContent = `Doan <-> ${name}`;
- await loadHistory(0);
- status('Conversation opened. Choose how many messages to load.');
- } catch (error) { status(error.message); }
- finally { if (generation === current) busy = false; }
- });
- element('conversations').append(button);
- }
-}
-
-function counters(conversation) {
- if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count');
- element('history-count').max = String(conversation.total);
- element('history-range').textContent = `Latest messages (0-${conversation.total})`;
- const sent = session.owner ? conversation.owner_sent : conversation.visitor_sent;
- const received = session.owner ? conversation.visitor_sent : conversation.owner_sent;
- element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`;
-}
-
-async function renderHistoryBatch(messages) {
- const requests = messages.map(message => ({ sender: message.sender === selected.id ? selected.public : config.owner, ciphertext: message.ciphertext }));
- const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
- for (let index = 0; index < messages.length; index += 1) {
- const message = messages[index];
- const item = document.createElement('li');
- const label = document.createElement('small');
- label.textContent = `#${message.seq} / ${message.sender === session.id ? 'YOU' : 'THEM'}`;
- const text = document.createElement('p');
- try {
- const entry = decrypted.messages[index];
- if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
- if (entry.error) throw new Error('Invalid ciphertext or signature');
- const payload = checkedPayload(entry.payload, 'message', selected.id, message.sender);
- if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch');
- text.textContent = payload.text;
- } catch { text.textContent = '[Message rejected: invalid ciphertext, signature, or conversation binding.]'; }
- item.append(label, text);
- element('messages').prepend(item);
- }
-}
-
-async function refreshCounts() {
- const conversation = selected.id;
- const result = await api({ op: 'history', conversation, count: 0, before: 0 });
- if (selected?.id === conversation) counters(result.conversation);
- return result.conversation;
-}
-
-async function loadHistory(count, cursor = 0) {
- if (!selected) throw new Error('Choose a conversation first');
- const conversation = await refreshCounts();
- if (!Number.isSafeInteger(count) || count < 0 || count > conversation.total) throw new Error(`Choose 0 to ${conversation.total} messages`);
- if (!cursor) { element('messages').replaceChildren(); before = 0; }
- if (count === 0) {
- element('history-count').value = '0';
- show('older', false);
- status('No message history loaded.');
- return;
- }
- let remaining = count;
- let next = cursor;
- let exhausted = false;
- while (remaining > 0) {
- const size = Math.min(50, remaining);
- const result = await api({ op: 'history', conversation: selected.id, count: size, before: next });
- counters(result.conversation);
- if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response');
- if (!result.messages.length) { exhausted = true; break; }
- const sequence = Math.min(...result.messages.map(message => message.seq));
- if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor');
- status(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`);
- await renderHistoryBatch(result.messages);
- remaining -= result.messages.length;
- next = sequence;
- before = next;
- if (result.messages.length < size) { exhausted = true; break; }
- }
- show('older', !exhausted && element('messages').children.length < Number(element('history-count').max));
- status('Selected history decrypted and verified locally.');
-}
-
-async function submitPending() {
- if (!pendingSend) throw new Error('No pending message');
- const result = await api(pendingSend);
- counters(result.conversation);
- pendingSend = undefined;
- element('text').value = '';
- show('retry', false);
- status('Encrypted message stored. Load history to read messages.');
-}
-
-async function send() {
- if (!selected) throw new Error('Choose a conversation first');
- if (pendingSend) throw new Error('Retry or lock before composing another message');
- const text = element('text').value;
- if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text');
- const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
- const recipient = session.owner ? selected.public : config.owner;
- status('Signing and encrypting for both participants locally...');
- const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: selected.id, sender: session.id, id, text }) }));
- pendingSend = { op: 'send', conversation: selected.id, id, ciphertext: result.ciphertext };
- show('retry');
- await submitPending();
-}
-
-function ownerSetup() {
- show('entry', false);
- show('owner-entry', false);
- show('unlock', false);
- show('create', !config.owner);
- show('owner-access', Boolean(config.owner));
- show('setup-label', !config.owner);
- element('create-title').textContent = 'Create the owner identity (owner only)';
- status('');
-}
-
-action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); status(''); });
-action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); status(''); });
-action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
-action('owner-open', async () => { show('owner-access', false); show('unlock'); status(''); });
-action('create-form', createIdentity);
-action('unlock-form', unlockIdentity);
-element('history-count').addEventListener('focus', () => {
- const current = generation;
- if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) status(error.message); });
-});
-action('history-form', () => loadHistory(Number(element('history-count').value)));
-action('send-form', send);
-action('retry', submitPending);
-action('refresh-inbox', inbox);
-action('older', () => loadHistory(Number(element('history-count').value), before));
-element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); });
-window.addEventListener('pagehide', clearState);
-window.addEventListener('pageshow', event => { if (event.persisted) clearState(); });
-
-async function downloadCrypto() {
- const controller = new AbortController();
- let timer;
- let reader;
- const refreshTimeout = () => {
- clearTimeout(timer);
- timer = setTimeout(() => controller.abort(), 45000);
- };
- try {
- refreshTimeout();
- const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal });
- if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`);
- const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length'));
- const total = Number.isSafeInteger(length) && length > 0 ? length : 0;
- const chunks = [];
- let received = 0;
- const progress = () => {
- const loaded = (received / 1048576).toFixed(1);
- if (total) {
- const percent = Math.min(100, Math.floor(received * 100 / total));
- const filled = Math.floor(percent / 5);
- status(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
- } else status(`Downloading cryptographic module... ${loaded} MiB received`);
- };
- progress();
- reader = response.body.getReader();
- for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- refreshTimeout();
- chunks.push(value);
- received += value.byteLength;
- progress();
- }
- if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.');
- const bytes = new Uint8Array(received);
- let offset = 0;
- for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
- return bytes.buffer;
- } catch (error) {
- if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.');
- throw error;
- } finally {
- clearTimeout(timer);
- reader?.releaseLock();
- }
-}
-
-async function start() {
- wasm = downloadCrypto();
- await wasm;
- status('Cryptographic module downloaded. Connecting to chat...');
- config = await api({ op: 'config' });
- if (config.origin !== location.origin) throw new Error('Server origin mismatch');
- show('owner-entry');
- show('entry', Boolean(config.owner));
- status('');
- if (location.hash === '#setup') ownerSetup();
-}
-
-start().catch(error => status(error.message));
-
-// @license-end