aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-server/web
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
commitbe16d00a8a4750028073e018ab2c58d54af192c4 (patch)
tree3eeabb31508f98a6753a2492238581f888769b4a /crates/chat-server/web
parentb7374fe09306c495131cf3673addb76a1dcf017d (diff)
Refine chat controls and add explicit owner recovery reset
Simplify the ASCII interface, expose owner setup, and load any selected number of messages using bounded history batches. Add backend-only owner reset with a matching public fingerprint and stopped-service guards, and document the loss of old history and key continuity.
Diffstat (limited to 'crates/chat-server/web')
-rw-r--r--crates/chat-server/web/app.js140
-rw-r--r--crates/chat-server/web/index.html19
-rw-r--r--crates/chat-server/web/style.css15
3 files changed, 120 insertions, 54 deletions
diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js
index c06116b..3c97f66 100644
--- a/crates/chat-server/web/app.js
+++ b/crates/chat-server/web/app.js
@@ -86,12 +86,15 @@ function clearState() {
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']) show(id, false);
+ 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.');
}
@@ -127,7 +130,7 @@ function download(file) {
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');
+ 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 || '' });
@@ -160,6 +163,8 @@ async function unlockIdentity() {
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'}]`;
@@ -176,7 +181,7 @@ async function unlockIdentity() {
element('conversation-fingerprint').textContent = `Conversation: ${session.id}`;
show('thread');
element('thread-name').textContent = 'You <-> Doan';
- await history(0);
+ await loadHistory(0);
}
status('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.');
}
@@ -217,7 +222,7 @@ async function inbox() {
name = profile.name;
}
element('thread-name').textContent = `Doan <-> ${name}`;
- await history(0);
+ await loadHistory(0);
status('Conversation opened. Choose how many messages to load.');
} catch (error) { status(error.message); }
finally { if (generation === current) busy = false; }
@@ -227,42 +232,74 @@ async function inbox() {
}
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 history(count, cursor = 0) {
+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');
- 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));
+ 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', count > 0 && result.messages.length === count);
- status(count === 0 ? 'No message history loaded.' : 'Selected history decrypted and verified locally.');
+ show('older', !exhausted && element('messages').children.length < Number(element('history-count').max));
+ status('Selected history decrypted and verified locally.');
}
async function submitPending() {
@@ -289,15 +326,32 @@ async function send() {
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.'); });
+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);
-action('history-form', () => history(Number(element('history-count').value)));
+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', () => history(Number(element('history-count').value), before));
+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(); });
@@ -310,14 +364,10 @@ async function start() {
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.'); }
+ show('owner-entry');
+ show('entry', Boolean(config.owner));
+ status('');
+ if (location.hash === '#setup') ownerSetup();
}
start().catch(error => status(error.message));
diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html
index c240b85..7c2fe8b 100644
--- a/crates/chat-server/web/index.html
+++ b/crates/chat-server/web/index.html
@@ -25,7 +25,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</head>
<body>
<main>
-<div class="libre-badge">[ <a 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> ]</div>
+<h1 class="sr-only">Message Me</h1>
+<div class="libre-badge">[ <a 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> ]
+<a class="script-licenses" href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a></div>
<header>
<pre class="logo-art"><a href="https://doan.luuduct.ai/" class="logo" aria-label="Doan Luu Duc Tai home">
____ ___ _ _ _
@@ -39,8 +41,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<div class="ascii-panel introduction">
<div class="ascii-bar" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div>
<div class="panel-body">
-<h1>MESSAGE ME</h1>
-<p>Private text conversations between you and Doan.</p>
<p class="build-status">EXPERIMENTAL / LOCAL TEST</p>
<p>Keep your file and password. There is no account recovery.<br><a href="/LIMITATIONS.txt">Read the limitations</a> before using this experimental messenger.</p>
</div>
@@ -51,6 +51,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<button id="create-choice">CREATE NEW CHAT</button>
<button id="open-choice">OPEN CHAT FILE</button>
</section>
+<section id="owner-entry" class="owner-entry" hidden>
+<button id="owner-choice">OWNER SETUP</button>
+<span class="owner-note">/* Owner (Doan Luu Duc Tai) only. */</span>
+</section>
+<section id="owner-access" hidden>
+<h2>Owner access (owner only)</h2>
+<p>An owner identity is already registered. Open your saved owner file with its password.</p>
+<p>If you lost that file, <a href="/LIMITATIONS.txt">reset the chat service from the backend</a> before creating a new owner identity. A reset clears the chat history; it cannot recover the lost key.</p>
+<button id="owner-open">OPEN OWNER FILE</button>
+</section>
<section id="create" hidden>
<h2 id="create-title">Create a new chat</h2>
<form id="create-form" autocomplete="off">
@@ -79,7 +89,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<h3 id="thread-name"></h3>
<p id="conversation-fingerprint"></p>
<p id="counts"></p>
-<form id="history-form"><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label class="field-heading" for="history-count">Latest messages (0-50)</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="history-count" type="number" min="0" max="50" step="1" value="0" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">LOAD HISTORY</button></form>
+<form id="history-form" novalidate><div class="ascii-field"><div class="ascii-bar field-header"><span aria-hidden="true">+--[ </span><label id="history-range" class="field-heading" for="history-count">Latest messages (0-0)</label><span aria-hidden="true"> ]</span><span class="ascii-rule" aria-hidden="true">--------------------------------------------------------------------------------</span><span aria-hidden="true">+</span></div><div class="field-row"><input id="history-count" type="number" min="0" max="0" step="1" value="0" required></div><div class="ascii-bar field-end" aria-hidden="true"><span>+</span><span class="ascii-rule">--------------------------------------------------------------------------------</span><span>+</span></div></div><button type="submit">LOAD HISTORY</button></form>
<button id="older" hidden>LOAD OLDER</button>
<ol id="messages" aria-label="Messages"></ol>
<p id="message-limit">Plain text only. Up to 8192 UTF-8 bytes per message.</p>
@@ -92,7 +102,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a>
<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
<a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a>
-<a href="/javascript.html" rel="jslicense">[ JAVASCRIPT LICENSES ]</a>
</footer>
</main>
</body>
diff --git a/crates/chat-server/web/style.css b/crates/chat-server/web/style.css
index 80b07a6..5587f58 100644
--- a/crates/chat-server/web/style.css
+++ b/crates/chat-server/web/style.css
@@ -29,12 +29,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}
* { box-sizing: border-box; }
-body { overflow-y: scroll; font: 16px/1.5 monospace, monospace; margin: 0; background: var(--background); color: var(--foreground); }
+html { overflow-y: scroll; scrollbar-gutter: stable; }
+body { overflow: visible; font: 16px/1.5 monospace, monospace; margin: 0; background: var(--background); color: var(--foreground); }
main { width: 80ch; max-width: calc(100% - 32px); margin: 16px auto; position: relative; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--link); outline-offset: 4px; }
-.logo-art { font: inherit; line-height: 1.2; margin: 0 0 2em; overflow-x: auto; }
+.logo-art { font: inherit; line-height: 1.2; margin: 0 0 2em; overflow: visible; }
.logo { color: var(--logo); display: inline-block; }
.logo:hover { text-decoration: none; }
.site-nav { display: flex; flex-wrap: wrap; gap: 2ch; margin: 0 0 2em; }
@@ -85,12 +86,18 @@ section { margin: 2em 0; }
#messages li::before { content: '--------------------------------------------------------------------------------'; display: block; overflow: hidden; white-space: pre; margin: 1em 0; }
#messages p { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5em 0 1em; }
#messages small { color: var(--link); font: inherit; }
+#status:empty { display: none; }
#status { min-height: 1.5em; color: var(--link); margin: 2em 0 1em; padding-left: 2ch; position: relative; }
#status::before { content: '>'; color: var(--good); position: absolute; left: 0; font-weight: bold; }
.libre-badge { position: absolute; top: 0; right: 0; color: var(--warning); font-weight: bold; }
-.libre-badge > a { color: inherit; text-decoration: underline wavy; text-underline-offset: .2em; }
+.libre-badge > a[aria-describedby] { color: inherit; text-decoration: underline wavy; text-underline-offset: .2em; }
#js-warning { display: none; position: absolute; top: 2em; right: 0; width: 48ch; max-width: calc(100vw - 32px); padding: 1em 2ch; background: var(--background); color: var(--foreground); border: 1px dashed var(--warning); z-index: 1; font-weight: normal; }
-.libre-badge > a:hover + #js-warning, .libre-badge > a:focus-visible + #js-warning { display: block; }
+.libre-badge > a[aria-describedby]:hover + #js-warning, .libre-badge > a[aria-describedby]:focus-visible + #js-warning { display: block; }
+.script-licenses { display: block; color: var(--link); font-size: .875em; text-align: right; margin-top: .5em; }
+.owner-entry { display: flex; flex-wrap: wrap; align-items: baseline; gap: .5em 1ch; margin: 1em 0; }
+.owner-entry button { margin-right: 0; }
+.owner-note { color: var(--muted); font-size: .875em; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.source-footer { display: flex; flex-direction: column; align-items: flex-end; gap: .5em; text-align: right; margin: 3em 0 1em; font-weight: bold; }
.source-footer::before { content: '________________________________________________________________________________'; display: block; width: 100%; overflow: hidden; white-space: pre; margin-bottom: 1em; font-weight: normal; }
.project-license { color: var(--license); }