summaryrefslogtreecommitdiff
path: root/crates/chat-server/web
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/web')
-rw-r--r--crates/chat-server/web/app.ts165
-rw-r--r--crates/chat-server/web/worker.ts4
2 files changed, 93 insertions, 76 deletions
diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts
index 79a827e..de3abb7 100644
--- a/crates/chat-server/web/app.ts
+++ b/crates/chat-server/web/app.ts
@@ -17,7 +17,7 @@
function element(id: string): HTMLElement {
const value = document.getElementById(id);
- if (!value) throw new Error(`Missing interface element: ${id}`);
+ if (!(value !== null)) throw new Error(`Missing interface element: ${id}`);
return value;
}
@@ -57,23 +57,28 @@ const workers = new Map<Worker, () => void>();
const blobs = new Set<string>();
function configuration(): StackChat.Configuration {
- if (!config) throw new Error('Chat configuration is not loaded');
+ if (config === undefined) throw new Error('Chat configuration is not loaded');
return config;
}
+function hasOwner(): boolean {
+ const owner = config?.owner;
+ return owner !== undefined && owner !== null && owner.length > 0;
+}
+
function ownerKey(): string {
const owner = configuration().owner;
- if (!owner) throw new Error('Owner setup is required first');
+ if (owner === null || owner.length === 0) throw new Error('Owner setup is required first');
return owner;
}
function activeSession(): StackChat.Session {
- if (!session) throw new Error('Unlock your identity file first');
+ if (session === undefined) throw new Error('Unlock your identity file first');
return session;
}
function conversation(): StackChat.Conversation {
- if (!selected) throw new Error('Choose a conversation first');
+ if (selected === undefined) throw new Error('Choose a conversation first');
return selected;
}
@@ -171,41 +176,41 @@ async function api<K extends keyof StackChat.Api>(request: StackChat.Api[K]['req
});
const result = record(await response.json());
if (current !== generation) throw new Error('Session locked');
- if (!response.ok || result['error']) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed');
+ if (!response.ok || 'error' in result) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed');
return apiReaders[request.op](result);
}
async function cryptoOperation<K extends keyof StackChat.Crypto>(request: StackChat.Crypto[K]['request'] & { op: K }): Promise<StackChat.Crypto[K]['response']> {
const current = generation;
- if (!wasm) throw new Error('Cryptographic module is not loaded');
+ if (wasm === undefined) throw new Error('Cryptographic module is not loaded');
const bytes = await wasm;
if (current !== generation) throw new Error('Session locked');
return new Promise<StackChat.Crypto[K]['response']>((resolve, reject) => {
const worker = new Worker('/worker.js');
- const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); };
+ const stop = (): void => { 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: MessageEvent<unknown>) => {
+ worker.onerror = (): void => { stop(); reject(new Error('Cryptographic worker failed')); };
+ worker.onmessage = (event: MessageEvent<unknown>): void => {
stop();
if (current !== generation) { reject(new Error('Session locked')); return; }
try {
const message = record(event.data);
if ('error' in message) throw new Error(stringField(message, 'error'));
resolve(cryptoReaders[request.op](message['result']));
- } catch (error) { reject(error); }
+ } catch (error) { reject(error instanceof Error ? error : new Error('Cryptographic response rejected', { cause: error })); }
};
worker.postMessage({ bytes, request });
});
}
function protectRequest<K extends 'encrypt' | 'decrypt' | 'decrypt_batch'>(op: K, fields: Omit<StackChat.Crypto[K]['request'], keyof StackChat.Protected | 'op'>): StackChat.Protected & { op: K } & typeof fields {
- if (!credentials) throw new Error('Unlock your identity file first');
+ if (credentials === undefined) throw new Error('Unlock your identity file first');
return { op, ...credentials, origin: location.origin, ...fields };
}
-function clearState() {
+function clearState(): void {
generation += 1;
busy = false;
for (const button of document.querySelectorAll('button')) button.disabled = false;
@@ -227,13 +232,18 @@ function clearState() {
}
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('entry', hasOwner());
show('owner-entry');
setStatus('Locked. Select your file and enter its password to return.');
}
-function action(id: string, callback: () => Promise<void>): void {
- element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => {
+function background(operation: () => Promise<void>): void {
+ const current = generation;
+ operation().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
+}
+
+function action(id: string, callback: () => void | Promise<void>): void {
+ const handler = async (event: Event): Promise<void> => {
event.preventDefault();
if (busy) return;
busy = true;
@@ -248,7 +258,8 @@ function action(id: string, callback: () => Promise<void>): void {
for (const button of document.querySelectorAll('button')) button.disabled = false;
}
}
- });
+ };
+ element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', event => { background(() => handler(event)); });
}
function download(file: string): void {
@@ -261,13 +272,13 @@ function download(file: string): void {
setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000);
}
-async function createIdentity() {
+async function createIdentity(): Promise<void> {
const password = input('new-password').value;
if (password !== input('confirm-password').value) throw new Error('Passwords do not match');
- if (!configuration().owner && element('setup-label').hidden) throw new Error('Owner setup is required first');
- setupToken = !configuration().owner ? input('setup-token').value : undefined;
+ if (!hasOwner() && element('setup-label').hasAttribute('hidden')) throw new Error('Owner setup is required first');
+ setupToken = !hasOwner() ? input('setup-token').value : undefined;
setStatus('Generating your purpose-specific identity and protecting its file...');
- const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner || '' });
+ const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner ?? '' });
pendingFile = result.file;
download(result.file);
form('create-form').reset();
@@ -277,16 +288,16 @@ async function createIdentity() {
setStatus('Reopen your downloaded identity file to verify recovery.');
}
-async function unlockIdentity() {
+async function unlockIdentity(): Promise<void> {
const file = input('file').files?.[0];
- if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB');
+ if (file === undefined || 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');
+ if (((pendingFile ?? '').length > 0) && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded');
const candidate = { file: fileText, password: input('password').value };
form('unlock-form').reset();
setStatus('Unlocking locally and verifying your identity...');
const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin });
- if (configuration().owner && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.');
+ if (hasOwner() && identity.owner !== configuration().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 });
@@ -301,7 +312,7 @@ async function unlockIdentity() {
show('owner-access', false);
show('unlock', false);
show('chat');
- element('who').textContent = `${identity.name} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`;
+ element('who').textContent = `${identity.name} [${activeSession().owner ? 'OWNER' : 'VISITOR'}]`;
element('fingerprint').textContent = `Identity: ${identity.fingerprint}`;
if (authenticated.owner) {
activeSession().owner = true;
@@ -329,15 +340,15 @@ function checkedPayload(payload: string, kind: 'profile' | 'message', conversati
return { id: stringField(value, 'id'), text: stringField(value, 'text') };
}
-async function inbox() {
+async function inbox(): Promise<void> {
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 () => {
+ button.textContent = `${conversation.id.slice(0, 12)} / ${String(conversation.total)} messages`;
+ const handler = async (): Promise<void> => {
if (busy) return;
- if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; }
+ if ((pendingSend !== undefined)) { setStatus('Retry the pending message, or lock before switching conversations.'); return; }
busy = true;
const current = generation;
setStatus('Opening conversation...');
@@ -351,8 +362,9 @@ async function inbox() {
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 }));
+ const certificate = conversation.profile;
+ if (certificate !== undefined && certificate !== null && certificate.length > 0) {
+ const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: certificate }));
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');
@@ -363,7 +375,8 @@ async function inbox() {
setStatus('Conversation opened. Choose how many messages to load.');
} catch (error) { setStatus(errorMessage(error)); }
finally { if (generation === current) busy = false; }
- });
+ };
+ button.addEventListener('click', () => { background(handler); });
element('conversations').append(button);
}
}
@@ -371,10 +384,10 @@ async function inbox() {
function counters(conversation: StackChat.Conversation): void {
if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count');
input('history-count').max = String(conversation.total);
- element('history-range').textContent = `Latest messages (0-${conversation.total})`;
+ element('history-range').textContent = `Latest messages (0-${String(conversation.total)})`;
const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent;
const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent;
- element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`;
+ element('counts').textContent = `Total: ${String(conversation.total)} | Sent: ${String(sent)} | Received: ${String(received)}`;
}
async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> {
@@ -382,14 +395,14 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void>
const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
- if (!message) throw new Error('Missing history message');
+ if (message === undefined) throw new Error('Missing history message');
const item = document.createElement('li');
const label = document.createElement('small');
- label.textContent = `#${message.seq} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`;
+ label.textContent = `#${String(message.seq)} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`;
const text = document.createElement('p');
try {
const entry = decrypted.messages[index];
- if (!entry || 'error' in entry) throw new Error('Invalid ciphertext or signature');
+ if (entry === undefined || 'error' in entry) throw new Error('Invalid ciphertext or signature');
if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
const payload = checkedPayload(entry.payload, 'message', conversation().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');
@@ -400,7 +413,7 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void>
}
}
-async function refreshCounts() {
+async function refreshCounts(): Promise<StackChat.Conversation> {
const conversationId = conversation().id;
const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 });
if (selected?.id === conversationId) counters(result.conversation);
@@ -408,10 +421,10 @@ async function refreshCounts() {
}
async function loadHistory(count: number, cursor = 0): Promise<void> {
- if (!selected) throw new Error('Choose a conversation first');
+ if (selected === undefined) throw new Error('Choose a conversation first');
const currentConversation = await refreshCounts();
- if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${currentConversation.total} messages`);
- if (!cursor) { element('messages').replaceChildren(); before = 0; }
+ if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${String(currentConversation.total)} messages`);
+ if (!(cursor !== 0)) { element('messages').replaceChildren(); before = 0; }
if (count === 0) {
input('history-count').value = '0';
show('older', false);
@@ -426,10 +439,10 @@ async function loadHistory(count: number, cursor = 0): Promise<void> {
const result = await api({ op: 'history', conversation: conversation().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; }
+ if (!(result.messages.length !== 0)) { 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');
- setStatus(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`);
+ setStatus(`Decrypting and verifying selected messages locally (${String(count - remaining + result.messages.length)}/${String(count)})...`);
await renderHistoryBatch(result.messages);
remaining -= result.messages.length;
next = sequence;
@@ -440,8 +453,8 @@ async function loadHistory(count: number, cursor = 0): Promise<void> {
setStatus('Selected history decrypted and verified locally.');
}
-async function submitPending() {
- if (!pendingSend) throw new Error('No pending message');
+async function submitPending(): Promise<void> {
+ if (pendingSend === undefined) throw new Error('No pending message');
const result = await api(pendingSend);
counters(result.conversation);
pendingSend = undefined;
@@ -450,11 +463,11 @@ async function submitPending() {
setStatus('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');
+async function send(): Promise<void> {
+ if (selected === undefined) throw new Error('Choose a conversation first');
+ if ((pendingSend !== undefined)) throw new Error('Retry or lock before composing another message');
const text = messageInput().value;
- if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text');
+ if (!((text.trim()).length > 0) || 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 = activeSession().owner ? conversation().public : ownerKey();
setStatus('Signing and encrypting for both participants locally...');
@@ -464,62 +477,66 @@ async function send() {
await submitPending();
}
-function ownerSetup() {
+function ownerSetup(): void {
show('entry', false);
show('owner-entry', false);
show('unlock', false);
- show('create', !configuration().owner);
- show('owner-access', Boolean(configuration().owner));
- show('setup-label', !configuration().owner);
+ show('create', !hasOwner());
+ show('owner-access', hasOwner());
+ show('setup-label', !hasOwner());
element('create-title').textContent = 'Create the owner identity (owner only)';
setStatus('');
}
-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'); setStatus(''); });
-action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); });
-action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
-action('owner-open', async () => { show('owner-access', false); show('unlock'); setStatus(''); });
+action('create-choice', () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); });
+action('open-choice', () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); });
+action('owner-choice', () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
+action('owner-open', () => { show('owner-access', false); show('unlock'); setStatus(''); });
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) setStatus(errorMessage(error)); });
+ if ((session !== undefined) && (selected !== undefined) && !busy) refreshCounts().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
});
action('history-form', () => loadHistory(Number(input('history-count').value)));
action('send-form', send);
action('retry', submitPending);
action('refresh-inbox', inbox);
action('older', () => loadHistory(Number(input('history-count').value), before));
-element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); });
+element('lock').addEventListener('click', () => {
+ const current = generation;
+ if (session !== undefined) api({ op: 'lock' }).catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
+ clearState();
+});
window.addEventListener('pagehide', clearState);
window.addEventListener('pageshow', event => { if (event.persisted) clearState(); });
-async function downloadCrypto() {
+async function downloadCrypto(): Promise<ArrayBuffer> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>> | undefined;
- const refreshTimeout = () => {
+ const refreshTimeout = (): void => {
clearTimeout(timer);
- timer = setTimeout(() => controller.abort(), 45000);
+ 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'));
+ if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${String(response.status)}). Reload to try again.`);
+ const length = ((response.headers.get('content-encoding') ?? '').length > 0) ? 0 : Number(response.headers.get('content-length'));
const total = Number.isSafeInteger(length) && length > 0 ? length : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
let received = 0;
- const progress = () => {
+ const progress = (): void => {
const loaded = (received / 1048576).toFixed(1);
- if (total) {
+ if ((total !== 0)) {
const percent = Math.min(100, Math.floor(received * 100 / total));
const filled = Math.floor(percent / 5);
- setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
+ setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${String(percent)}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
} else setStatus(`Downloading cryptographic module... ${loaded} MiB received`);
};
progress();
- if (!response.body) throw new Error('Cryptographic module response has no body');
+ if (!(response.body !== null)) throw new Error('Cryptographic module response has no body');
reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
@@ -529,13 +546,13 @@ async function downloadCrypto() {
received += value.byteLength;
progress();
}
- if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.');
+ if (!(received !== 0)) 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.');
+ if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.', { cause: error });
throw error;
} finally {
clearTimeout(timer);
@@ -543,18 +560,18 @@ async function downloadCrypto() {
}
}
-async function start() {
+async function start(): Promise<void> {
wasm = downloadCrypto();
await wasm;
setStatus('Cryptographic module downloaded. Connecting to chat...');
config = await api({ op: 'config' });
if (configuration().origin !== location.origin) throw new Error('Server origin mismatch');
show('owner-entry');
- show('entry', Boolean(configuration().owner));
+ show('entry', hasOwner());
setStatus('');
if (location.hash === '#setup') ownerSetup();
}
-start().catch(error => setStatus(errorMessage(error)));
+start().catch((error: unknown) => { setStatus(errorMessage(error)); });
// @license-end
diff --git a/crates/chat-server/web/worker.ts b/crates/chat-server/web/worker.ts
index d4367bf..d3a1c68 100644
--- a/crates/chat-server/web/worker.ts
+++ b/crates/chat-server/web/worker.ts
@@ -17,9 +17,9 @@
importScripts('/wasi.mjs');
-self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>) => {
+self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>): Promise<void> => {
try {
- const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request);
+ const result = await 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.' });