diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 2d028ff..30dbd4b 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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//. 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, prefix, files: [{path,size,modified}] } + // GET /api/site// -> the object (read-through) + // PUT /api/site// -> 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\n\n\n") + // DELETE /api/site// -> 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 = /([\s\S]*?)<\/Contents>/g; + let m; + while ((m = re.exec(xml))) { + const key = /([^<]*)<\/Key>/.exec(m[1])?.[1] || ""; + const size = Number(/(\d+)<\/Size>/.exec(m[1])?.[1] || 0); + const modified = /([^<]*)<\/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, seq, updated_at, dns, verified_owner } //