feat(sirius-x): Sirius Studio web-builder + signed site uploads; shorter dashboard menu

Owners had no way to put a real page behind a name without running Sia
tooling themselves. Sirius Studio (studio.html, js/studio.js) embeds
GrapesJS (BSD-3, vendored under vendor/grapesjs so nothing loads from a
third party) with three starter templates; Publish exports one
self-contained index.html plus the editor project, uploads both to the
name's own folder bns/<name>/ on Sia, and sets the s3 record on chain if
the name does not point there yet. Drafts save to the same folder.

Gateway: /api/site/<name>[/<path>] — list, read-through, PUT and DELETE.
Every write carries a wallet signature over (name, path, body hash,
timestamp) that must recover to the current NFT owner, and writes are
confined to bns/<name>/ so no name can touch another name's bucket.

Dashboard menu is now Web-Builder, Domain names, My TLDs, Settings, Sign
out; Overview and Wallet folded into Domain names and Settings, Docs is a
link in Settings.
This commit is contained in:
Local Dev 2026-09-17 01:35:21 +02:00
parent 63e9b31c92
commit 4a5c726022

View file

@ -14,6 +14,7 @@ import http from "http";
import https from "https";
import tls from "tls";
import { AwsClient } from "aws4fetch";
import { createHash } from "node:crypto";
// libauth is present so the next drop's signature-verify path is a
// contained edit (no dep install between now and then). Only utf8ToBin
// and sha256 are used today; the rest are named here so the imports
@ -988,6 +989,119 @@ const server = http.createServer(async (req, res) => {
}
return;
}
// --- site files (Sirius Studio) ---
// The name's own folder on Sia is bns/<name>/. Writes go there and ONLY
// there, whatever the on-chain s3 record says — so no name can be used
// to overwrite another name's bucket.
// GET /api/site/<name> -> { name, prefix, files: [{path,size,modified}] }
// GET /api/site/<name>/<path> -> the object (read-through)
// PUT /api/site/<name>/<path> -> body = file bytes; headers:
// x-bns-ts unix ms, within 10 minutes of now
// x-bns-sig base64 65-byte BCH message signature over
// sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
// DELETE /api/site/<name>/<path> -> same signature scheme, empty body
// The signature must recover to the name's current on-chain NFT owner
// (same check as /api/records). 8 MB per file.
if (req.url.startsWith("/api/site/")) {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-methods", "GET, PUT, DELETE, OPTIONS");
res.setHeader("access-control-allow-headers", "content-type, x-bns-sig, x-bns-ts");
res.setHeader("access-control-max-age", "86400");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
const rest = req.url.slice("/api/site/".length).split("?")[0];
const slash = rest.indexOf("/");
const rawName = decodeURIComponent(slash < 0 ? rest : rest.slice(0, slash));
const filePath = slash < 0 ? "" : decodeURIComponent(rest.slice(slash + 1));
let name;
try { name = normalizeName(rawName); }
catch (e) { res.setHeader("content-type", "application/json"); res.writeHead(400); res.end(JSON.stringify({ error: e.message })); return; }
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}/`;
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; }
try {
const up = await aws.fetch(`${S3_ENDPOINT}/bns?list-type=2&prefix=${encodeURIComponent(name + "/")}&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 = [];
const re = /<Contents>([\s\S]*?)<\/Contents>/g;
let m;
while ((m = re.exec(xml))) {
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 });
}
res.writeHead(200); res.end(JSON.stringify({ name, prefix, files }));
} catch (e) { res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); }
return;
}
// ---- path sanity ----
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}`;
// ---- read-through ----
if (req.method === "GET") {
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; }
if (!up.ok) { res.setHeader("content-type", "application/json"); res.writeHead(502); res.end(JSON.stringify({ error: `sia read failed: ${up.status}` })); return; }
const buf = Buffer.from(await up.arrayBuffer());
res.setHeader("content-type", typeOf(filePath));
res.setHeader("cache-control", "no-cache");
res.writeHead(200); res.end(buf);
} catch (e) { res.setHeader("content-type", "application/json"); res.writeHead(502); res.end(JSON.stringify({ error: `sia error: ${e.message}` })); }
return;
}
res.setHeader("content-type", "application/json");
if (req.method !== "PUT" && req.method !== "DELETE") { res.writeHead(405); res.end(JSON.stringify({ error: "GET, PUT or DELETE" })); return; }
if (!entry.owner) { res.writeHead(503); res.end(JSON.stringify({ error: "gateway index has no owner recorded for this name yet — retry after next index refresh (30s)" })); return; }
// ---- body (8 MB cap) ----
const chunks = []; let total = 0;
for await (const c of req) {
total += c.length;
if (total > 8 * 1024 * 1024) { res.writeHead(413); res.end(JSON.stringify({ error: "file > 8 MB" })); return; }
chunks.push(c);
}
const body = req.method === "PUT" ? Buffer.concat(chunks) : Buffer.alloc(0);
// ---- signature ----
const ts = Number(req.headers["x-bns-ts"] || 0);
const sigB64 = String(req.headers["x-bns-sig"] || "");
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 10 * 60 * 1000) { res.writeHead(401); res.end(JSON.stringify({ error: "x-bns-ts missing or more than 10 minutes off" })); return; }
let sigBytes;
try { sigBytes = base64ToBin(sigB64); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "x-bns-sig is not valid base64" })); return; }
if (sigBytes.length !== 65) { res.writeHead(400); res.end(JSON.stringify({ error: `x-bns-sig must decode to 65 bytes (got ${sigBytes.length})` })); return; }
const bodyHex = createHash("sha256").update(body).digest("hex");
const digest = sha256.hash(utf8ToBin(`BNS-SITE1\n${name}\n${filePath}\n${bodyHex}\n${ts}`));
const recovered = secp256k1.recoverPublicKeyCompressed(sigBytes.slice(1), (sigBytes[0] - 27) & 3, digest);
if (typeof recovered === "string") { res.writeHead(401); res.end(JSON.stringify({ error: `signature recovery failed: ${recovered}` })); return; }
const ownerPrefix = String(entry.owner).split(":")[0] || "bchtest";
const encAddr = encodeCashAddress({ prefix: ownerPrefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(recovered)) });
const derivedAddr = typeof encAddr === "string" ? encAddr : (encAddr?.address ?? "");
if (derivedAddr !== entry.owner) { res.writeHead(403); res.end(JSON.stringify({ error: "signature does not match current on-chain NFT owner", derived: derivedAddr, current: entry.owner })); return; }
// ---- write / delete ----
try {
if (req.method === "DELETE") {
const del = await aws.fetch(`${S3_ENDPOINT}/${key}`, { method: "DELETE" });
if (!del.ok && del.status !== 204 && del.status !== 404) { res.writeHead(502); res.end(JSON.stringify({ error: `sia delete failed: ${del.status}` })); return; }
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, path: filePath, deleted: true }));
return;
}
const ct = String(req.headers["content-type"] || "").split(";")[0].trim() || typeOf(filePath);
const put = await aws.fetch(`${S3_ENDPOINT}/${key}`, { method: "PUT", body, headers: { "content-type": ct } });
if (!put.ok) { const detail = await put.text().catch(() => ""); res.writeHead(502); res.end(JSON.stringify({ error: `sia write failed: ${put.status} ${detail.slice(0, 200)}` })); return; }
// Drop the gateway's page cache for this name so the new file shows at once.
for (const k of [...cache.keys()]) if (k === name || k.startsWith(name + "/") || k.startsWith(name + "|")) cache.delete(k);
res.writeHead(200); res.end(JSON.stringify({ ok: true, name, path: filePath, bytes: total, sia_key: key }));
} catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: `site write error: ${e.message}` })); }
return;
}
// --- resolver-side DNS read (verified) ---
// GET /api/dns/<name> -> { name, seq, updated_at, dns, verified_owner }
//