clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

media: NIP-96 door with NIP-94 metadata onto the same bucket and table

Dami committed 317c75b
GET /.well-known/nostr/nip96.json advertises the api at /nip96 with the Blossom host as download_url and a free plan sized by maxBlobMB. POST /nip96 takes a multipart upload under NIP-98, where the payload tag is the file's hash in hex or base64 if present, runs the same upload gate and blocked-hash check as Blossom, stores through the shared helper, and answers with the nip94_event tags and the Blossom URL. GET /nip96 lists the caller's files by page, DELETE /nip96/<sha256> is for the uploader or the storage role, and GET /nip96/<sha256> serves the blob as Blossom does. NIP-96 is marked unrecommended upstream in favour of Blossom, so this stays a thin adapter and is not advertised in NIP-11.
commit
317c75b6635da3d04bbdbde3ec0173633deac495
parent
1e22d59adc7a94163807609eea546a2657edec2c
author
Dami <dami.etoile@gmail.com>
3 files changed, +208 -2
Asrc/nip96.ts114++++++++++++++++++++++++++++++
Msrc/relay.ts2+
Mtest/media.test.ts94++++++++++++++++++++++++-
A src/nip96.ts +114 -0
@@ -0,0 +1,114 @@
1+// NIP-96 HTTP file storage with NIP-94 metadata: a second door for clients2+// that do not speak Blossom. Same bucket, same blobs table, same gates; the3+// file URL is the Blossom one, so a file uploaded through either door is4+// served, listed and deleted through both. NIP-96 is marked unrecommended5+// upstream in favour of Blossom, which is why this stays a thin adapter.6+//7+// GET /.well-known/nostr/nip96.json discovery8+// POST /nip96 multipart, NIP-98 -> nip94_event9+// GET /nip96?page=&count= NIP-98, the caller's files10+// GET /nip96/<sha256>[.ext] the blob, as Blossom serves it11+// DELETE /nip96/<sha256>[.ext] NIP-98; uploader or storage role12+import { sha256 } from "@noble/hashes/sha2.js";13+import { tagValues } from "./event.ts";14+import { bytesToHex } from "./negentropy.ts";15+import { verifyNIP98 } from "./manage.ts";16+import { can } from "./roles.ts";17+import { EXT, blobBlocked, blobURL, blossom, nip94Tags, storeBlob, type Blob } from "./blossom.ts";18+import type { Relay } from "./relay.ts";19+20+const SHA_RE = /^\/nip96\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/;21+const MAX_PAGE = 100;22+23+export async function nip96(relay: Relay, req: Request): Promise<Response> {24+ const url = new URL(req.url);25+ const host = url.host;26+ const now = Math.floor(Date.now() / 1000);27+ const cors = { "access-control-allow-origin": "*", "access-control-allow-headers": "authorization, content-type", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS" };28+ const json = (b: unknown, status = 200, extra: Record<string, string> = {}) =>29+ new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", ...cors, ...extra } });30+ const fail = (message: string, status: number) => json({ status: "error", message }, status, { "x-reason": message });31+32+ if (req.method === "OPTIONS") return new Response(null, { headers: cors });33+34+ if (url.pathname === "/.well-known/nostr/nip96.json") {35+ return json({36+ api_url: `https://${host}/nip96`,37+ download_url: `https://${host}`,38+ supported_nips: [94, 98],39+ content_types: Object.keys(EXT),40+ plans: {41+ free: { name: "Free", is_nip98_required: true, max_byte_size: relay.settings.policy.maxBlobMB * 1024 * 1024, file_expiration: [0, 0], media_transformations: {} },42+ },43+ });44+ }45+46+ // Downloads are the Blossom path with a different prefix.47+ const m = SHA_RE.exec(url.pathname);48+ if (m && (req.method === "GET" || req.method === "HEAD")) {49+ const target = new URL(req.url);50+ target.pathname = url.pathname.slice("/nip96".length);51+ return blossom(relay, new Request(target.toString(), req));52+ }53+54+ const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");55+ if (typeof auth === "string") return fail(auth, 401);56+57+ if (m && req.method === "DELETE") {58+ const sha = m[1];59+ const blob = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];60+ if (!blob) return fail("not found", 404);61+ if (blob.uploader !== auth.pubkey && !can(relay.settings.roleOf(auth.pubkey), "storage")) return fail("restricted: not the uploader", 403);62+ await relay.deleteBlob(sha);63+ return json({ status: "success", message: "File deleted." });64+ }65+66+ if (url.pathname !== "/nip96") return fail("not found", 404);67+68+ if (req.method === "GET") {69+ const count = Math.max(1, Math.min(MAX_PAGE, Number(url.searchParams.get("count")) || 20));70+ const page = Math.max(0, Math.floor(Number(url.searchParams.get("page")) || 0));71+ const total = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM blobs WHERE uploader=?`, auth.pubkey).one().n;72+ const rows = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE uploader=? ORDER BY uploaded DESC LIMIT ? OFFSET ?`, auth.pubkey, count, page * count).toArray();73+ return json({ count, total, page, files: rows.map((b) => ({ tags: nip94Tags(host, b), content: "", created_at: b.uploaded })) });74+ }75+76+ if (req.method !== "POST") return fail("method not allowed", 405);77+ const gate = relay.mayUpload(auth.pubkey, host);78+ if (gate) return fail(gate, 403);79+ const max = relay.settings.policy.maxBlobMB * 1024 * 1024;80+ const tooBig = `invalid: file exceeds ${relay.settings.policy.maxBlobMB} MB`;81+ if (Number(req.headers.get("content-length")) > max) return fail(tooBig, 413);82+ let form: FormData;83+ try {84+ form = await req.formData();85+ } catch {86+ return fail("invalid: body is not multipart form data", 400);87+ }88+ const file = form.get("file");89+ if (!(file instanceof File)) return fail("invalid: form needs a file field", 400);90+ const declared = String(form.get("size") ?? "");91+ if (declared !== "" && Number(declared) !== file.size) return fail("invalid: size field does not match the file", 400);92+ if (file.size === 0) return fail("invalid: empty file", 400);93+ if (file.size > max) return fail(tooBig, 413);94+ const body = new Uint8Array(await file.arrayBuffer());95+ const digest = sha256(body);96+ const sha = bytesToHex(digest);97+ // NIP-96 puts the file's hash in the token's payload tag, hex per NIP-9898+ // or base64 per NIP-96's own wording; either is accepted, a mismatch is not.99+ const payload = tagValues(auth, "payload")[0];100+ if (payload !== undefined && payload !== sha && payload !== btoa(String.fromCharCode(...digest))) return fail("restricted: token payload does not match the file", 403);101+ if (blobBlocked(relay, sha)) return fail("blocked: this file was removed by a moderator", 403);102+ const type = (file.type || String(form.get("content_type") ?? "") || "application/octet-stream").split(";")[0].trim().toLowerCase() || "application/octet-stream";103+ const { blob, created } = await storeBlob(relay, body, type, auth.pubkey, now);104+ relay.meterBytes(body.length, 0);105+ return json(106+ {107+ status: "success",108+ message: created ? "Upload successful." : "File already stored.",109+ nip94_event: { tags: nip94Tags(host, blob), content: String(form.get("caption") ?? "").slice(0, 2000) },110+ url: blobURL(host, blob),111+ },112+ created ? 201 : 200,113+ );114+}
M src/relay.ts +2 -0
M test/media.test.ts +92 -2
@@ -1,5 +1,6 @@
1-// Media beyond the Blossom basics: NIP-94 tags in descriptors (BUD-08) and2-// blob reports in the moderation queue (BUD-09).1+// Media beyond the Blossom basics: NIP-94 tags in descriptors (BUD-08), blob2+// reports in the moderation queue (BUD-09), and the NIP-96 door onto the3+// same bucket and table.34 import { SELF } from "cloudflare:test";45 import { describe, it, expect } from "vitest";56 import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure";
@@ -40,6 +41,16 @@ async function report(host: string, sk: Uint8Array, tags: string[][], content =
4041 return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };4142 }4243 44+async function nip96Upload(host: string, sk: Uint8Array, bytes: Uint8Array, opts: { type?: string; size?: string; payload?: string; caption?: string } = {}) {45+ const url = `http://${host}/nip96`;46+ const form = new FormData();47+ form.append("file", new File([bytes], "a.txt", { type: opts.type ?? "text/plain" }));48+ if (opts.size !== undefined) form.append("size", opts.size);49+ if (opts.caption) form.append("caption", opts.caption);50+ const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: nip98(sk, url, "POST", opts.payload) }, body: form });51+ return { status: resp.status, body: await resp.json<any>(), reason: resp.headers.get("x-reason") ?? "" };52+}53+4354 describe("BUD-08 nip94 tags", () => {4455 it("descriptors from upload and list carry the tags the relay can vouch for", async () => {4556 const host = "nip94.bind.ws";
@@ -88,5 +99,84 @@ describe("BUD-09 blob reports", () => {
8899 expect((await rpc(host, owner, "listreports")).result).toEqual([]);89100 const again = await upload(host, bob, "questionable");90101 expect([again.status, again.body.error.split(":")[0]]).toEqual([403, "blocked"]);102+ const viaNip96 = await nip96Upload(host, bob, new TextEncoder().encode("questionable"));103+ expect(viaNip96.status).toBe(403);104+ });105+});106+107+describe("NIP-96 door", () => {108+ it("advertises itself and shares the bucket, list and delete with Blossom", async () => {109+ const host = "nip96.bind.ws";110+ const owner = generateSecretKey();111+ const bob = generateSecretKey();112+ await rpc(host, owner, "claim");113+114+ const doc = await (await SELF.fetch(`http://${host}/.well-known/nostr/nip96.json`)).json<any>();115+ expect(doc.api_url).toBe(`https://${host}/nip96`);116+ expect(doc.download_url).toBe(`https://${host}`);117+ expect(doc.supported_nips).toContain(98);118+ expect(doc.plans.free.max_byte_size).toBe(25 * 1024 * 1024);119+ expect(doc.content_types).toContain("image/png");120+121+ const bytes = new TextEncoder().encode("through the other door");122+ const sha = bytesToHex(sha256(bytes));123+ let r = await nip96Upload(host, bob, bytes, { payload: sha, caption: "a caption", size: String(bytes.length) });124+ expect([r.status, r.body.status, r.body.message]).toEqual([201, "success", "Upload successful."]);125+ expect(r.body.nip94_event.tags).toEqual([["url", `https://${host}/${sha}.txt`], ["m", "text/plain"], ["x", sha], ["ox", sha], ["size", String(bytes.length)]]);126+ expect(r.body.nip94_event.content).toBe("a caption");127+ r = await nip96Upload(host, bob, bytes);128+ expect([r.status, r.body.message]).toEqual([200, "File already stored."]);129+130+ // Served through both doors, listed through both doors.131+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);132+ const viaNip96 = await SELF.fetch(`http://${host}/nip96/${sha}.txt`);133+ expect([viaNip96.status, await viaNip96.text()]).toEqual([200, "through the other door"]);134+ const listURL = `http://${host}/nip96?page=0&count=10`;135+ const list = await (await SELF.fetch(listURL, { headers: { authorization: nip98(bob, listURL, "GET") } })).json<any>();136+ expect([list.total, list.page, list.count]).toEqual([1, 0, 10]);137+ expect(list.files[0].tags).toEqual(r.body.nip94_event.tags);138+ expect((await rpc(host, owner, "listblobs")).result.map((b: any) => b.sha256)).toEqual([sha]);139+ expect((await SELF.fetch(`http://${host}/nip96`)).status).toBe(401);140+141+ // Refusals: wrong payload, wrong size field, no file, stranger deleting.142+ expect((await nip96Upload(host, bob, new TextEncoder().encode("other"), { payload: sha })).status).toBe(403);143+ expect((await nip96Upload(host, bob, bytes, { size: "3" })).status).toBe(400);144+ const empty = new FormData();145+ empty.append("caption", "no file");146+ expect((await SELF.fetch(`http://${host}/nip96`, { method: "POST", headers: { authorization: nip98(bob, `http://${host}/nip96`, "POST") }, body: empty })).status).toBe(400);147+ const stranger = generateSecretKey();148+ const delURL = `http://${host}/nip96/${sha}`;149+ expect((await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(stranger, delURL, "DELETE") } })).status).toBe(403);150+ // The owner has the storage role; the uploader has the file.151+ const del = await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(bob, delURL, "DELETE") } });152+ expect([del.status, (await del.json<any>()).status]).toEqual([200, "success"]);153+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);154+ expect((await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(owner, delURL, "DELETE") } })).status).toBe(404);155+ expect((await rpc(host, owner, "listblobs")).result).toEqual([]);156+157+ // A Blossom upload shows up in the NIP-96 listing and the owner can delete it there.158+ const up = await upload(host, bob, "blossom side");159+ const again = await (await SELF.fetch(listURL, { headers: { authorization: nip98(bob, listURL, "GET") } })).json<any>();160+ expect(again.files.map((f: any) => f.tags.find((t: string[]) => t[0] === "x")[1])).toEqual([up.sha]);161+ const ownerDel = `http://${host}/nip96/${up.sha}`;162+ expect((await SELF.fetch(ownerDel, { method: "DELETE", headers: { authorization: nip98(owner, ownerDel, "DELETE") } })).status).toBe(200);163+ });164+165+ it("applies the same gates as Blossom", async () => {166+ const bob = generateSecretKey();167+ const bytes = new TextEncoder().encode("gated");168+ let r = await nip96Upload("nip96-unclaimed.bind.ws", bob, bytes);169+ expect([r.status, r.reason.split(":")[0]]).toEqual([403, "restricted"]);170+171+ const host = "nip96-gates.bind.ws";172+ const owner = generateSecretKey();173+ await rpc(host, owner, "claim");174+ await rpc(host, owner, "banpubkey", getPublicKey(bob), "no");175+ r = await nip96Upload(host, bob, bytes);176+ expect([r.status, r.reason.split(":")[0]]).toEqual([403, "blocked"]);177+ await rpc(host, owner, "setpolicy", { maxBlobMB: 1 });178+ r = await nip96Upload(host, owner, new Uint8Array(1024 * 1024 + 1));179+ expect(r.status).toBe(413);180+ expect((await SELF.fetch(`http://${host}/.well-known/nostr/nip96.json`).then((x) => x.json<any>())).plans.free.max_byte_size).toBe(1024 * 1024);91181 });92182 });