diff --git a/marketing/podcast/SCORE.md b/marketing/podcast/SCORE.md index 06341011f0..c2edef96e8 100644 --- a/marketing/podcast/SCORE.md +++ b/marketing/podcast/SCORE.md @@ -56,6 +56,14 @@ Kubo node Keeps uses) with TZIP-21 metadata (the script as description, links to the episode and the live $code), `mint_OBJKT` mints `DAILY_EDITIONS` (1, a daily unique) and an objkt ask lists it at `DAILY_PRICE_XTZ` (3). HEN metadata is immutable, so a minted day can't be renamed or re-imaged. +Interactive dailies use `DAILY_ARTIFACT=zip` by default (`html` in an older env +also selects ZIP). The offline crawl is packaged as `index.html`, `cover.gif` +and `thumbnail.png` in `out/daily/.zip`; `/api/ipfs-add` pins those files +as a directory. The artifact URI is the directory root with MIME type +`application/x-directory`, as HEN/Teia require, while the GIF remains the +display image. Never mint a bare HTML or ZIP file CID as the interactive +artifact. `DAILY_ARTIFACT=gif` retains the GIF-only option. An unminted receipt +with previously pinned metadata in another format stops for inspection. Stages resume from `out/daily/.token.json`; it refuses to sign with any key but aesthetic.tez or below 0.15 XTZ (each day burns ~0.06). Needs `AESTHETIC_KEY` in the appliance env (jasellite: `~/.config/ac/tezos-daily.env`, diff --git a/marketing/podcast/bin/daily-artifact-check.mjs b/marketing/podcast/bin/daily-artifact-check.mjs index e53bc853af..c56512834b 100644 --- a/marketing/podcast/bin/daily-artifact-check.mjs +++ b/marketing/podcast/bin/daily-artifact-check.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// daily-artifact-check.mjs — watch a daily bundle run the way objkt holds it. +// daily-artifact-check.mjs — watch a daily package in the HEN/objkt sandbox. // // Serves the bundle to a sandboxed iframe (allow-scripts, opaque origin) and // aborts every other request, then screenshots it for a few seconds: it @@ -7,7 +7,7 @@ // half of the gate; daily-token.mjs runs the static half (checkBundle) itself, // since jasellite has no Chrome. Needs puppeteer (oven/node_modules or root). // -// node bin/daily-artifact-check.mjs out/daily/daily-2026-09-29.html [--shots dir] +// node bin/daily-artifact-check.mjs out/daily/daily-2026-10-06.zip [--shots dir] import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { resolve, dirname, basename } from "node:path"; @@ -25,9 +25,11 @@ const need = (m) => { const puppeteer = need("puppeteer"), sharp = need("sharp"); const [file, ...rest] = process.argv.slice(2); -if (!file) { console.error("usage: daily-artifact-check.mjs [--shots dir]"); process.exit(2); } +if (!file) { console.error("usage: daily-artifact-check.mjs [--shots dir]"); process.exit(2); } const shotsDir = rest.includes("--shots") ? rest[rest.indexOf("--shots") + 1] : null; -const bundle = readFileSync(file); +const zip = file.endsWith(".zip") ? new (need("adm-zip"))(readFileSync(file)) : null; +const bundle = zip ? zip.readFile("index.html") : readFileSync(file); +if (!bundle) throw new Error("ZIP is missing root index.html"); const ART = "https://art.test/index.html", HOST = "https://host.test/"; const host = ``; @@ -40,6 +42,10 @@ page.on("request", (r) => { const u = r.url(); if (u === ART) return r.respond({ status: 200, contentType: "text/html", body: bundle }); if (u === HOST) return r.respond({ status: 200, contentType: "text/html", body: host }); + if (zip && ["https://art.test/cover.gif", "https://art.test/thumbnail.png"].includes(u)) { + const name = new URL(u).pathname.slice(1); + return r.respond({ status: 200, contentType: name.endsWith("gif") ? "image/gif" : "image/png", body: zip.readFile(name) }); + } if (u.startsWith("blob:") || u.startsWith("data:")) return r.continue(); if (!u.endsWith("/favicon.ico")) leaked.push(u); r.abort(); diff --git a/marketing/podcast/bin/daily-token.mjs b/marketing/podcast/bin/daily-token.mjs index 6809d963f8..dad5b79af2 100644 --- a/marketing/podcast/bin/daily-token.mjs +++ b/marketing/podcast/bin/daily-token.mjs @@ -5,9 +5,9 @@ // here (lib/crawl.mjs), and the same crawl as a live KidLisp // $code on AC; the thumbnail is one of the GIF's frames // bundle — the $code packed as a Keep is (oven/bundler.mjs, PACK mode): -// one self-extracting HTML that runs offline in objkt's sandbox -// pin — bundle, GIF, thumb and TZIP-21 metadata to AC's IPFS node -// (/api/ipfs-add); the bundle is the artifact, the GIF the display +// index.html + covers in a HEN/Teia ZIP, with an offline runtime +// pin — the ZIP's contents as an IPFS directory, plus GIF, thumb and +// TZIP-21 metadata (/api/ipfs-add); the GIF stays the display // mint — mint_OBJKT on the hic et nunc minter, signed by aesthetic.tez // list — an objkt ask for the whole edition // @@ -23,7 +23,7 @@ // AESTHETIC_KEY, AESTHETIC_ADDRESS the signer (must be aesthetic.tez) // AC_TOKEN or ~/.ac-token an @jeffrey AC session, for /api/ipfs-add // Tuning: DAILY_EDITIONS (1), DAILY_PRICE_XTZ (3), DAILY_ROYALTIES_PERMILLE (150), -// DAILY_ARTIFACT (html | gif: what the artifactUri is; see lib/artifact.mjs). +// DAILY_ARTIFACT (zip | gif; legacy html also uses ZIP packaging). import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; @@ -73,7 +73,7 @@ const PRICE_XTZ = Number(process.env.DAILY_PRICE_XTZ || 3); const ROYALTIES = Number(process.env.DAILY_ROYALTIES_PERMILLE || 150); // HEN is per-mille const MIN_BALANCE_XTZ = 0.15; // a mint + a listing burn ~0.06 -const { artifactMode, crawlBundle, checkBundle, tokenMetadata } = await import(resolve(ROOT, "lib", "artifact.mjs")); +const { artifactMode, crawlBundle, checkBundle, crawlPackage, checkPinnedArtifact, tokenMetadata, TEIA_FORMAT } = await import(resolve(ROOT, "lib", "artifact.mjs")); let ARTIFACT; try { ARTIFACT = artifactMode(); } catch (err) { console.error(`✗ ${err.message}`); process.exit(1); } @@ -101,6 +101,7 @@ if (receipt.listed) { console.log(`✓ ${slug} already minted and listed: ${receipt.objktUrl}`); process.exit(0); } +checkPinnedArtifact(receipt, ARTIFACT); // ── 0. the episode ─────────────────────────────────────────────────────── // The script daily.mjs wrote is the material; its redaction guard already @@ -161,7 +162,8 @@ if (!receipt.code) { // that fails is rebuilt once from this checkout's runtime, and if it still // fails the night refuses to mint HTML rather than mint a broken artifact. const htmlPath = resolve(dailyDir, `${slug}.html`); -if (ARTIFACT === "html") { +let packaged; +if (ARTIFACT === "zip") { const pack = async () => { const html = await crawlBundle(receipt.code, receipt.source); writeFileSync(htmlPath, html); @@ -183,13 +185,20 @@ if (ARTIFACT === "html") { process.exit(1); } console.log(" ✓ bundle checks out: PACK mode, offline, source intact, runtime current"); + packaged = crawlPackage(readFileSync(htmlPath, "utf8"), { + gif: readFileSync(resolve(dailyDir, `${slug}.gif`)), + thumbnail: readFileSync(resolve(dailyDir, `${slug}-thumb.png`)), + frames: receipt.frames, + }); + writeFileSync(resolve(dailyDir, `${slug}.zip`), packaged.zip); + console.log(` ✓ HEN/Teia package → out/daily/${slug}.zip (index.html + covers)`); } const metadataFor = (uris) => tokenMetadata({ title, body, date, episodeUrl, code: receipt.code, creator: SIGNER, artifact: ARTIFACT, uris, ac: AC }); if (flags.dry) { const dry = (f) => `ipfs://<${f}>`; - const metadata = metadataFor({ html: dry(`${slug}.html`), gif: dry(`${slug}.gif`), thumb: dry(`${slug}-thumb.png`) }); + const metadata = metadataFor({ directory: dry(`${slug}-directory`), gif: dry(`${slug}.gif`), thumb: dry(`${slug}-thumb.png`) }); writeFileSync(resolve(dailyDir, `${slug}.metadata.json`), JSON.stringify(metadata, null, 2) + "\n"); console.log(`✓ dry run (${ARTIFACT}): $${receipt.code} rendered; metadata → out/daily/${slug}.metadata.json; nothing pinned or minted.`); process.exit(0); @@ -215,13 +224,13 @@ const pinFile = (path, name, mimeType) => pin({ name, mimeType, base64: readFile if (!receipt.metadataUri) { const uris = { - html: ARTIFACT === "html" ? await pinFile(htmlPath, `${slug}.html`, "text/html") : undefined, + directory: packaged ? await pin({ files: packaged.files.map(({ name, mime, content }) => ({ name, mimeType: mime, base64: content.toString("base64") })) }) : undefined, gif: await pinFile(resolve(dailyDir, `${slug}.gif`), `${slug}.gif`, "image/gif"), thumb: await pinFile(resolve(dailyDir, `${slug}-thumb.png`), `${slug}-thumb.png`, "image/png"), }; const metadata = metadataFor(uris); const metadataUri = await pin({ name: `${slug}.json`, json: metadata }); - Object.assign(receipt, { artifact: ARTIFACT, artifactUri: metadata.artifactUri, displayUri: metadata.displayUri, thumbnailUri: metadata.thumbnailUri, metadataUri }); + Object.assign(receipt, { artifact: ARTIFACT, artifactMimeType: ARTIFACT === "zip" ? TEIA_FORMAT : "image/gif", artifactUri: metadata.artifactUri, displayUri: metadata.displayUri, thumbnailUri: metadata.thumbnailUri, metadataUri }); save(); console.log(` ✓ pinned ${receipt.metadataUri}`); } diff --git a/marketing/podcast/lib/artifact.mjs b/marketing/podcast/lib/artifact.mjs index 6b76d8ff81..4a68eaa716 100644 --- a/marketing/podcast/lib/artifact.mjs +++ b/marketing/podcast/lib/artifact.mjs @@ -1,27 +1,39 @@ // artifact.mjs — the daily token's live artifact and its TZIP-21 metadata. // // The crawl's KidLisp $code, packed by the oven's own bundler exactly as a -// Keep is: one self-extracting text/html file in PACK mode (the runtime, fonts -// and source inlined as a VFS; fetch() answered from it), so it runs in -// objkt's sandbox with no network. The GIF stays on as the displayUri, for -// wallets and feeds that don't run HTML. +// Keep is: one self-extracting page in PACK mode (runtime, fonts and source +// inlined). Package it as index.html + covers in a ZIP, and pin those files +// as an IPFS directory so HEN/Teia select their interactive viewer. import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { gunzipSync } from "node:zlib"; +import { teiaPackage, TEIA_FORMAT } from "../../../system/backend/whistlegraph-teia.mjs"; + +export { teiaPackage as crawlPackage, TEIA_FORMAT }; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = resolve(HERE, "..", "..", ".."); -export const ARTIFACTS = ["html", "gif"]; +export const ARTIFACTS = ["zip", "gif"]; -// DAILY_ARTIFACT=html|gif; anything else is a mistake worth stopping for. +// Older appliance configs may still say html; they get the corrected package. export function artifactMode(env = process.env) { - const mode = (env.DAILY_ARTIFACT || "html").trim().toLowerCase(); + const mode = (env.DAILY_ARTIFACT || "zip").trim().toLowerCase(); + if (mode === "html") return "zip"; if (!ARTIFACTS.includes(mode)) throw new Error(`DAILY_ARTIFACT must be ${ARTIFACTS.join(" or ")}, not "${mode}"`); return mode; } +// A saved metadata URI is immutable once submitted. Never resume an unminted +// legacy receipt into another bare-HTML mint, or change a submitted operation. +export function checkPinnedArtifact(receipt, artifact) { + if (receipt.metadataUri && !receipt.mintOp && receipt.tokenId === undefined && + (receipt.artifact !== artifact || (artifact === "zip" && receipt.artifactMimeType !== TEIA_FORMAT))) { + throw new Error("Unminted receipt has a different artifact format; inspect its pinned metadata before retrying"); + } +} + // The crawl sizes itself from the live screen, but AC still offsets its // wrapper to centre the canvas. A pack carries no style.css, so the wrapper // is given the position AC's stylesheet would, or it sits in the top-left. @@ -71,20 +83,21 @@ export function checkBundle(html, { code, source }) { return problems; } -export function tokenMetadata({ title, body, date, episodeUrl, code, creator, artifact = "html", uris, ac = "https://aesthetic.computer", size = 512 }) { +export function tokenMetadata({ title, body, date, episodeUrl, code, creator, artifact = "zip", uris, ac = "https://aesthetic.computer", size = 512 }) { + const live = artifactMode({ DAILY_ARTIFACT: artifact }) === "zip"; + if (live && !uris.directory) throw new Error("Interactive artifact needs an IPFS directory URI"); const gif = { uri: uris.gif, mimeType: "image/gif", dimensions: { value: `${size}x${size}`, unit: "px" } }; - const html = { uri: uris.html, mimeType: "text/html", dimensions: { value: "responsive", unit: "viewport" } }; - const live = artifact === "html"; + const directory = { uri: uris.directory, mimeType: TEIA_FORMAT, dimensions: { value: "responsive", unit: "viewport" } }; return { name: title, description: `${body}\n\n— Aesthetic Dot Computer, ${date}. Listen: ${episodeUrl}\nThe page as a live KidLisp piece: ${ac}/${code}`, tags: ["aesthetic.computer", "kidlisp", "podcast", "devlog", "pixelfont"], symbol: "OBJKT", - artifactUri: live ? uris.html : uris.gif, + artifactUri: live ? uris.directory : uris.gif, displayUri: uris.gif, thumbnailUri: uris.thumb, creators: [creator], - formats: live ? [html, gif] : [{ uri: uris.gif, mimeType: "image/gif" }], + formats: live ? [directory, gif] : [{ uri: uris.gif, mimeType: "image/gif" }], decimals: 0, isBooleanAmount: false, shouldPreferSymbol: false, diff --git a/marketing/podcast/test/daily-artifact.test.mjs b/marketing/podcast/test/daily-artifact.test.mjs index 249f6da4d4..2841760cd1 100644 --- a/marketing/podcast/test/daily-artifact.test.mjs +++ b/marketing/podcast/test/daily-artifact.test.mjs @@ -4,11 +4,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { gunzipSync, gzipSync } from "node:zlib"; -import { artifactMode, tokenMetadata, crawlBundle, checkBundle, CRAWL_STYLE } from "../lib/artifact.mjs"; +import { artifactMode, tokenMetadata, crawlBundle, checkBundle, crawlPackage, checkPinnedArtifact, CRAWL_STYLE } from "../lib/artifact.mjs"; +import AdmZip from "adm-zip"; import { crawlLayout, crawlPiece, readablePeriod } from "../lib/crawl.mjs"; const SIGNER = "tz1gkf8EexComFBJvjtT1zdsisdah791KwBE"; -const uris = { html: "ipfs://QmHtml", gif: "ipfs://QmGif", thumb: "ipfs://QmThumb" }; +const uris = { directory: "ipfs://QmDirectory", gif: "ipfs://QmGif", thumb: "ipfs://QmThumb" }; const episode = { title: "a door; (for letters)", body: `It said "hello", then (quietly) left; a colon: fine?`, @@ -19,21 +20,21 @@ const episode = { uris, }; -test("the switch: html by default, gif on request, anything else refused", () => { - assert.equal(artifactMode({}), "html"); +test("the switch: ZIP by default and for legacy HTML configs, GIF on request", () => { + assert.equal(artifactMode({}), "zip"); + assert.equal(artifactMode({ DAILY_ARTIFACT: "zip" }), "zip"); assert.equal(artifactMode({ DAILY_ARTIFACT: "gif" }), "gif"); - assert.equal(artifactMode({ DAILY_ARTIFACT: " HTML " }), "html"); - assert.throws(() => artifactMode({ DAILY_ARTIFACT: "svg" }), /html or gif/); + assert.equal(artifactMode({ DAILY_ARTIFACT: " HTML " }), "zip"); + assert.throws(() => artifactMode({ DAILY_ARTIFACT: "svg" }), /zip or gif/); }); -test("html metadata: the bundle is the artifact, the GIF the display", () => { - const m = tokenMetadata({ ...episode, artifact: "html" }); - assert.equal(m.artifactUri, uris.html); +test("HEN metadata selects the directory viewer, with the GIF as display", () => { + const m = tokenMetadata(episode); + assert.equal(m.artifactUri, uris.directory); assert.equal(m.displayUri, uris.gif); assert.equal(m.thumbnailUri, uris.thumb); - // The HTML format entry is a Keep's, field for field. assert.deepEqual(m.formats, [ - { uri: uris.html, mimeType: "text/html", dimensions: { value: "responsive", unit: "viewport" } }, + { uri: uris.directory, mimeType: "application/x-directory", dimensions: { value: "responsive", unit: "viewport" } }, { uri: uris.gif, mimeType: "image/gif", dimensions: { value: "512x512", unit: "px" } }, ]); assert.deepEqual(m.creators, [SIGNER]); @@ -43,6 +44,21 @@ test("html metadata: the bundle is the artifact, the GIF the display", () => { assert.equal(m.date, "2026-09-29T00:30:00.000Z"); }); +test("bare HTML cannot be passed as a directory; old configs use the new format", () => { + assert.throws(() => tokenMetadata({ ...episode, uris: { html: "ipfs://QmHtml" } }), /directory URI/); + assert.equal(tokenMetadata({ ...episode, artifact: "html" }).formats[0].mimeType, "application/x-directory"); +}); + +test("an unminted legacy receipt cannot resume into another HTML mint", () => { + const legacy = { artifact: "html", metadataUri: "ipfs://QmMetadata" }; + assert.throws(() => checkPinnedArtifact(legacy, "zip"), /Unminted receipt/); + assert.throws(() => checkPinnedArtifact({ ...legacy, artifact: "zip" }, "zip"), /Unminted receipt/); + assert.doesNotThrow(() => checkPinnedArtifact({ ...legacy, mintOp: "opPending" }, "zip")); + assert.doesNotThrow(() => checkPinnedArtifact({ ...legacy, tokenId: "885470" }, "zip")); + assert.doesNotThrow(() => checkPinnedArtifact({}, "zip")); + assert.doesNotThrow(() => checkPinnedArtifact({ artifact: "zip", artifactMimeType: "application/x-directory", metadataUri: "ipfs://QmNew" }, "zip")); +}); + test("gif metadata is exactly what the daily minted before the switch", () => { const m = tokenMetadata({ ...episode, artifact: "gif" }); assert.deepEqual(m, { @@ -94,6 +110,12 @@ test("the bundle is one offline, self-extracting PACK-mode page", async () => { // The gate daily-token runs before pinning: this bundle passes it... assert.deepEqual(checkBundle(outer, { code: "dly", source }), []); + const packed = crawlPackage(outer, { gif: Buffer.from("gif"), thumbnail: Buffer.from("png"), frames: 300 }); + const zip = new AdmZip(packed.zip); + assert.deepEqual(zip.getEntries().map(entry => entry.entryName).sort(), ["cover.gif", "index.html", "thumbnail.png"]); + for (const file of packed.files) assert.deepEqual(zip.readFile(file.name), file.content, "IPFS gets the exact ZIP contents"); + assert.deepEqual(checkBundle(zip.readAsText("index.html"), { code: "dly", source }), []); + assert.match(zip.readAsText("index.html"), /property="og:image" content="cover.gif"/); assert.deepEqual(checkBundle(outer, { code: "xyz", source }), ["doesn't start $xyz"]); // ...and one packed from a runtime without the highlighter fixes fails it. const stale = { ...vfs, "lib/kidlisp.mjs": { ...vfs["lib/kidlisp.mjs"], content: vfs["lib/kidlisp.mjs"].content.replace(/tokenScan\(/g, "scan(").replace(/window\.acPACK_MODE\s*&&\s*!window\.acKEEP_LABEL\)\s*return/g, "") } }; diff --git a/system/netlify/functions/ipfs-add.mjs b/system/netlify/functions/ipfs-add.mjs index 15be115f71..2d79ef0beb 100644 --- a/system/netlify/functions/ipfs-add.mjs +++ b/system/netlify/functions/ipfs-add.mjs @@ -3,6 +3,7 @@ // POST /api/ipfs-add // Body: { name, mimeType, base64 } a file // or { name, json } a JSON document (TZIP-21 metadata) +// or { files: [{ name, mimeType, base64 }] } a flat IPFS directory // Auth: AC Bearer token (must be admin) // Returns: { cid, uri: "ipfs://" } // @@ -11,8 +12,6 @@ // gateways so objkt's indexer finds the CID quickly. Lets off-box jobs (the // daily token on jasellite) pin without a third-party pinning plan. -import { authorize, hasAdmin } from "../../backend/authorization.mjs"; - const IPFS_API = process.env.IPFS_API_URL || "http://localhost:5001"; const IPFS_SEEDER_URL = process.env.IPFS_SEEDER_URL || "http://137.184.237.166:5001"; const PUBLIC_GATEWAYS = ["https://ipfs.io", "https://dweb.link", "https://nftstorage.link"]; @@ -27,7 +26,7 @@ function jsonResponse(statusCode, body) { return { statusCode, headers: CORS_HEADERS, body: JSON.stringify(body) }; } -export const handler = async (event) => { +export const createHandler = ({ authorize, hasAdmin, fetch = globalThis.fetch }) => async (event) => { if (event.httpMethod === "OPTIONS") { return { statusCode: 200, headers: CORS_HEADERS, body: "" }; } @@ -46,30 +45,46 @@ export const handler = async (event) => { return jsonResponse(400, { error: "Invalid JSON body" }); } - const name = String(body.name || "file").slice(0, 120); - let content, mimeType; - if (body.json !== undefined) { - content = Buffer.from(JSON.stringify(body.json)); - mimeType = "application/json"; - } else if (typeof body.base64 === "string") { - content = Buffer.from(body.base64, "base64"); - mimeType = String(body.mimeType || "application/octet-stream"); - } else { - return jsonResponse(400, { error: "Send { name, mimeType, base64 } or { name, json }" }); + if (!body || typeof body !== "object") return jsonResponse(400, { error: "Invalid body" }); + const directory = body.files !== undefined; + if (directory && (!Array.isArray(body.files) || !body.files.length || body.files.length > 32)) { + return jsonResponse(400, { error: "A directory needs 1–32 files" }); } - if (!content.length) return jsonResponse(400, { error: "Empty content" }); - const form = new FormData(); - form.append("file", new Blob([content], { type: mimeType }), name); + const names = new Set(); + let bytes = 0; + for (const file of directory ? body.files : [body]) { + if (!file || typeof file !== "object") return jsonResponse(400, { error: "Invalid file" }); + const name = String(file.name || "file").slice(0, 120); + if (directory && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) || names.has(name))) { + return jsonResponse(400, { error: "Directory filenames must be unique and have no paths" }); + } + names.add(name); + let content, mimeType; + if (file.json !== undefined) { + content = Buffer.from(JSON.stringify(file.json)); + mimeType = "application/json"; + } else if (typeof file.base64 === "string") { + content = Buffer.from(file.base64, "base64"); + mimeType = String(file.mimeType || "application/octet-stream"); + } else { + return jsonResponse(400, { error: "Send { name, mimeType, base64 }, { name, json }, or { files: [...] }" }); + } + if (!content.length) return jsonResponse(400, { error: "Empty content" }); + bytes += content.length; + form.append("file", new Blob([content], { type: mimeType }), name); + } let cid; try { - const res = await fetch(`${IPFS_API}/api/v0/add?pin=true&cid-version=0`, { + const res = await fetch(`${IPFS_API}/api/v0/add?pin=true&cid-version=0${directory ? "&wrap-with-directory=true" : ""}`, { method: "POST", body: form, signal: AbortSignal.timeout(120000), }); if (!res.ok) throw new Error(`IPFS add ${res.status}`); - cid = (await res.json()).Hash; + const entries = (await res.text()).trim().split("\n").map(line => JSON.parse(line)); + cid = (directory ? entries.find(entry => entry.Name === "") : entries[0])?.Hash; + if (!/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/.test(cid || "")) throw new Error("IPFS did not return the requested file or directory CID"); } catch (err) { return jsonResponse(502, { error: err.message }); } @@ -80,6 +95,11 @@ export const handler = async (event) => { fetch(`${gw}/ipfs/${cid}`, { headers: { Range: "bytes=0-0" }, signal: AbortSignal.timeout(20000) }).catch(() => {}); } - console.log(`📌 ipfs-add ${name} (${mimeType}, ${content.length} bytes) → ${cid}`); + console.log(`📌 ipfs-add ${[...names].join(", ")} (${bytes} bytes${directory ? ", directory" : ""}) → ${cid}`); return jsonResponse(200, { cid, uri: `ipfs://${cid}` }); }; + +export const handler = async event => { + const { authorize, hasAdmin } = await import("../../backend/authorization.mjs"); + return createHandler({ authorize, hasAdmin })(event); +}; diff --git a/system/tests/ipfs-add.test.mjs b/system/tests/ipfs-add.test.mjs new file mode 100644 index 0000000000..dcf85771ac --- /dev/null +++ b/system/tests/ipfs-add.test.mjs @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHandler } from "../netlify/functions/ipfs-add.mjs"; + +const fileCid = "Qm" + "a".repeat(44), rootCid = "Qm" + "b".repeat(44); +const file = { name: "index.html", mimeType: "text/html", base64: Buffer.from("").toString("base64") }; +const event = body => ({ httpMethod: "POST", headers: {}, body: JSON.stringify(body) }); +function fixture({ response, user = {}, admin = true } = {}) { + const requests = []; + const handler = createHandler({ + authorize: async () => user, hasAdmin: async () => admin, + fetch: async (url, options) => { + requests.push({ url, options }); + return new Response(response || JSON.stringify({ Name: "index.html", Hash: fileCid }) + "\n" + JSON.stringify({ Name: "", Hash: rootCid }) + "\n"); + }, + }); + return { handler, requests }; +} + +test("directory pin returns the wrapping CID and preserves ZIP filenames and bytes", async () => { + const { handler, requests } = fixture(); + const files = [file, { ...file, name: "cover.gif", mimeType: "image/gif" }, { ...file, name: "thumbnail.png", mimeType: "image/png" }]; + const result = await handler(event({ files })); + assert.equal(result.statusCode, 200); + assert.deepEqual(JSON.parse(result.body), { cid: rootCid, uri: `ipfs://${rootCid}` }); + assert.match(requests[0].url, /wrap-with-directory=true/); + const uploaded = requests[0].options.body.getAll("file"); + assert.deepEqual(uploaded.map(f => f.name), files.map(f => f.name)); + assert.deepEqual(uploaded.map(f => f.type), files.map(f => f.mimeType)); + for (const f of uploaded) assert.equal(await f.text(), ""); + assert.ok(requests.some(r => r.url.includes(`/pin/add?arg=${rootCid}`))); +}); + +test("single files and JSON metadata still use file CIDs", async () => { + for (const body of [file, { name: "metadata.json", json: { name: "Daily" } }]) { + const { handler, requests } = fixture({ response: JSON.stringify({ Hash: fileCid }) }); + const result = await handler(event(body)); + assert.equal(JSON.parse(result.body).uri, `ipfs://${fileCid}`); + assert.doesNotMatch(requests[0].url, /wrap-with-directory/); + } +}); + +test("a missing wrapping CID fails instead of minting the index.html file CID", async () => { + const { handler, requests } = fixture({ response: JSON.stringify({ Name: "index.html", Hash: fileCid }) }); + assert.equal((await handler(event({ files: [file] }))).statusCode, 502); + assert.equal(requests.length, 1); +}); + +test("directory uploads retain admin authorization and reject ambiguous filenames", async () => { + for (const [options, status] of [[{ user: null }, 401], [{ admin: false }, 403]]) { + const { handler, requests } = fixture(options); + assert.equal((await handler(event({ files: [file] }))).statusCode, status); + assert.equal(requests.length, 0); + } + for (const files of [[], [file, file], [{ ...file, name: "../index.html" }], [null]]) { + const { handler, requests } = fixture(); + assert.equal((await handler(event({ files }))).statusCode, 400); + assert.equal(requests.length, 0); + } +});