fix(studio): open the page the name actually serves, not an empty folder

Sirius Studio only looked in its own folder, bns/<name>/. Every site
published the conventional way lives at bns/<label>/ (the s3 record),
or inline in the h record, so the editor opened those names as "new
site" with a template picker instead of the current build.

The gateway's site read gains a read-only ?src=live view that follows
the on-chain record: list and fetch from whatever bucket s3 points at,
or serve the h HTML as index.html. Writes are unchanged and still
confined to bns/<name>/.

The editor now starts from the live page when there is no draft:
relative image and link URLs are resolved against the public site URL,
stylesheets are inlined (cross-origin ones such as font CDNs are kept
as links and re-emitted on export), scripts dropped. When a draft
exists but the name serves something else, or index.html on Sia is
newer than the draft, a notice says so with a one-click import. An
"Import live site" button is always available. Verified with
aloevera.test, which is hosted at bns/aloevera/.
This commit is contained in:
Local Dev 2026-09-20 01:22:21 +02:00
parent c8d465e2e4
commit bdb4c6235d

View file

@ -1238,14 +1238,30 @@ const server = http.createServer(async (req, res) => {
const entry = (await getIndex()).get(name);
if (!entry) { res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: `"${name}" not registered` })); return; }
const prefix = `bns/${name}/`;
// GET ...?src=live — a READ-ONLY view of wherever the name's on-chain
// records say the site lives right now: a CLI-published bucket such as
// bns/<label>/ (the `s3` record) or inline HTML (the `h` record). Sirius
// Studio uses it to import the current build. Writes never look at it;
// they stay confined to bns/<name>/.
const srcLive = req.method === "GET" && new URL(req.url, "http://x").searchParams.get("src") === "live";
const recS3raw = typeof entry.records?.s3 === "string" ? entry.records.s3.trim().replace(/^\/+/, "").replace(/\/?$/, "/") : "";
const livePrefix = srcLive && /^bns\/[A-Za-z0-9._\-\/]{1,200}$/.test(recS3raw) && !recS3raw.includes("..") ? recS3raw : null;
const liveInline = srcLive && !livePrefix && typeof entry.records?.h === "string" && entry.records.h ? entry.records.h : null;
const SITE_TYPES = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8", css: "text/css; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", json: "application/json", txt: "text/plain; charset=utf-8", md: "text/markdown; charset=utf-8", xml: "application/xml", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", pdf: "application/pdf", mp3: "audio/mpeg", mp4: "video/mp4", webm: "video/webm", wasm: "application/wasm" };
const typeOf = (p) => SITE_TYPES[(p.split(".").pop() || "").toLowerCase()] || "application/octet-stream";
// ---- list ----
if (!filePath) {
res.setHeader("content-type", "application/json");
if (req.method !== "GET") { res.writeHead(405); res.end(JSON.stringify({ error: "GET" })); return; }
if (srcLive && !livePrefix) {
// Nothing on Sia for this name: inline HTML or no site at all.
res.writeHead(200);
res.end(JSON.stringify({ name, prefix: null, source: liveInline ? "inline" : "none", files: liveInline ? [{ path: "index.html", size: Buffer.byteLength(liveInline), modified: null, inline: true }] : [] }));
return;
}
const listPrefix = (srcLive ? livePrefix : prefix).slice("bns/".length);
try {
const up = await aws.fetch(`${S3_ENDPOINT}/bns?list-type=2&prefix=${encodeURIComponent(name + "/")}&max-keys=1000`);
const up = await aws.fetch(`${S3_ENDPOINT}/bns?list-type=2&prefix=${encodeURIComponent(listPrefix)}&max-keys=1000`);
if (!up.ok) { res.writeHead(502); res.end(JSON.stringify({ error: `sia list failed: ${up.status}` })); return; }
const xml = await up.text();
const files = [];
@ -1255,9 +1271,9 @@ const server = http.createServer(async (req, res) => {
const key = /<Key>([^<]*)<\/Key>/.exec(m[1])?.[1] || "";
const size = Number(/<Size>(\d+)<\/Size>/.exec(m[1])?.[1] || 0);
const modified = /<LastModified>([^<]*)<\/LastModified>/.exec(m[1])?.[1] || null;
if (key.startsWith(name + "/")) files.push({ path: key.slice(name.length + 1), size, modified });
if (key.startsWith(listPrefix)) files.push({ path: key.slice(listPrefix.length), size, modified });
}
res.writeHead(200); res.end(JSON.stringify({ name, prefix, files }));
res.writeHead(200); res.end(JSON.stringify({ name, prefix: srcLive ? livePrefix : prefix, source: srcLive ? "live" : "studio", files }));
} catch (e) { res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); }
return;
}
@ -1265,9 +1281,13 @@ const server = http.createServer(async (req, res) => {
if (!/^[A-Za-z0-9._\-\/]{1,200}$/.test(filePath) || filePath.includes("..") || filePath.startsWith("/") || filePath.endsWith("/")) {
res.setHeader("content-type", "application/json"); res.writeHead(400); res.end(JSON.stringify({ error: "invalid path" })); return;
}
const key = `${prefix}${filePath}`;
const key = `${srcLive && livePrefix ? livePrefix : prefix}${filePath}`;
// ---- read-through ----
if (req.method === "GET") {
if (srcLive && !livePrefix) {
if (liveInline && filePath === "index.html") { res.setHeader("content-type", "text/html; charset=utf-8"); res.setHeader("cache-control", "no-cache"); res.writeHead(200); res.end(liveInline); return; }
res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: "the name has no Sia-hosted site" })); return;
}
try {
const up = await aws.fetch(`${S3_ENDPOINT}/${key}`);
if (up.status === 404 || up.status === 403) { res.setHeader("content-type", "application/json"); res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; }