diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 12:25:30 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-31 12:25:30 -0500 |
| commit | 9139398d465f5899cc31bc4cdd54daa7392ca550 (patch) | |
| tree | debf0b15a969e8c64e3b3597f4eb7953b7f047e1 /crates/chat-server | |
| parent | ef61fc5aca6eca613a055c507a060b70bd537a37 (diff) | |
Migrate browser code and tests to strict TypeScript
Diffstat (limited to 'crates/chat-server')
| -rw-r--r-- | crates/chat-server/src/http.rs | 101 | ||||
| -rw-r--r-- | crates/chat-server/tests/browser.ts (renamed from crates/chat-server/tests/browser.mjs) | 147 | ||||
| -rw-r--r-- | crates/chat-server/tests/progress.ts (renamed from crates/chat-server/tests/progress.mjs) | 58 | ||||
| -rw-r--r-- | crates/chat-server/web/app.js | 421 | ||||
| -rw-r--r-- | crates/chat-server/web/app.ts | 560 | ||||
| -rw-r--r-- | crates/chat-server/web/javascript.html | 1 | ||||
| -rw-r--r-- | crates/chat-server/web/worker.ts (renamed from crates/chat-server/web/worker.js) | 2 |
7 files changed, 744 insertions, 546 deletions
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs index 7394b71..cb21b1f 100644 --- a/crates/chat-server/src/http.rs +++ b/crates/chat-server/src/http.rs @@ -93,59 +93,94 @@ async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Val } } -async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { - if uri.path() == "/THIRD-PARTY-LICENSES.html" { - let Some(origin) = app.public.get("SITE_ORIGIN") else { - return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); - }; - return secured( - Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), - ); +fn packaged_asset<'a>(root: &Path, packaged: &'a str, development: &'a str) -> &'a str { + if root.join(packaged).is_file() { + packaged + } else { + development } - let (path, mime) = match uri.path() { - "/" => ( +} + +fn asset_location(root: &Path, path: &str) -> Option<(&'static str, &'static str)> { + let assets = [ + ( + "/", + "bin/chat/web/index.html", "crates/chat-server/web/index.html", "text/html; charset=utf-8", ), - "/app.js" => ( - "crates/chat-server/web/app.js", + ( + "/app.js", + "bin/chat/web/app.js", + "var/build/typescript/crates/chat-server/web/app.js", "text/javascript; charset=utf-8", ), - "/worker.js" => ( - "crates/chat-server/web/worker.js", + ( + "/worker.js", + "bin/chat/web/worker.js", + "var/build/typescript/crates/chat-server/web/worker.js", "text/javascript; charset=utf-8", ), - "/wasi.mjs" => ( - "crates/chat-crypto/browser/wasi.mjs", + ( + "/wasi.mjs", + "bin/chat/wasi.mjs", + "var/build/typescript/crates/chat-crypto/browser/wasi.js", "text/javascript; charset=utf-8", ), - "/style.css" => ( + ( + "/style.css", + "bin/chat/web/style.css", "crates/chat-server/web/style.css", "text/css; charset=utf-8", ), - "/crypto.wasm" => ( - if app.root.join("bin/chat/crypto.wasm").is_file() { - "bin/chat/crypto.wasm" - } else { - "target/wasm32-wasip1/release/stack-chat-crypto.wasm" - }, + ( + "/crypto.wasm", + "bin/chat/crypto.wasm", + "target/wasm32-wasip1/release/stack-chat-crypto.wasm", "application/wasm", ), - "/javascript.html" => ( + ( + "/javascript.html", + "bin/chat/web/javascript.html", "crates/chat-server/web/javascript.html", "text/html; charset=utf-8", ), - "/SOURCE.tar.gz" => ( - if app.root.join("bin/chat/SOURCE.tar.gz").is_file() { - "bin/chat/SOURCE.tar.gz" - } else { - "var/build/chat-source.tar.gz" - }, + ( + "/SOURCE.tar.gz", + "bin/chat/SOURCE.tar.gz", + "var/build/chat-source.tar.gz", "application/gzip", ), - "/LIMITATIONS.txt" => ("docs/CHAT-APPLICATION.txt", "text/plain; charset=utf-8"), - "/LICENSE" => ("LICENSE", "text/plain; charset=utf-8"), - _ => return secured(StatusCode::NOT_FOUND.into_response()), + ( + "/LIMITATIONS.txt", + "docs/CHAT-APPLICATION.txt", + "docs/CHAT-APPLICATION.txt", + "text/plain; charset=utf-8", + ), + ( + "/LICENSE", + "LICENSE", + "LICENSE", + "text/plain; charset=utf-8", + ), + ]; + assets + .into_iter() + .find(|(route, _, _, _)| *route == path) + .map(|(_, packaged, development, mime)| (packaged_asset(root, packaged, development), mime)) +} + +async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response { + if uri.path() == "/THIRD-PARTY-LICENSES.html" { + let Some(origin) = app.public.get("SITE_ORIGIN") else { + return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + return secured( + Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(), + ); + } + let Some((path, mime)) = asset_location(&app.root, uri.path()) else { + return secured(StatusCode::NOT_FOUND.into_response()); }; if matches!(uri.path(), "/" | "/javascript.html") { let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else { diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.ts index b0a9041..74a9df2 100644 --- a/crates/chat-server/tests/browser.mjs +++ b/crates/chat-server/tests/browser.ts @@ -21,16 +21,18 @@ import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; +import { chromium, firefox, type Browser, type BrowserContext, type Page } from 'playwright'; +import { libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; const run = promisify(execFile); -const packaged = Boolean(process.env.CHAT_TEST_ROOT); -let root = packaged ? resolve(process.env.CHAT_TEST_ROOT) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); +const testRoot = process.env['CHAT_TEST_ROOT']; +const packaged = Boolean(testRoot); +let root = testRoot ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); const initialRoot = root; const token = packaged - ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN=')).slice('CHAT_SETUP_TOKEN='.length) + ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) || '' : randomBytes(32).toString('hex'); -const origin = process.env.CHAT_TEST_ORIGIN || `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; +const origin = process.env['CHAT_TEST_ORIGIN'] || `http://127.0.0.1:${process.env['CHAT_TEST_PORT'] || '9081'}`; if (packaged) { const fixture = await readFile(join(root,'.env'),'utf8'); const address = new URL(origin); @@ -48,14 +50,15 @@ if (packaged) { } else { await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); - await mkdir(join(root,'var/build'), {recursive:true}); + await mkdir(join(root,'var'), {recursive:true}); + await symlink(resolve('var/build'),join(root,'var/build')); } -async function stack(...args) { +async function stack(...args: string[]) { return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); } async function restorePackage() { const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); - const fingerprint = listed.stdout.split('\n').find(line => line.startsWith('fpr:')).split(':')[9]; + const fingerprint = required(required(listed.stdout.split('\n').find(line => line.startsWith('fpr:')), 'Missing key fingerprint').split(':')[9], 'Missing key fingerprint field'); const key = join(testHome,'recipient.asc'); const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); await writeFile(key,exported.stdout); @@ -73,15 +76,15 @@ async function restorePackage() { await launchServer(); console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); } -let server; +let server: ReturnType<typeof spawn> | undefined; let serverErrors = ''; async function verifyGateway() { const url = new URL(origin); const args = ['--noproxy','*','--silent','--show-error','--fail']; if (url.hostname.endsWith('.localhost')) { - const state = JSON.parse(await readFile(join(root,'var/state.json'),'utf8')); + const state = record(JSON.parse(await readFile(join(root,'var/state.json'),'utf8'))); const certificate = join(testHome,'gateway-ca.crt'); - await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${state.project}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); + await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${string(state['project'])}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); args.push('--cacert',certificate,'--resolve',`${url.hostname}:${url.port}:127.0.0.1`); } const health = await run('curl',[...args,origin + '/healthz']); @@ -98,23 +101,24 @@ async function launchServer() { throw failure; } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); - server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); + server.stderr?.on('data', bytes => { serverErrors += bytes.toString(); }); await Promise.race([ - new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), + new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => ready()); server?.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), ]); } async function stopServer() { if (packaged) { await stack('down'); return; } + if (!server) return; if (server.exitCode !== null || server.signalCode !== null) return; - const stopped = new Promise(done => server.once('exit',done)); + const stopped = new Promise<void>(done => server?.once('exit', () => done())); server.kill('SIGINT'); await stopped; } await launchServer(); -const libre = process.env.CHAT_LIBREJS === '1'; -let persistent; -let browser; +const libre = process.env['CHAT_LIBREJS'] === '1'; +let persistent: BrowserContext | undefined; +let browser: Browser | { close(): Promise<void> }; if (libre) { const profile = join(testHome,'firefox-profile'); await mkdir(join(profile,'extensions'),{recursive:true}); @@ -124,63 +128,64 @@ if (libre) { 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, }}); - browser = { close: () => persistent.close() }; + browser = { close: () => required(persistent, 'Missing Firefox context').close() }; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); } const deadline = setTimeout(() => browser.close(), 300000); -const contexts = []; -const external = []; -const errors = []; -const bodies = []; +const contexts: BrowserContext[] = []; +const external: string[] = []; +const errors: string[] = []; +const bodies: ReturnType<typeof requestBody>[] = []; async function page() { - const context = persistent || await browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}); + const context = required(persistent || await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); const navigate = page.goto.bind(page); - page.goto = async (...args) => { + page.goto = async (...args: Parameters<Page['goto']>) => { for (let attempt = 0; ; attempt += 1) { try { return await navigate(...args); } - catch (error) { if (attempt >= 2 || !error.message.includes('ERR_NETWORK_CHANGED')) throw error; } + catch (error) { if (attempt >= 2 || !(error instanceof Error && error.message.includes('ERR_NETWORK_CHANGED'))) throw error; } } }; page.on('request', request => { if (!request.url().startsWith(origin + '/')) external.push(request.url()); - if (request.url() === origin + '/api') bodies.push(JSON.parse(request.postData())); + if (request.url() === origin + '/api') bodies.push(requestBody(required(request.postData(), 'Missing API body'))); }); return page; } -async function waitEntry(page) { await page.locator('#create-choice').waitFor({state:'visible'}); } -async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } -async function create(page, name, owner = false) { +async function waitEntry(page: Page) { await page.locator('#create-choice').waitFor({state:'visible'}); } +async function waitStatus(page: Page, text: string) { await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value), text); } +async function create(page: Page, name: string, owner = false) { const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length; if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); await page.fill('#name', name); await page.fill('#new-password', password); await page.fill('#confirm-password', password); - const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status').textContent)).then(async () => { throw new Error(await page.locator('#status').textContent()); })]); + const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status')?.textContent || '')).then(async () => { throw new Error(await page.locator('#status').textContent() || 'Missing status'); })]); await page.click('#create-form button'); const download = await downloaded; - const bytes = await readFile(await download.path()); - const envelope = JSON.parse(bytes.toString()); - assert.ok(envelope.format.startsWith('stack-chat-file/v2;')); + const bytes = await readFile(required(await download.path(), 'Missing download')); + const envelope = record(JSON.parse(bytes.toString())); + const ciphertext = list(envelope['ciphertext']).map(string); + assert.ok(string(envelope['format']).startsWith('stack-chat-file/v2;')); assert.ok(bytes.toString().startsWith('{\n "format"')); - assert.ok(Array.isArray(envelope.ciphertext)); - assert.ok(envelope.ciphertext.length > 1); - assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); - assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80); + assert.ok(Array.isArray(ciphertext)); + assert.ok(ciphertext.length > 1); + assert.ok(ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); + assert.ok(required(ciphertext.at(-1), 'Missing final chunk').length > 0 && required(ciphertext.at(-1), 'Missing final chunk').length <= 80); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; } -async function unlock(page, file, phrase = password) { +async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password) { await page.setInputFiles('#file', file); await page.fill('#password', phrase); await page.click('#unlock-form button'); } -async function storageEmpty(page) { +async function storageEmpty(page: Page) { assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); @@ -196,10 +201,10 @@ async function verifyLibreJS() { } else if (request.url === '/executed') { executed = true; response.end('unexpected'); } else { response.setHeader('Content-Type','text/html'); response.end('<!doctype html><script src="/control.js"></script><p>Unlicensed external-script rejection control.</p>'); } }); - await new Promise(done => controlServer.listen(0,'127.0.0.1',done)); - const controlPage = await persistent.newPage(); + await new Promise<void>(done => controlServer.listen(0,'127.0.0.1',done)); + const controlPage = await required(persistent, 'Missing Firefox context').newPage(); try { - await controlPage.goto(`http://127.0.0.1:${controlServer.address().port}/`); + await controlPage.goto(serverOrigin(controlServer) + '/'); await controlPage.waitForTimeout(1500); assert.equal(await controlPage.evaluate(() => window.unlicensedControl),undefined,'LibreJS must reject the unlicensed control'); assert.equal(executed,false); @@ -207,14 +212,21 @@ async function verifyLibreJS() { } try { if (libre) await verifyLibreJS(); + const malformed = await page(); + await malformed.route('**/api', route => route.fulfill({ json: { owner: 42, origin, protocol: 'stack-chat/v1' } })); + await malformed.goto(origin); + await waitStatus(malformed, 'Invalid owner configuration'); + assert.equal(await malformed.locator('#create-choice').isVisible(), false); + await storageEmpty(malformed); + await malformed.close(); const owner = await page(); const response = await owner.goto(origin); await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); await owner.click('#owner-choice'); - assert.match(response.headers()['cache-control'], /no-store/); - assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); + assert.match(required(response, 'Missing response').headers()['cache-control'] || '', /no-store/); + assert.match(required(response, 'Missing response').headers()['content-security-policy'] || '', /frame-ancestors 'none'/); await owner.locator('#setup-token').waitFor({state:'visible'}); - assert.match(await owner.locator('#create-title').textContent(),/owner only/); + assert.match(await owner.locator('#create-title').textContent() || '',/owner only/); assert.equal(await owner.locator('.introduction h1').count(),0); assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1); assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0); @@ -222,7 +234,7 @@ try { const ownerFile = await create(owner,'Doan',true); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent(), /OWNER/); + assert.match(await owner.locator('#who').textContent() || '', /OWNER/); const visitor = await page(); await visitor.goto(origin); await waitEntry(visitor); @@ -234,7 +246,7 @@ try { await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.equal(await visitor.locator('#messages li').count(),0); - assert.match(await visitor.locator('#counts').textContent(),/Total: 0/); + assert.match(await visitor.locator('#counts').textContent() || '',/Total: 0/); const malicious = '<script>window.chatInjection = true</script> hello λ'; await visitor.fill('#text',malicious); await visitor.click('#send-form button'); @@ -242,8 +254,20 @@ try { await owner.click('#refresh-inbox'); await owner.locator('#conversations button').first().click(); await waitStatus(owner,'Conversation opened'); - assert.match(await owner.locator('#thread-name').textContent(),/A friend/); + assert.match(await owner.locator('#thread-name').textContent() || '',/A friend/); assert.equal(await owner.locator('#messages li').count(),0); + await owner.route('**/api', async route => { + const body = requestBody(required(route.request().postData(), 'Missing API body')); + if (body.op === 'history') { + const response = record(await (await route.fetch()).json()); + await route.fulfill({ json: { ...response, conversation: { ...record(response['conversation']), total: '1' } } }); + } else await route.continue(); + }); + await owner.fill('#history-count','1'); + await owner.click('#history-form button'); + await waitStatus(owner, 'Invalid response count: total'); + assert.equal(await owner.locator('#messages li').count(),0); + await owner.unroute('**/api'); await owner.fill('#history-count','1'); await owner.click('#history-form button'); await waitStatus(owner,'Selected history decrypted'); @@ -256,20 +280,20 @@ try { await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); - assert.match(await visitor.locator('#counts').textContent(),/Total: 2 \| Sent: 1 \| Received: 1/); + assert.match(await visitor.locator('#counts').textContent() || '',/Total: 2 \| Sent: 1 \| Received: 1/); let interrupted = false; await visitor.route('**/api',async route => { - const body = route.request().postDataJSON(); + const body = requestBody(required(route.request().postData(), 'Missing API request')); if (body.op === 'send' && !interrupted) { interrupted = true; await route.fetch(); await route.abort(); } else await route.continue(); }); await visitor.fill('#text','Safe retry after a lost response'); await visitor.click('#send-form button'); await visitor.locator('#retry').waitFor({state:'visible'}); - await visitor.waitForFunction(() => !document.getElementById('retry').disabled); + await visitor.waitForFunction(() => !document.querySelector<HTMLButtonElement>('#retry')?.disabled); await visitor.click('#retry'); await waitStatus(visitor,'Encrypted message stored'); - assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); await visitor.reload(); await waitEntry(visitor); assert.equal(await visitor.locator('#messages li').count(),0); @@ -278,12 +302,12 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.fill('#history-count','1'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); - assert.match(await visitor.locator('#messages p').textContent(),/Safe retry/); + assert.match(await visitor.locator('#messages p').textContent() || '',/Safe retry/); await visitor.click('#older'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); @@ -305,7 +329,7 @@ try { await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); - assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); + assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); @@ -318,7 +342,7 @@ try { await owner.click('#owner-open'); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); - assert.match(await owner.locator('#who').textContent(),/OWNER/); + assert.match(await owner.locator('#who').textContent() || '',/OWNER/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await visitor.click('#lock'); @@ -326,7 +350,7 @@ try { assert.equal(await visitor.locator('#messages li').count(),0); await visitor.goto(origin + '/LIMITATIONS.txt'); await visitor.goBack(); - await visitor.waitForFunction(() => !document.getElementById('entry').hidden); + await visitor.waitForFunction(() => document.getElementById('entry')?.hidden === false); assert.equal(await visitor.locator('#messages li').count(),0); assert.equal(await visitor.locator('#password').inputValue(),''); assert.equal(await visitor.locator('#chat').isVisible(),false); @@ -368,7 +392,7 @@ try { } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); - const administrative = (...args) => packaged + const administrative = (...args: string[]) => packaged ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); @@ -402,14 +426,13 @@ try { await waitStatus(visitor,'Pinned owner key differs'); assert.equal(await visitor.locator('#chat').isVisible(),false); assert.equal(serverErrors,''); - if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); + if (process.env['CHAT_SCREENSHOT']) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { - const extensions = JSON.parse(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); - const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); + const addon = libreJSAddon(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); assert.ok(addon?.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); } - console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); + console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, malformed configuration and history rejection, no browser storage or external requests, no private uploads or database plaintext.'); } finally { clearTimeout(deadline); await browser.close(); diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.ts index 0ee5614..59c939d 100644 --- a/crates/chat-server/tests/progress.mjs +++ b/crates/chat-server/tests/progress.ts @@ -14,22 +14,23 @@ // along with this program. If not, see <https://www.gnu.org/licenses/>. import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; +import { createServer, type ServerResponse } from 'node:http'; import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; +import { chromium, firefox, type BrowserContext, type Page } from 'playwright'; +import { libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; -const libre = process.env.CHAT_LIBREJS === '1'; +const libre = process.env['CHAT_LIBREJS'] === '1'; const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); let mode = 'known'; -let transfer; -let configuration; -let configReady; -let signalConfig; -let origin; -const errors = []; +let transfer: ServerResponse | undefined; +let configuration: ServerResponse | undefined; +let configReady: Promise<void> | undefined; +let signalConfig: (() => void) | undefined; +let origin = ''; +const errors: string[] = []; const server = createServer(async (request,response) => { response.setHeader('Cache-Control','no-store'); if (request.url === '/crypto.wasm') { @@ -40,20 +41,20 @@ const server = createServer(async (request,response) => { response.write(bytes.subarray(0,1048576)); return; } - if (request.url === '/api') { configuration = response; signalConfig(); return; } - const assets = { '/':'crates/chat-server/web/index.html','/app.js':'crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; - const path = assets[request.url]; + if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; } + const assets: Record<string, string> = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; + const path = assets[request.url || '']; if (!path) { response.writeHead(404); response.end(); return; } - const mime = request.url === '/' ? 'text/html' : request.url.endsWith('.js') ? 'text/javascript' : 'text/css'; + const mime = request.url === '/' ? 'text/html' : request.url?.endsWith('.js') ? 'text/javascript' : 'text/css'; response.setHeader('Content-Type',mime); let source = await readFile(resolve(path),'utf8'); if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); response.end(source); }); -await new Promise(done => server.listen(0,'127.0.0.1',done)); -origin = `http://127.0.0.1:${server.address().port}`; +await new Promise<void>(done => server.listen(0,'127.0.0.1',done)); +origin = serverOrigin(server); let browser; -let context; +let context: BrowserContext; if (libre) { await mkdir(join(profile,'extensions')); const addon = await readFile(resolve('.tools/librejs.xpi')); @@ -64,7 +65,7 @@ if (libre) { browser = await chromium.launch({headless:true,channel:'chromium'}); context = await browser.newContext(); } -async function open(selected) { +async function open(selected: 'known' | 'unknown' | 'failure') { mode = selected; configuration = undefined; configReady = new Promise(done => { signalConfig = done; }); @@ -74,16 +75,16 @@ async function open(selected) { await page.goto(origin); return page; } -async function waitStatus(page,part) { - await page.waitForFunction(value => document.getElementById('status').textContent.includes(value),part); - return page.locator('#status').textContent(); +async function waitStatus(page: Page, part: string) { + await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value),part); + return await page.locator('#status').textContent() || ''; } -async function complete(page,offset) { - transfer.end(bytes.subarray(offset)); +async function complete(page: Page, offset: number) { + required(transfer, 'Missing transfer').end(bytes.subarray(offset)); await waitStatus(page,'Connecting to chat'); await configReady; - configuration.setHeader('Content-Type','application/json'); - configuration.end(JSON.stringify({origin,owner:''})); + required(configuration, 'Missing configuration').setHeader('Content-Type','application/json'); + required(configuration, 'Missing configuration').end(JSON.stringify({origin,owner:'',protocol:'stack-chat/v1'})); await page.locator('#owner-choice').waitFor({state:'visible'}); assert.equal(await page.locator('#status').textContent(),''); assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); @@ -93,11 +94,11 @@ try { const known = await open('known'); assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); await known.clock.fastForward(44000); - transfer.write(bytes.subarray(1048576,2097152)); + required(transfer, 'Missing transfer').write(bytes.subarray(1048576,2097152)); assert.match(await waitStatus(known,'2.0 /'),/39%/); await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); await known.clock.fastForward(44000); - assert.ok(!(await known.locator('#status').textContent()).includes('stalled')); + assert.ok(!(await known.locator('#status').textContent() || '').includes('stalled')); await complete(known,2097152); const unknown = await open('unknown'); assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); @@ -110,13 +111,12 @@ try { await waitStatus(stalled,'1.0 /'); await stalled.clock.fastForward(45001); await waitStatus(stalled,'stalled for 45 seconds'); - assert.match(await stalled.locator('#status').textContent(),/reload/); + assert.match(await stalled.locator('#status').textContent() || '',/reload/); assert.equal(await stalled.locator('#owner-choice').isVisible(),false); await stalled.close(); assert.deepEqual(errors,[]); if (libre) { - const state = JSON.parse(await readFile(join(profile,'extensions.json'),'utf8')); - assert.ok(state.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack')?.active); + assert.ok(libreJSAddon(await readFile(join(profile,'extensions.json'),'utf8')).active); } console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); } finally { 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 diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts new file mode 100644 index 0000000..79a827e --- /dev/null +++ b/crates/chat-server/web/app.ts @@ -0,0 +1,560 @@ +// 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 + +function element(id: string): HTMLElement { + const value = document.getElementById(id); + if (!value) throw new Error(`Missing interface element: ${id}`); + return value; +} + +function input(id: string): HTMLInputElement { + const value = element(id); + if (!(value instanceof HTMLInputElement)) throw new Error(`Expected input: ${id}`); + return value; +} + +function form(id: string): HTMLFormElement { + const value = element(id); + if (!(value instanceof HTMLFormElement)) throw new Error(`Expected form: ${id}`); + return value; +} + +function messageInput(): HTMLTextAreaElement { + const value = element('text'); + if (!(value instanceof HTMLTextAreaElement)) throw new Error('Missing message input'); + return value; +} + +const show = (id: string, visible = true): void => { element(id).hidden = !visible; }; +const setStatus = (message: string): void => { element('status').textContent = message; }; +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Unexpected operation failure'; +let config: StackChat.Configuration | undefined; +let wasm: Promise<ArrayBuffer> | undefined; +let credentials: StackChat.Credentials | undefined; +let session: StackChat.Session | undefined; +let selected: StackChat.Conversation | undefined; +let before = 0; +let pendingFile: string | undefined; +let setupToken: string | undefined; +let pendingSend: StackChat.SendRequest | undefined; +let generation = 0; +let busy = false; +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'); + return config; +} + +function ownerKey(): string { + const owner = configuration().owner; + if (!owner) throw new Error('Owner setup is required first'); + return owner; +} + +function activeSession(): StackChat.Session { + if (!session) throw new Error('Unlock your identity file first'); + return session; +} + +function conversation(): StackChat.Conversation { + if (!selected) throw new Error('Choose a conversation first'); + return selected; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function record(value: unknown): Record<string, unknown> { + if (!isRecord(value)) throw new Error('Invalid response object'); + return value; +} + +function stringField(value: Record<string, unknown>, key: string): string { + const field = value[key]; + if (typeof field !== 'string') throw new Error(`Invalid response field: ${key}`); + return field; +} + +function countField(value: Record<string, unknown>, key: string): number { + const field = value[key]; + if (typeof field !== 'number' || !Number.isSafeInteger(field) || field < 0) throw new Error(`Invalid response count: ${key}`); + return field; +} + +function booleanField(value: Record<string, unknown>, key: string): boolean { + const field = value[key]; + if (typeof field !== 'boolean') throw new Error(`Invalid response flag: ${key}`); + return field; +} + +function arrayField(value: Record<string, unknown>, key: string): unknown[] { + const field: unknown = value[key]; + if (!Array.isArray(field)) throw new Error(`Invalid response list: ${key}`); + return field; +} + +function decodeConversation(value: unknown): StackChat.Conversation { + const item = record(value); + const total = countField(item, 'total'); + const visitor = countField(item, 'visitor_sent'); + const owner = countField(item, 'owner_sent'); + if (total > 10000 || visitor + owner !== total) throw new Error('Invalid conversation count'); + const profile = item['profile']; + if (profile !== undefined && profile !== null && typeof profile !== 'string') throw new Error('Invalid conversation profile'); + return { id: stringField(item, 'id'), public: stringField(item, 'public'), total, visitor_sent: visitor, owner_sent: owner, ...(profile === undefined ? {} : { profile }) }; +} + +function decodeMessage(value: unknown): StackChat.Message { + const item = record(value); + return { seq: countField(item, 'seq'), id: stringField(item, 'id'), sender: stringField(item, 'sender'), ciphertext: stringField(item, 'ciphertext') }; +} + +function decodeDecryption(value: unknown): StackChat.Decryption { + const item = record(value); + return { signer: stringField(item, 'signer'), payload: stringField(item, 'payload') }; +} + +const apiReaders: { [K in keyof StackChat.Api]: (value: unknown) => StackChat.Api[K]['response'] } = { + config(value) { + const item = record(value); + const owner = item['owner']; + if (owner !== null && typeof owner !== 'string') throw new Error('Invalid owner configuration'); + return { owner, origin: stringField(item, 'origin'), protocol: stringField(item, 'protocol') }; + }, + challenge(value) { const item = record(value); return { id: stringField(item, 'id'), challenge: stringField(item, 'challenge') }; }, + authenticate(value) { const item = record(value); return { token: stringField(item, 'token'), id: stringField(item, 'id'), owner: booleanField(item, 'owner'), expires_seconds: countField(item, 'expires_seconds') }; }, + profile(value) { return { stored: booleanField(record(value), 'stored') }; }, + inbox(value) { return { conversations: arrayField(record(value), 'conversations').map(decodeConversation) }; }, + history(value) { const item = record(value); return { conversation: decodeConversation(item['conversation']), messages: arrayField(item, 'messages').map(decodeMessage) }; }, + send(value) { const item = record(value); return { seq: countField(item, 'seq'), conversation: decodeConversation(item['conversation']) }; }, + lock(value) { return { locked: booleanField(record(value), 'locked') }; }, +}; + +const cryptoReaders: { [K in keyof StackChat.Crypto]: (value: unknown) => StackChat.Crypto[K]['response'] } = { + create(value) { return { file: stringField(record(value), 'file') }; }, + open(value) { const item = record(value); return { public: stringField(item, 'public'), fingerprint: stringField(item, 'fingerprint'), owner: stringField(item, 'owner'), name: stringField(item, 'name') }; }, + proof(value) { return { signature: stringField(record(value), 'signature') }; }, + encrypt(value) { return { ciphertext: stringField(record(value), 'ciphertext') }; }, + decrypt: decodeDecryption, + decrypt_batch(value) { + return { messages: arrayField(record(value), 'messages').map(entry => { + const item = record(entry); + return 'error' in item ? { error: stringField(item, 'error') } : decodeDecryption(item); + }) }; + }, +}; + +async function api<K extends keyof StackChat.Api>(request: StackChat.Api[K]['request'] & { op: K }): Promise<StackChat.Api[K]['response']> { + 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 = 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'); + 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'); + 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 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>) => { + 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); } + }; + 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'); + 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; + input('history-count').max = '0'; + element('history-range').textContent = 'Latest messages (0-0)'; + for (const input of document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('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'); + 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 => { + event.preventDefault(); + if (busy) return; + busy = true; + setStatus('Working locally...'); + const current = generation; + for (const button of document.querySelectorAll<HTMLButtonElement>('button:not(#lock)')) button.disabled = true; + try { await callback(); } + catch (error) { if (generation === current) setStatus(errorMessage(error)); } + finally { + if (generation === current) { + busy = false; + for (const button of document.querySelectorAll('button')) button.disabled = false; + } + } + }); +} + +function download(file: string): void { + 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 = 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; + 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 || '' }); + pendingFile = result.file; + download(result.file); + form('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.'; + setStatus('Reopen your downloaded identity file to verify recovery.'); +} + +async function unlockIdentity() { + const file = input('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: 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.'); + 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} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`; + element('fingerprint').textContent = `Identity: ${identity.fingerprint}`; + if (authenticated.owner) { + activeSession().owner = true; + show('inbox'); + await inbox(); + } else { + activeSession().owner = false; + const profile = await cryptoOperation(protectRequest('encrypt', { recipient: ownerKey(), payload: JSON.stringify({ v: 1, kind: 'profile', origin: location.origin, conversation: activeSession().id, sender: activeSession().id, name: identity.name }) })); + await api({ op: 'profile', conversation: activeSession().id, ciphertext: profile.ciphertext }); + selected = { id: activeSession().id, public: identity.public, total: 0, visitor_sent: 0, owner_sent: 0 }; + element('conversation-fingerprint').textContent = `Conversation: ${activeSession().id}`; + show('thread'); + element('thread-name').textContent = 'You <-> Doan'; + await loadHistory(0); + } + setStatus('Unlocked in memory only. No history loaded automatically. Lock or leave to forget this session.'); +} + +function checkedPayload(payload: string, kind: 'profile', conversation: string, sender: string): { name: string }; +function checkedPayload(payload: string, kind: 'message', conversation: string, sender: string): { id: string; text: string }; +function checkedPayload(payload: string, kind: 'profile' | 'message', conversation: string, sender: string): { name: string } | { id: string; text: string } { + const value = record(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'); + if (kind === 'profile') return { name: stringField(value, 'name') }; + return { id: stringField(value, 'id'), text: stringField(value, 'text') }; +} + +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) { setStatus('Retry the pending message, or lock before switching conversations.'); return; } + busy = true; + const current = generation; + setStatus('Opening conversation...'); + try { + selected = conversation; + element('conversation-fingerprint').textContent = `Conversation: ${conversation.id}`; + element('thread-name').textContent = `Doan <-> ${conversation.id.slice(0, 12)}`; + pendingSend = undefined; + messageInput().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); + setStatus('Conversation opened. Choose how many messages to load.'); + } catch (error) { setStatus(errorMessage(error)); } + finally { if (generation === current) busy = false; } + }); + element('conversations').append(button); + } +} + +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})`; + 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}`; +} + +async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> { + const requests = messages.map(message => ({ sender: message.sender === conversation().id ? conversation().public : ownerKey(), 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]; + if (!message) 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'}`; + 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.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'); + 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 conversationId = conversation().id; + const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 }); + if (selected?.id === conversationId) counters(result.conversation); + return result.conversation; +} + +async function loadHistory(count: number, cursor = 0): Promise<void> { + if (!selected) 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 (count === 0) { + input('history-count').value = '0'; + show('older', false); + setStatus('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: 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; } + 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})...`); + 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(input('history-count').max)); + setStatus('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; + messageInput().value = ''; + show('retry', false); + 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'); + 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'); + 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...'); + const result = await cryptoOperation(protectRequest('encrypt', { recipient, payload: JSON.stringify({ v: 1, kind: 'message', origin: location.origin, conversation: conversation().id, sender: activeSession().id, id, text }) })); + pendingSend = { op: 'send', conversation: conversation().id, id, ciphertext: result.ciphertext }; + show('retry'); + await submitPending(); +} + +function ownerSetup() { + 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); + 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-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)); }); +}); +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(); }); +window.addEventListener('pagehide', clearState); +window.addEventListener('pageshow', event => { if (event.persisted) clearState(); }); + +async function downloadCrypto() { + const controller = new AbortController(); + let timer: ReturnType<typeof setTimeout> | undefined; + let reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>> | undefined; + 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: Uint8Array<ArrayBuffer>[] = []; + 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); + setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${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'); + 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; + 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)); + setStatus(''); + if (location.hash === '#setup') ownerSetup(); +} + +start().catch(error => setStatus(errorMessage(error))); + +// @license-end diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html index 644e5a1..b39504c 100644 --- a/crates/chat-server/web/javascript.html +++ b/crates/chat-server/web/javascript.html @@ -23,6 +23,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. <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></div> +<p>The readable JavaScript is compiled from the included TypeScript sources. The source archive includes shared protocol declarations, compiler configurations, the npm lock, and build/test scripts. No third-party JavaScript runs in the browser. Node, npm, and the pinned TypeScript compiler are development tools; the production server serves prebuilt scripts.</p> <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="{{SITE_ORIGIN}}/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/worker.js b/crates/chat-server/web/worker.ts index 64e0795..d4367bf 100644 --- a/crates/chat-server/web/worker.js +++ b/crates/chat-server/web/worker.ts @@ -17,7 +17,7 @@ importScripts('/wasi.mjs'); -self.onmessage = async event => { +self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>) => { try { const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request); self.postMessage({ result }); |
