diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 21:39:57 -0500 |
| commit | 556eaa133e9cfe7f4c8e542708119fdfd311357e (patch) | |
| tree | 57ae2a9c5455f335e8c34b293f4f13975cb3d09e /crates/chat-server/web | |
| parent | 143880db086c617b843340ca7023a879507761f8 (diff) | |
Implement experimental file-based encrypted private chat
Add fixed-suite Rust/WASI identity files, signed challenge authentication, ciphertext-only SQLite storage, a minimal LibreJS-compatible interface, and consistent backup snapshots. Keep production unchanged and document the local workflow, limits, source distribution, and browser recovery tests.
Diffstat (limited to 'crates/chat-server/web')
| -rw-r--r-- | crates/chat-server/web/app.js | 325 | ||||
| -rw-r--r-- | crates/chat-server/web/index.html | 82 | ||||
| -rw-r--r-- | crates/chat-server/web/javascript.html | 28 | ||||
| -rw-r--r-- | crates/chat-server/web/style.css | 49 | ||||
| -rw-r--r-- | crates/chat-server/web/worker.js | 31 |
5 files changed, 515 insertions, 0 deletions
diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js new file mode 100644 index 0000000..c06116b --- /dev/null +++ b/crates/chat-server/web/app.js @@ -0,0 +1,325 @@ +// 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; + 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']) show(id, false); + show('entry', Boolean(config?.owner)); + 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 && location.hash !== '#setup') 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('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 history(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 history(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) { + 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 history(count, cursor = 0) { + if (!selected) throw new Error('Choose a conversation first'); + if (!Number.isSafeInteger(count) || count < 0 || count > 50) throw new Error('Choose 0 to 50 messages'); + const result = await api({ op: 'history', conversation: selected.id, count, before: cursor }); + counters(result.conversation); + if (!cursor) element('messages').replaceChildren(); + if (result.messages.length) { + status('Decrypting and verifying selected messages locally...'); + const requests = result.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 < result.messages.length; index += 1) { + const message = result.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); + } + before = Math.min(...result.messages.map(message => message.seq)); + } + show('older', count > 0 && result.messages.length === count); + status(count === 0 ? 'No message history loaded.' : '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(); +} + +action('create-choice', async () => { show('entry', false); show('create'); status('Create a purpose-specific protected identity file.'); }); +action('open-choice', async () => { show('entry', false); show('unlock'); status('Select your saved identity file.'); }); +action('create-form', createIdentity); +action('unlock-form', unlockIdentity); +action('history-form', () => history(Number(element('history-count').value))); +action('send-form', send); +action('retry', submitPending); +action('refresh-inbox', inbox); +action('older', () => history(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 start() { + wasm = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => { + if (!response.ok) throw new Error('Build the WASI cryptographic module first'); + return response.arrayBuffer(); + }); + await wasm; + config = await api({ op: 'config' }); + if (config.origin !== location.origin) throw new Error('Server origin mismatch'); + if (!config.owner) { + if (location.hash === '#setup') { + show('create'); + show('setup-label'); + element('create-title').textContent = 'Create the dedicated owner identity'; + status('Owner setup. Keep this identity separate from your canonical OpenPGP key.'); + } else { status('Owner setup is not complete. Please return later.'); } + } else { show('entry'); status('Choose CREATE NEW CHAT or OPEN CHAT FILE.'); } +} + +start().catch(error => status(error.message)); + +// @license-end diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html new file mode 100644 index 0000000..9783789 --- /dev/null +++ b/crates/chat-server/web/index.html @@ -0,0 +1,82 @@ +<!-- +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/>. +--> +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Message Me | Doan Luu Duc Tai</title> +<link rel="stylesheet" href="/style.css"> +<script src="/app.js" defer></script> +</head> +<body> +<main> +<header><a href="https://doan.luuduct.ai/">~doan</a> / message me <span class="status">EXPERIMENTAL / LOCAL TEST</span></header> +<h1>[ private chat ]</h1> +<p>Text only. A dedicated identity file. Encrypted for you and Doan.</p> +<p class="notice">This is an experimental application, not an audited messenger. <a href="/LIMITATIONS.txt">Read its limitations</a> before use. Keep your file and password safe: there is no account recovery.</p> +<p id="status" role="status" aria-live="polite">Loading cryptographic module...</p> +<section id="entry" hidden> +<button id="create-choice">CREATE NEW CHAT</button> +<button id="open-choice">OPEN CHAT FILE</button> +</section> +<section id="create" hidden> +<h2 id="create-title">Create a new chat</h2> +<form id="create-form" autocomplete="off"> +<label>Display name <input id="name" maxlength="80" required autocomplete="off"></label> +<label>Password <input id="new-password" type="password" minlength="12" maxlength="1024" required autocomplete="off"></label> +<label>Confirm password <input id="confirm-password" type="password" minlength="12" required autocomplete="off"></label> +<label id="setup-label" hidden>Owner setup token from the root .env <input id="setup-token" type="password" autocomplete="off"></label> +<p>Use a strong, unique passphrase. This identity is solely for this chat; do not reuse it elsewhere.</p> +<button type="submit">GENERATE AND DOWNLOAD IDENTITY FILE</button> +</form> +</section> +<section id="unlock" hidden> +<h2>Open your saved identity file</h2> +<p id="reopen-note">Your file is opened locally. The file and password are never uploaded.</p> +<form id="unlock-form" autocomplete="off"> +<label>Identity file <input id="file" type="file" required></label> +<label>Password <input id="password" type="password" required autocomplete="off"></label> +<button type="submit">UNLOCK CHAT</button> +</form> +</section> +<section id="chat" hidden> +<div class="chat-heading"><h2 id="who"></h2><button id="lock">LOCK / FORGET</button></div> +<p id="fingerprint"></p> +<section id="inbox" hidden><h3>Conversations</h3><button id="refresh-inbox">REFRESH INBOX</button><div id="conversations"></div></section> +<section id="thread" hidden> +<h3 id="thread-name"></h3> +<p id="conversation-fingerprint"></p> +<p id="counts"></p> +<form id="history-form"><label>Latest messages to load (0–50) <input id="history-count" type="number" min="0" max="50" step="1" value="0" required></label><button type="submit">LOAD HISTORY</button></form> +<button id="older" hidden>LOAD OLDER</button> +<ol id="messages" aria-label="Messages"></ol> +<form id="send-form" autocomplete="off"><label>Plain text message (up to 8192 UTF-8 bytes) <textarea id="text" rows="5" required maxlength="8192" autocomplete="off"></textarea></label><button type="submit">ENCRYPT AND SEND</button></form> +<button id="retry" hidden>RETRY THE SAME ENCRYPTED MESSAGE</button> +</section> +</section> +<noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript> +<footer> +<p><a class="badge" href="https://www.gnu.org/software/librejs/" aria-describedby="js-warning">GNU LibreJS-compliant</a><span id="js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span></p> +<a href="https://www.gnu.org/licenses/agpl-3.0.html">LICENSE</a> +<a href="/THIRD-PARTY-LICENSES.html">THIRD PARTY LICENSES</a> +<a href="/SOURCE.tar.gz">CORRESPONDING SOURCE CODE</a> +<a href="/javascript.html" rel="jslicense">JAVASCRIPT LICENSES / RAW SOURCE</a> +</footer> +</main> +</body> +</html> diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html new file mode 100644 index 0000000..927a678 --- /dev/null +++ b/crates/chat-server/web/javascript.html @@ -0,0 +1,28 @@ +<!-- +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/>. +--> +<!doctype html> +<html lang="en"><head><meta charset="utf-8"><title>JavaScript licenses</title><link rel="stylesheet" href="/style.css"></head><body><main> +<h1>JavaScript licenses and source</h1> +<p>All first-party scripts are available under the GNU Affero General Public License, version 3 or, at your option, any later version. The table uses the license identifiers recognized by GNU LibreJS.</p> +<table id="jslicense-labels1"><thead><tr><th>Script / raw file</th><th>License</th><th>Corresponding source</th></tr></thead><tbody> +<tr><td><a href="/app.js">app.js</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +<tr><td><a href="/worker.js">worker.js</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +<tr><td><a href="/wasi.mjs">wasi.mjs</a></td><td><a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU-AGPL-3.0</a></td><td><a href="/SOURCE.tar.gz">Complete corresponding source</a></td></tr> +</tbody></table> +<p>The WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p> +<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p> +</main></body></html> diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css new file mode 100644 index 0000000..0983b2e --- /dev/null +++ b/crates/chat-server/web/style.css @@ -0,0 +1,49 @@ +/* +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/>. +*/ +* { box-sizing: border-box; } +:root { color-scheme: dark; font-family: "Courier New", monospace; background: #131715; color: #d4d8cd; line-height: 1.6; } +body { margin: 0; } +main { max-width: 58rem; margin: 2rem auto; padding: 0 1.25rem; } +header, footer { border-top: 1px solid #667367; padding-top: 1rem; } +header { display: flex; flex-wrap: wrap; gap: .6rem; } +h1 { font-size: 1.8rem; color: #e3e3ce; } +h2, h3 { font-size: 1.05rem; } +a { color: #aec2a5; text-underline-offset: .2rem; } +.status, .badge { color: #e0c46d; } +.status { margin-left: auto; } +.notice { border-left: 2px solid #c5ad68; padding-left: 1rem; } +button, input, textarea { font: inherit; color: inherit; background: #1c231e; border: 1px solid #667367; border-radius: 0; padding: .55rem .7rem; } +button { cursor: pointer; margin: .4rem .6rem .4rem 0; } +button:hover, button:focus-visible { border-color: #d7c884; color: #f0e5b0; } +button:disabled { opacity: .5; cursor: wait; } +input, textarea { display: block; width: 100%; margin: .35rem 0 1rem; } +input[type=number] { width: 8rem; } +label { display: block; } +section { margin: 1.5rem 0; } +[hidden] { display: none !important; } +.chat-heading { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; } +#fingerprint, #conversation-fingerprint { overflow-wrap: anywhere; font-size: .8rem; } +#messages { padding: 0; list-style: none; } +#messages li { border-top: 1px dashed #48564a; padding: 1rem 0; } +#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .4rem 0; } +#messages small { color: #a8b39e; } +#status { min-height: 1.6rem; color: #e0c46d; } +footer { margin-top: 3rem; padding-bottom: 2rem; } +footer > a { display: block; width: fit-content; } +footer p { position: relative; } +#js-warning { display: none; max-width: 48rem; border: 1px solid #c5ad68; background: #1c231e; padding: 1rem; margin: .8rem 0; } +.badge:hover + #js-warning, .badge:focus-visible + #js-warning { display: block; } diff --git a/crates/chat-server/web/worker.js b/crates/chat-server/web/worker.js new file mode 100644 index 0000000..64e0795 --- /dev/null +++ b/crates/chat-server/web/worker.js @@ -0,0 +1,31 @@ +// 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 + +importScripts('/wasi.mjs'); + +self.onmessage = async event => { + try { + const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); + self.postMessage({ result }); + } catch { + self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' }); + } finally { + self.close(); + } +}; + +// @license-end |
