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:
parent
63e9b31c92
commit
4a5c726022
1 changed files with 114 additions and 0 deletions
|
|
@ -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 }
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue