feat(argus): POST /api/icon-upload route for Deviant Potidaea Katalogos icons

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/<sha256>.<ext>",
    url:  "https://navigate.st/bns/potidaea.asm/icons/<sha256>.<ext>",
    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.
This commit is contained in:
Local Dev 2026-09-07 02:29:02 +02:00
parent db71b9e832
commit ef0e7e430c

View file

@ -303,6 +303,89 @@ const server = http.createServer(async (req, res) => {
return handleSearch(u, 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/<sha256>.<ext>",
// url: "https://navigate.st/bns/potidaea.asm/icons/<sha256>.<ext>" }
//
// 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) --- // --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) ---
// GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] } // 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. // The list a name-selling site scans to know which suffixes it may sell.