summaryrefslogtreecommitdiff
path: root/crates/chat-server
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server')
-rw-r--r--crates/chat-server/src/http.rs13
-rw-r--r--crates/chat-server/tests/progress.mjs127
-rw-r--r--crates/chat-server/web/app.js54
-rw-r--r--crates/chat-server/web/index.html2
-rw-r--r--crates/chat-server/web/javascript.html2
5 files changed, 188 insertions, 10 deletions
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/http.rs
index 324c593..7394b71 100644
--- a/crates/chat-server/src/http.rs
+++ b/crates/chat-server/src/http.rs
@@ -20,7 +20,7 @@ use axum::{
body::Body,
extract::{DefaultBodyLimit, State},
http::{HeaderMap, HeaderValue, StatusCode},
- response::{IntoResponse, Response},
+ response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde_json::{Value, json};
@@ -94,6 +94,14 @@ 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(),
+ );
+ }
let (path, mime) = match uri.path() {
"/" => (
"crates/chat-server/web/index.html",
@@ -135,9 +143,6 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response {
},
"application/gzip",
),
- "/THIRD-PARTY-LICENSES.html" => {
- ("web/THIRD-PARTY-LICENSES.html", "text/html; charset=utf-8")
- }
"/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()),
diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.mjs
new file mode 100644
index 0000000..0ee5614
--- /dev/null
+++ b/crates/chat-server/tests/progress.mjs
@@ -0,0 +1,127 @@
+// 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/>.
+
+import assert from 'node:assert/strict';
+import { createServer } 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';
+
+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 = [];
+const server = createServer(async (request,response) => {
+ response.setHeader('Cache-Control','no-store');
+ if (request.url === '/crypto.wasm') {
+ if (mode === 'failure') { response.writeHead(503); response.end(); return; }
+ response.setHeader('Content-Type','application/wasm');
+ if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length);
+ transfer = 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 (!path) { response.writeHead(404); response.end(); return; }
+ 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}`;
+let browser;
+let context;
+if (libre) {
+ await mkdir(join(profile,'extensions'));
+ const addon = await readFile(resolve('.tools/librejs.xpi'));
+ await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon);
+ context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}});
+ browser = {close:() => context.close()};
+} else {
+ browser = await chromium.launch({headless:true,channel:'chromium'});
+ context = await browser.newContext();
+}
+async function open(selected) {
+ mode = selected;
+ configuration = undefined;
+ configReady = new Promise(done => { signalConfig = done; });
+ const page = await context.newPage();
+ page.on('pageerror',error => errors.push(error.message));
+ await page.clock.install();
+ 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 complete(page,offset) {
+ 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:''}));
+ 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);
+ await page.close();
+}
+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));
+ 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'));
+ 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');
+ await complete(unknown,1048576);
+ const failure = await open('failure');
+ await waitStatus(failure,'HTTP 503');
+ assert.equal(await failure.locator('#owner-choice').isVisible(),false);
+ await failure.close();
+ const stalled = await open('known');
+ 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.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);
+ }
+ 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 {
+ await browser.close();
+ server.closeAllConnections();
+ await new Promise(done => server.close(done));
+ await rm(profile,{recursive:true,force:true});
+}
diff --git a/crates/chat-server/web/app.js b/crates/chat-server/web/app.js
index 3c97f66..4b0da63 100644
--- a/crates/chat-server/web/app.js
+++ b/crates/chat-server/web/app.js
@@ -356,12 +356,58 @@ element('lock').addEventListener('click', () => { if (session) api({ op: 'lock'
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 = fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit' }).then(response => {
- if (!response.ok) throw new Error('Build the WASI cryptographic module first');
- return response.arrayBuffer();
- });
+ 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');
diff --git a/crates/chat-server/web/index.html b/crates/chat-server/web/index.html
index b2a4363..70f3ebb 100644
--- a/crates/chat-server/web/index.html
+++ b/crates/chat-server/web/index.html
@@ -100,7 +100,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<noscript>This chat requires free JavaScript and WebAssembly for local cryptography. The main website remains JavaScript-free.</noscript>
<footer class="source-footer" aria-label="Licensing and source">
<a class="project-license" href="https://www.gnu.org/licenses/agpl-3.0.html">[ LICENSE ]</a>
-<a class="dependency-licenses" href="/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
+<a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
<a href="/SOURCE.tar.gz">[ SOURCE CODE ]</a>
</footer>
</main>
diff --git a/crates/chat-server/web/javascript.html b/crates/chat-server/web/javascript.html
index 205a103..644e5a1 100644
--- a/crates/chat-server/web/javascript.html
+++ b/crates/chat-server/web/javascript.html
@@ -24,5 +24,5 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<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 WebAssembly module is compiled from the Rust workspace and unmodified OpenSSL 3.6.5, under their respective free software licenses. The source archive includes first-party sources, Cargo dependencies, the OpenSSL source archive, build scripts, and notices. General-purpose Rust and WASI SDK tools are documented with verified download locations in CHAT-OPENSSL-MIGRATION.txt.</p>
-<p><a href="/THIRD-PARTY-LICENSES.html">Third-party licenses</a> / <a href="/SOURCE.tar.gz">Source archive</a> / <a href="/">Return</a></p>
+<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>