feat(gateway): rewrite cross-name absolute links through /bns/

theseus.x had a hardcoded href="https://sirius.x/" that worked in Theseus
(which resolves BCH names natively) but 404'd for ordinary browsers
served through the relay. Fixing it in every source file each time a
site adds a cross-name link is thankless; fix it once at the gateway.

New transformHtml() = injectBase() + rewriteBnsLinks(). The rewrite
matches href="https?://<label>.<tld>/..." and, when <tld> is a known
BCDN-only TLD, replaces the URL with /bns/<host>/... so the click stays
inside the relay origin.

Allowlisted TLDs: bch, x, p2p, deviant, bit, nav, test, coin, cash.
Intentionally EXCLUDED: st, com, gt, sc, de — those exist in ICANN and
rewriting them would hijack real web references (dl.silentmode.st is
the working case). The rewrite only fires on gateway-served HTML, so
the source sites remain clean when consumed at their native origin
by Theseus or Ariadne.

Verified: https://sirius.x/ on theseus.x home + plug-ins pages now
becomes /bns/sirius.x/; ordinary dl.silentmode.st links unchanged.
This commit is contained in:
Local Dev 2026-09-20 18:35:09 +02:00
parent 0c6753d6a3
commit 63aba73d98

View file

@ -267,6 +267,32 @@ function injectBase(html, href) {
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => m + `<base href="${href}">`);
return `<base href="${href}">` + html;
}
// TLDs the gateway will route through /bns/ when it sees them on a cross-name
// absolute link inside served HTML. Restricted to TLDs that are BCDN-only or
// deliberately BCDN-shadowed (never a live ICANN answer today) so we don't
// mis-hijack an ordinary web reference. .st, .com, .gt, .sc are intentionally
// EXCLUDED — they live in ICANN and rewriting them would break real links.
const BNS_TLDS_REWRITABLE = new Set(["bch","x","p2p","deviant","bit","nav","test","coin","cash"]);
// Rewrite <a href="https://<label>.<bcdn-tld>/..."> to <a href="/bns/<label>.<bcdn-tld>/...">
// so an ordinary browser served through the relay follows the link back through
// the relay instead of trying to resolve the BCDN name via DNS (which returns
// NXDOMAIN). This is a NO-OP on the native origin (Theseus/Ariadne), because
// the gateway never serves there — those clients see the original URLs.
function rewriteBnsLinks(html) {
return html.replace(
/\bhref\s*=\s*"(https?:\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z0-9-]+))(\/[^"]*)?)"/gi,
(m, full, host, tld, path) => {
if (!BNS_TLDS_REWRITABLE.has(tld.toLowerCase())) return m;
return `href="/bns/${host.toLowerCase()}${path || "/"}"`;
}
);
}
// Apply both transforms (base first, then link rewrite) — order doesn't
// matter because they touch disjoint constructs, but grouping them here
// means every text/html response goes through the same path.
function transformHtml(html, baseHref) {
return rewriteBnsLinks(injectBase(html, baseHref));
}
const nx = (h) => `<div style="font:16px system-ui;padding:2rem;color:#333"><h2>NXDOMAIN</h2><p><code>${h}</code> is not registered on the Bitcoin Cash chain.</p></div>`;
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
@ -404,7 +430,7 @@ async function serve(res, name, reqPath, baseHref) {
case "h":
if (reqPath !== "/") { res.writeHead(404); return res.end("not found"); }
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
return res.end(injectBase(r.h, baseHref));
return res.end(transformHtml(r.h, baseHref));
case "s3": {
const sub = subFolder(name);
let base = r.s3; if (base.endsWith("/") && sub) base += sub + "/";
@ -442,7 +468,7 @@ async function serve(res, name, reqPath, baseHref) {
}
const buf = Buffer.from(await up.arrayBuffer());
let ct = up.headers.get("content-type"); if (!ct || ct === "application/octet-stream") ct = guess(key);
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(transformHtml(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, { "content-type": ct }); return res.end(buf);
}
case "ip": {
@ -464,7 +490,7 @@ async function serve(res, name, reqPath, baseHref) {
if (lk === "content-type") continue;
outHeaders[k] = v;
}
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(transformHtml(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, outHeaders); return res.end(buf);
}
case "p": {
@ -483,7 +509,7 @@ async function serve(res, name, reqPath, baseHref) {
if (lk === "content-type") continue;
outHeaders[k] = v;
}
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); }
if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(transformHtml(buf.toString("utf8"), baseHref)); }
res.writeHead(up.status, outHeaders); return res.end(buf);
}
case "u":