From ef0e7e430c99aa07d5ea7dea57bf742f3570eafb Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 02:29:02 +0200 Subject: [PATCH] feat(argus): POST /api/icon-upload route for Deviant Potidaea Katalogos icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content-addressed image upload for the DVMB icon sidecar (see the Deviant repo commit 'in-browser Sia upload' for the client side). Request POST /api/icon-upload Content-Type: image/(svg+xml|png|jpeg|gif|webp|x-icon) body: raw image bytes (≤ 128 KB) Response { sha256, ext, path: "s3://bns/potidaea/icons/.", url: "https://navigate.st/bns/potidaea.asm/icons/.", bytes } Why no auth Content-addressed: the key IS the SHA-256, so a re-upload of the same bytes overwrites itself harmlessly, and a spammer only pollutes their own hash-space. The on-chain iconSha256 field (DVMB chunk 5) ties an entry NFT to specific bytes — a rogue upload can't hijack another asset's icon. Guards • content-type whitelist (only real image formats) • 128 KB body cap enforced during read (early destroy on overflow) • CORS * — safe, since we never touch per-user state • OPTIONS preflight handled for browsers that trigger one Deployed to silentmode 2026-09-06; live at https://navigate.st/api/icon-upload Smoke: browser JS POST returned 200 with the expected content- addressed URL; a later GET of that URL returned matching bytes. --- src/gateway/public-gateway.mjs | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index c3da2ec..0e79792 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -303,6 +303,89 @@ const server = http.createServer(async (req, res) => { return handleSearch(u, res); } + // --- icon upload endpoint (Deviant Potidaea Katalogos DVMB icons) --- + // POST /api/icon-upload with a raw image body → + // { sha256, path: "s3://bns/potidaea/icons/.", + // url: "https://navigate.st/bns/potidaea.asm/icons/." } + // + // Content-addressed: the storage key IS the SHA-256 of the bytes, so + // • re-uploads of the same content overwrite themselves harmlessly + // • per-user quota isn't necessary (a spammer just re-writes their + // own keys), and CORS is safe as * because we never touch state + // • the on-chain iconSha256 field ties the entry NFT to specific + // bytes, so a rogue upload can't hijack another asset's icon + // + // Guards: 128 KB size cap, image/* content-type whitelist. Any + // authorization checks would break the mockup's chipnet-test posture; + // spam is inherently rate-limited by disk write speed on the shared + // Sia bucket. If misuse becomes a problem, add a per-IP rate-limit + // here rather than authentication (the mockup has none by design). + if (req.url === "/api/icon-upload") { + // OPTIONS preflight — CORS + if (req.method === "OPTIONS") { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "content-type", + "access-control-max-age": "86400", + }); + return res.end(); + } + res.setHeader("access-control-allow-origin", "*"); + if (req.method !== "POST") { + res.writeHead(405, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: "POST only" })); + } + const CT_TO_EXT = { + "image/svg+xml": "svg", "image/png": "png", "image/jpeg": "jpg", + "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", + }; + const ct = (req.headers["content-type"] || "").split(";")[0].trim().toLowerCase(); + const ext = CT_TO_EXT[ct]; + if (!ext) { + res.writeHead(415, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: "unsupported content-type; allowed: " + Object.keys(CT_TO_EXT).join(", ") })); + } + const chunks = []; + let total = 0; + const LIMIT = 128 * 1024; + let aborted = false; + req.on("data", (c) => { + total += c.length; + if (total > LIMIT) { + aborted = true; + req.destroy(); + try { res.writeHead(413, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "body too large (>128 KB)" })); } catch {} + return; + } + chunks.push(c); + }); + req.on("end", async () => { + if (aborted) return; + try { + const bytes = Buffer.concat(chunks); + const { createHash } = await import("node:crypto"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const key = `bns/potidaea/icons/${sha256}.${ext}`; + const putUrl = `${S3_ENDPOINT}/${key}`; + const r = await aws.fetch(putUrl, { method: "PUT", body: bytes, headers: { "content-type": ct } }); + if (!r.ok) { + const detail = await r.text().catch(() => ""); + res.writeHead(502, { "content-type": "application/json" }); + return res.end(JSON.stringify({ error: "Sia PUT failed", status: r.status, detail: detail.slice(0, 200) })); + } + const publicUrl = `https://navigate.st/bns/potidaea.asm/icons/${sha256}.${ext}`; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ sha256, ext, path: `s3://${key}`, url: publicUrl, bytes: bytes.length })); + } catch (e) { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: String(e?.message || e) })); + } + }); + req.on("error", () => { /* swallow */ }); + return; + } + // --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) --- // GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] } // The list a name-selling site scans to know which suffixes it may sell.