diff options
Diffstat (limited to 'crates/chat-server/web/app.js')
| -rw-r--r-- | crates/chat-server/web/app.js | 421 |
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 |
