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:
parent
db71b9e832
commit
ef0e7e430c
1 changed files with 83 additions and 0 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue