summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--docs/CHAT-APPLICATION.txt13
-rw-r--r--docs/OPERATIONS.txt19
-rw-r--r--infra/containers/cgit/cgitrc2
-rw-r--r--infra/containers/cgit/license.lua47
-rw-r--r--infra/containers/chat/Containerfile1
-rw-r--r--infra/services.toml1
-rw-r--r--src/render.rs1
-rw-r--r--web/index.html6
-rw-r--r--web/message/index.html2
-rw-r--r--web/styles.css42
15 files changed, 258 insertions, 74 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>
diff --git a/docs/CHAT-APPLICATION.txt b/docs/CHAT-APPLICATION.txt
index ea817a5..6a25776 100644
--- a/docs/CHAT-APPLICATION.txt
+++ b/docs/CHAT-APPLICATION.txt
@@ -29,6 +29,13 @@ WHAT EXISTS
a capability-limited WASI host. Text is displayed through textContent.
SearXNG and the other existing services are unchanged.
+ Startup shows an ASCII download bar, percentage, and received/total MiB
+ while fetching the cryptographic module. If its size is unavailable, it shows
+ received MiB. A 45-second gap without download progress aborts with a reload
+ instruction; slow transfers continue while bytes arrive. After download,
+ a separate status identifies the connection to chat. No browser cache or
+ storage was added.
+
Visitors choose CREATE NEW CHAT or OPEN CHAT FILE. Creation generates a
purpose-specific identity locally and downloads its protected file. Reopening
the exact downloaded file and entering its password is required before the
@@ -342,6 +349,11 @@ LICENSES, SOURCE, AND VERIFICATION
The local check used Alpine musl 1.2.6 headers with the host C compiler for
the C object build and Rust's self-contained musl runtime for final linking.
+ The rendered dependency-license page is centralized on the main website at
+ https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html. Chat links point there; its
+ previous local license-page URL redirects there. cgit keeps ordinary source
+ and raw-file views without a custom HTML renderer.
+
First-party software is GNU AGPL version 3 or later. Legal license headers
are the exception to the no-comments convention. Documentation is plaintext.
JavaScript has GNU LibreJS @license/@license-end markers and a linked
@@ -371,6 +383,7 @@ LICENSES, SOURCE, AND VERIFICATION
cargo stack check
PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-crypto/tests/browser.mjs
PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/browser.mjs
+ PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/browsers" node crates/chat-server/tests/progress.mjs
Browser automation additionally needs Node, Playwright under .tools/browser,
and its browser downloads under .tools/browsers. These are development tools
diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt
index a2534b6..5b1da25 100644
--- a/docs/OPERATIONS.txt
+++ b/docs/OPERATIONS.txt
@@ -338,11 +338,14 @@ RECOVERY LIMITS AND SECRET ROTATION
The current command intentionally accepts only its verified format-2
archives. Fresh format-2 production backups replace the normal workflow.
-LICENSE PAGE IN CGIT
-
- The stack repository's About tab renders only
- web/THIRD-PARTY-LICENSES.html. Source and Raw links retain the normal cgit
- views and preserve revision queries. Other files are not rendered as HTML.
- The rendered document is isolated in a sandboxed frame, with scripts and
- external resources blocked. The standalone website license page remains
- available at /THIRD-PARTY-LICENSES.html.
+CENTRAL THIRD-PARTY LICENSE PAGE
+
+ cargo-about generates web/THIRD-PARTY-LICENSES.html. Caddy serves the rendered
+ document at https://doan.luuduct.ai/THIRD-PARTY-LICENSES.html, using the
+ configured SITE_HOST for other deployments. Website and chat license links
+ use that same canonical page. Local previews use the local main-site origin.
+
+ cgit no longer has a custom license renderer, Lua filter, or license About
+ tab. The generated file remains accessible through its normal Source and Raw
+ views in the repository. The old chat /THIRD-PARTY-LICENSES.html URL redirects
+ to the canonical site page. Source exports retain license texts for offline use.
diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc
index 0dacb99..fb9cae8 100644
--- a/infra/containers/cgit/cgitrc
+++ b/infra/containers/cgit/cgitrc
@@ -24,6 +24,4 @@ defbranch=main
cache-root=/var/cache/cgit
cache-size=1000
cache-scanrc-ttl=1
-readme=:web/THIRD-PARTY-LICENSES.html
-about-filter=lua:/etc/cgit-license.lua
scan-path=/srv/git
diff --git a/infra/containers/cgit/license.lua b/infra/containers/cgit/license.lua
deleted file mode 100644
index ff5685f..0000000
--- a/infra/containers/cgit/license.lua
+++ /dev/null
@@ -1,47 +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/>.
-
-local chunks = {}
-local rendered = false
-
-function filter_open(filename)
- chunks = {}
- rendered = filename == "web/THIRD-PARTY-LICENSES.html"
-end
-
-function filter_write(buffer)
- if rendered then
- chunks[#chunks + 1] = buffer
- else
- html_txt(buffer)
- end
-end
-
-function filter_close()
- if rendered then
- local query = os.getenv("QUERY_STRING") or ""
- local suffix = query ~= "" and ("?" .. query) or ""
- html('<nav aria-label="License views"><strong>Rendered</strong> | <a href="')
- html_attr("../tree/web/THIRD-PARTY-LICENSES.html" .. suffix)
- html('">Source</a> | <a href="')
- html_attr("../plain/web/THIRD-PARTY-LICENSES.html" .. suffix)
- html('">Raw</a></nav>')
- html('<iframe title="Third-party licenses" sandbox="" referrerpolicy="no-referrer" style="width:100%;height:80vh;border:0" srcdoc="')
- html_attr([[<!doctype html><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'">]])
- html_attr(table.concat(chunks))
- html('"></iframe>')
- end
- return 0
-end
diff --git a/infra/containers/chat/Containerfile b/infra/containers/chat/Containerfile
index ff97f05..a63683e 100644
--- a/infra/containers/chat/Containerfile
+++ b/infra/containers/chat/Containerfile
@@ -19,7 +19,6 @@ COPY crypto.wasm /app/bin/chat/crypto.wasm
COPY SOURCE.tar.gz /app/bin/chat/SOURCE.tar.gz
COPY web /app/crates/chat-server/web
COPY wasi.mjs /app/crates/chat-crypto/browser/wasi.mjs
-COPY THIRD-PARTY-LICENSES.html /app/web/THIRD-PARTY-LICENSES.html
COPY CHAT-APPLICATION.txt /app/docs/CHAT-APPLICATION.txt
COPY CHAT-PRODUCTION.txt /app/docs/CHAT-PRODUCTION.txt
COPY LICENSE /app/LICENSE
diff --git a/infra/services.toml b/infra/services.toml
index 09abfc5..7fea853 100644
--- a/infra/services.toml
+++ b/infra/services.toml
@@ -33,7 +33,6 @@ health = ["curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:808
mounts = [
{ source = "var/data/repos", target = "/srv/git", read_only = true },
{ source = "@cgit", target = "/etc/cgitrc", read_only = true },
- { source = "infra/containers/cgit/license.lua", target = "/etc/cgit-license.lua", read_only = true },
]
tmpfs = ["/run", "/var/cache/cgit"]
diff --git a/src/render.rs b/src/render.rs
index c141405..5171753 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -99,7 +99,6 @@ mod tests {
let homepage = fs::read_to_string(output.join("web/index.html"))?;
assert!(homepage.contains("https://git.example.net:8443"));
assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"));
- assert!(homepage.contains("A home-grown experimental messenger. Know its"));
settings.mode = Mode::Local;
settings.port = 8180;
prepare(&project, &settings)?;
diff --git a/web/index.html b/web/index.html
index 82cdc18..825e48e 100644
--- a/web/index.html
+++ b/web/index.html
@@ -51,7 +51,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
| <a href="{{CHAT_PUBLIC_ORIGIN}}/">Message Me</a> |
| |
| Email: <a href="mailto:doan@luuduct.ai">doan@luuduct.ai</a> |
-| GnuPG: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> |
+| OpenPGP: <a href="{{WKD_ORIGIN}}/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp?l=doan">3346AA3150F8F16828BB0D14F8C66DF77AFECEA6</a> |
| |
| <a href="https://smp11.simplex.im/a#ikrTZ8sfleSZEifKV2wI8foAVwkbKfOoKc6TO2P4ZT8">SimpleX Chat</a> |
| |
@@ -77,14 +77,14 @@ ________________________________________________________________________________
<a href="{{CHAT_PUBLIC_ORIGIN}}/">{{CHAT_HOST}}</a>
-* A home-grown experimental messenger. Know its <a href="{{CHAT_PUBLIC_ORIGIN}}/LIMITATIONS.txt">limitations</a>.
+* A home-grown experimental messenger. <span class="librejs-note"><a href="https://www.gnu.org/software/librejs/" aria-describedby="chat-js-warning">LibreJS-compliant</a><span class="librejs-warning" id="chat-js-warning" role="tooltip">LibreJS compliance concerns software freedom, not a guarantee of privacy or security. Review the source code, dependencies, and scripts delivered to your browser. Server-delivered code can change between visits; free software can still track users or behave maliciously.</span></span>. Know its <a href="{{CHAT_PUBLIC_ORIGIN}}/LIMITATIONS.txt">limitations</a>.
</pre>
<nav 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="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a>
+ <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
<a href="{{GIT_ORIGIN}}/stack/tree/web/index.html">[ SOURCE CODE ]</a>
</nav>
diff --git a/web/message/index.html b/web/message/index.html
index d1739b0..dbab7cf 100644
--- a/web/message/index.html
+++ b/web/message/index.html
@@ -44,7 +44,7 @@ For now, you can reach me by <a href="mailto:doan@luuduct.ai">email</a> or use t
</pre>
<nav 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="{{GIT_ORIGIN}}/stack/about/">[ THIRD PARTY LICENSES ]</a>
+ <a class="dependency-licenses" href="{{SITE_ORIGIN}}/THIRD-PARTY-LICENSES.html">[ THIRD PARTY LICENSES ]</a>
<a href="{{GIT_ORIGIN}}/stack/tree/web/message/index.html">[ SOURCE CODE ]</a>
</nav>
</div>
diff --git a/web/styles.css b/web/styles.css
index a50bc37..20e469d 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -137,3 +137,45 @@ a {
color: #a3c7a8;
}
}
+
+.librejs-note {
+ position: relative;
+}
+
+.librejs-note > a {
+ color: #806000;
+ text-decoration: underline wavy;
+ text-underline-offset: .2em;
+}
+
+.librejs-warning {
+ display: none;
+ position: absolute;
+ bottom: 100%;
+ right: 0;
+ width: 48ch;
+ max-width: calc(100vw - 32px);
+ padding: 1em 2ch;
+ white-space: normal;
+ background: #fff;
+ color: #000;
+ border: 1px dashed #806000;
+ z-index: 1;
+}
+
+.librejs-note:hover .librejs-warning,
+.librejs-note:focus-within .librejs-warning {
+ display: block;
+}
+
+@media (prefers-color-scheme: dark) {
+ .librejs-note > a {
+ color: #ffdf60;
+ }
+
+ .librejs-warning {
+ background: #000;
+ color: #fff;
+ border-color: #ffdf60;
+ }
+}