clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / test / object / media.test.ts
media.test.ts 18.3K
1// Files: Blossom upload, fetch, list and delete, BUD-06 upload requirements,2// BUD-04 mirror, NIP-94 tags in descriptors (BUD-08), blob reports (BUD-09),3// and the NIP-96 door onto the same bucket and table.4import { SELF, env, runInDurableObject } from "cloudflare:test";5import { describe, it, expect } from "vitest";6import { generateSecretKey, getPublicKey } from "nostr-tools/pure";7import { sha256 } from "@noble/hashes/sha2.js";8import type { Relay } from "../../src/relay.ts";9import { bytesToHex } from "../../src/negentropy.ts";10import { now, ev, rpc } from "../helpers/relay.ts";11import { blossomToken, upload } from "../helpers/media.ts";1213async function head(host: string, headers: Record<string, string>) {14 const resp = await SELF.fetch(`http://${host}/upload`, { method: "HEAD", headers });15 return { status: resp.status, reason: resp.headers.get("x-reason") ?? "", text: await resp.text() };16}1718async function mirror(host: string, sk: Uint8Array, url: unknown, sha?: string, raw?: string) {19 const resp = await SELF.fetch(`http://${host}/mirror`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", sha), "content-type": "application/json" }, body: raw ?? JSON.stringify({ url }) });20 const text = await resp.text();21 return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };22}2324// nip98 signs a token by hand so the payload tag can be the file hash, as25// NIP-96 wants, rather than the hash of a JSON body.26const nip98 = (sk: Uint8Array, url: string, method: string, payload?: string) =>27 "Nostr " + btoa(JSON.stringify(ev(sk, 27235, "", [["u", url], ["method", method], ...(payload ? [["payload", payload]] : [])])));2829async function report(host: string, sk: Uint8Array, tags: string[][], content = "not ok", raw?: string) {30 const body = raw ?? JSON.stringify(ev(sk, 1984, content, tags));31 const resp = await SELF.fetch(`http://${host}/report`, { method: "PUT", headers: { "content-type": "application/json" }, body });32 const text = await resp.text();33 return { status: resp.status, body: text ? JSON.parse(text) : null, reason: resp.headers.get("x-reason") ?? "" };34}3536async function nip96Upload(host: string, sk: Uint8Array, bytes: Uint8Array, opts: { type?: string; size?: string; payload?: string; caption?: string } = {}) {37 const url = `http://${host}/nip96`;38 const form = new FormData();39 form.append("file", new File([bytes], "a.txt", { type: opts.type ?? "text/plain" }));40 if (opts.size !== undefined) form.append("size", opts.size);41 if (opts.caption) form.append("caption", opts.caption);42 const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: nip98(sk, url, "POST", opts.payload) }, body: form });43 return { status: resp.status, body: await resp.json<any>(), reason: resp.headers.get("x-reason") ?? "" };44}4546describe("Blossom media", () => {47 const blossomToken = (sk: Uint8Array, action: string, sha: string) =>48 "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ["x", sha], ["expiration", String(now() + 300)]])));4950 it("stores, serves, lists and deletes blobs under the relay's write policy, counting them as storage", async () => {51 const host = "media.bind.ws";52 const owner = generateSecretKey();53 const member = generateSecretKey();54 const stranger = generateSecretKey();55 await rpc(host, owner, "claim");56 await rpc(host, owner, "setpolicy", { writes: "allowlist", maxBlobMB: 1 });57 await rpc(host, owner, "allowpubkey", getPublicKey(member));58 const body = new TextEncoder().encode("hello blossom " + host);59 const sha = bytesToHex(sha256(body));6061 const put = (sk: Uint8Array, data: Uint8Array, shaTag = sha, type = "text/plain") =>62 SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossomToken(sk, "upload", shaTag), "content-type": type }, body: data });63 expect((await put(stranger, body)).status).toBe(403);64 expect((await SELF.fetch(`http://${host}/upload`, { method: "PUT", body })).status).toBe(401);65 expect((await put(member, body, "00".repeat(32))).status).toBe(400);66 let resp = await put(member, body);67 expect(resp.status).toBe(200);68 const desc: any = await resp.json();69 expect(desc).toMatchObject({ sha256: sha, size: body.length, type: "text/plain", url: `https://${host}/${sha}.txt` });70 expect((await put(member, new Uint8Array(1024 * 1024 + 1), "ff".repeat(32))).status).toBe(413);7172 resp = await SELF.fetch(`http://${host}/${sha}.txt`);73 expect(resp.status).toBe(200);74 expect(resp.headers.get("content-type")).toBe("text/plain");75 expect(await resp.text()).toBe("hello blossom " + host);76 resp = await SELF.fetch(`http://${host}/${sha}`, { method: "HEAD" });77 expect(resp.headers.get("content-length")).toBe(String(body.length));78 resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { range: "bytes=0-4" } });79 expect(resp.status).toBe(206);80 expect(await resp.text()).toBe("hello");81 expect((await SELF.fetch(`http://${host}/${"ab".repeat(32)}`)).status).toBe(404);8283 const list: any = await (await SELF.fetch(`http://${host}/list/${getPublicKey(member)}`)).json();84 expect(list.map((b: any) => b.sha256)).toEqual([sha]);85 const stub = env.RELAY.getByName("media");86 await runInDurableObject(stub, async (r: Relay) => expect(r.mediaBytes()).toBeGreaterThan(0));8788 const del = (sk: Uint8Array) => SELF.fetch(`http://${host}/${sha}`, { method: "DELETE", headers: { authorization: blossomToken(sk, "delete", sha) } });89 expect((await del(stranger)).status).toBe(403);90 expect((await del(member)).status).toBe(204);91 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);92 expect((await rpc(host, owner, "listblobs")).result).toEqual([]);93 });94});9596describe("BUD-06 upload requirements", () => {97 it("answers HEAD /upload from the headers and the policy, with no body", async () => {98 const host = "buds-head.bind.ws";99 const owner = generateSecretKey();100 const stranger = generateSecretKey();101 await rpc(host, owner, "claim");102 const bytes = new TextEncoder().encode("a small file");103 const sha = bytesToHex(sha256(bytes));104 const ok = { "x-sha-256": sha, "x-content-type": "text/plain", "x-content-length": String(bytes.length), authorization: blossomToken(owner, "upload", sha) };105106 let r = await head(host, ok);107 expect([r.status, r.reason, r.text]).toEqual([200, "", ""]);108 r = await head(host, { ...ok, "x-sha-256": "nope" });109 expect([r.status, r.reason.split(":")[0]]).toEqual([400, "invalid"]);110 const { "x-content-length": _, ...noLength } = ok;111 r = await head(host, noLength);112 expect(r.status).toBe(411);113 r = await head(host, { ...ok, "x-content-length": "twelve" });114 expect(r.status).toBe(400);115 const { authorization: __, ...noAuth } = ok;116 r = await head(host, noAuth);117 expect(r.status).toBe(401);118 expect(r.reason).toMatch(/^auth-required/);119 r = await head(host, { ...ok, "x-content-length": String(26 * 1024 * 1024) });120 expect(r.status).toBe(413);121 expect(r.reason).toMatch(/25 MB/);122 r = await head(host, { ...ok, authorization: blossomToken(owner, "upload", "ab".repeat(32)) });123 expect(r.status).toBe(400);124125 await rpc(host, owner, "setpolicy", { writes: "owner" });126 r = await head(host, { ...ok, authorization: blossomToken(stranger, "upload", sha) });127 expect(r.status).toBe(403);128 expect(r.reason).toMatch(/^restricted/);129130 // Already stored is still a 200: the PUT would answer with the descriptor.131 expect((await upload(host, owner, "a small file")).status).toBe(200);132 expect((await head(host, ok)).status).toBe(200);133 });134});135136describe("BUD-04 mirror", () => {137 it("copies a blob from another relay on this host, once", async () => {138 const a = "buds-src.bind.ws";139 const b = "buds-dst.bind.ws";140 const alice = generateSecretKey();141 const bob = generateSecretKey();142 await rpc(a, alice, "claim");143 await rpc(b, bob, "claim");144 const up = await upload(a, alice, "the same bytes, elsewhere");145 expect(up.status).toBe(200);146 expect(up.body.url).toBe(`https://${a}/${up.sha}.txt`);147148 let r = await mirror(b, bob, up.body.url, up.sha);149 expect(r.status, r.reason).toBe(201);150 expect(r.body).toEqual({ url: `https://${b}/${up.sha}.txt`, sha256: up.sha, size: up.body.size, type: "text/plain", uploaded: expect.any(Number), nip94: expect.any(Array) });151 const got = await SELF.fetch(`http://${b}/${up.sha}`);152 expect(got.status).toBe(200);153 expect(await got.text()).toBe("the same bytes, elsewhere");154 const list = (await rpc(b, bob, "listblobs")).result;155 expect(list.map((x: any) => [x.sha256, x.uploader])).toEqual([[up.sha, getPublicKey(bob)]]);156157 r = await mirror(b, bob, up.body.url, up.sha);158 expect(r.status).toBe(200);159 expect(r.body.sha256).toBe(up.sha);160 expect((await rpc(b, bob, "listblobs")).result.length).toBe(1);161 });162163 it("refuses what it should: wrong hash, bad body, bad url, no auth, policy", async () => {164 const a = "buds-src2.bind.ws";165 const b = "buds-dst2.bind.ws";166 const alice = generateSecretKey();167 const bob = generateSecretKey();168 const stranger = generateSecretKey();169 await rpc(a, alice, "claim");170 await rpc(b, bob, "claim");171 const up = await upload(a, alice, "bytes to mirror");172173 let r = await mirror(b, bob, up.body.url, "ab".repeat(32));174 expect([r.status, r.reason.split(":")[0]]).toEqual([409, "invalid"]);175 expect((await rpc(b, bob, "listblobs")).result).toEqual([]);176 r = await mirror(b, bob, undefined, undefined, "not json");177 expect(r.status).toBe(400);178 r = await mirror(b, bob, "");179 expect(r.status).toBe(400);180 r = await mirror(b, bob, "ftp:/nope");181 expect(r.status).toBe(400);182 r = await mirror(b, bob, "http://example.com/" + up.sha);183 expect([r.status, r.reason]).toEqual([400, "invalid: only https urls can be mirrored"]);184 r = await mirror(b, bob, `https://${a}/${"cd".repeat(32)}`);185 expect([r.status, r.reason]).toEqual([502, "error: the origin answered 404"]);186 const noAuth = await SELF.fetch(`http://${b}/mirror`, { method: "PUT", body: JSON.stringify({ url: up.body.url }) });187 expect(noAuth.status).toBe(401);188189 await rpc(b, bob, "setpolicy", { writes: "owner" });190 r = await mirror(b, stranger, up.body.url, up.sha);191 expect(r.status).toBe(403);192 await rpc(b, bob, "setpolicy", { writes: "open", maxBlobMB: 1 });193 // The origin declares its size, so an oversized blob is refused before any bytes move.194 await rpc(a, alice, "setpolicy", { maxBlobMB: 2 });195 const big = await SELF.fetch(`http://${a}/upload`, { method: "PUT", headers: { authorization: blossomToken(alice, "upload"), "content-type": "application/octet-stream" }, body: new Uint8Array(1024 * 1024 + 1) });196 expect(big.status).toBe(200);197 r = await mirror(b, bob, (await big.json<any>()).url);198 expect(r.status).toBe(413);199 });200201});202203describe("BUD-08 nip94 tags", () => {204 it("descriptors from upload and list carry the tags the relay can vouch for", async () => {205 const host = "nip94.bind.ws";206 const owner = generateSecretKey();207 await rpc(host, owner, "claim");208 const up = await upload(host, owner, "tagged bytes");209 expect(up.status).toBe(200);210 const url = `https://${host}/${up.sha}.txt`;211 expect(up.body.nip94).toEqual([["url", url], ["m", "text/plain"], ["x", up.sha], ["ox", up.sha], ["size", "12"]]);212 const list = await (await SELF.fetch(`http://${host}/list/${getPublicKey(owner)}`)).json<any>();213 expect(list[0].nip94).toEqual(up.body.nip94);214 });215});216217describe("BUD-09 blob reports", () => {218 it("files a report per held blob into the moderation queue, and deleting blocks the hash", async () => {219 const host = "blobreport.bind.ws";220 const owner = generateSecretKey();221 const bob = generateSecretKey();222 const carol = generateSecretKey();223 await rpc(host, owner, "claim");224 const up = await upload(host, bob, "questionable");225 expect(up.status).toBe(200);226 const unknown = "ab".repeat(32);227228 let r = await report(host, carol, [["x", up.sha, "nudity"], ["x", unknown, "spam"]]);229 expect([r.status, r.body]).toEqual([200, { ok: true, filed: 1 }]);230 const open = (await rpc(host, owner, "listreports")).result;231 expect(open.length).toBe(1);232 expect(open[0]).toMatchObject({ reporter: getPublicKey(carol), target_pubkey: getPublicKey(bob), target_event: up.sha, type: "nudity", content: "not ok", blob: 1 });233234 r = await report(host, carol, [["x", unknown, "spam"]]);235 expect([r.status, r.reason.split(":")[0]]).toEqual([404, "not found"]);236 r = await report(host, carol, [["e", up.sha]]);237 expect(r.status).toBe(400);238 const forged = ev(carol, 1984, "signed", [["x", up.sha]]);239 forged.content = "tampered";240 r = await report(host, carol, [], "", JSON.stringify(forged));241 expect([r.status, r.reason.split(":")[0]]).toEqual([400, "invalid"]);242 r = await report(host, carol, [["x", up.sha]], "", JSON.stringify(ev(carol, 1, "", [["x", up.sha]])));243 expect(r.status).toBe(400);244 expect((await report("nobody-home.bind.ws", carol, [["x", up.sha]])).status).toBe(403);245246 expect((await rpc(host, owner, "resolvereport", open[0].id, "delete")).result).toBe(true);247 expect((await SELF.fetch(`http://${host}/${up.sha}`)).status).toBe(404);248 expect((await rpc(host, owner, "listreports")).result).toEqual([]);249 const again = await upload(host, bob, "questionable");250 expect([again.status, again.body.error.split(":")[0]]).toEqual([403, "blocked"]);251 const viaNip96 = await nip96Upload(host, bob, new TextEncoder().encode("questionable"));252 expect(viaNip96.status).toBe(403);253 });254});255256describe("NIP-96 door", () => {257 it("advertises itself and shares the bucket, list and delete with Blossom", async () => {258 const host = "nip96.bind.ws";259 const owner = generateSecretKey();260 const bob = generateSecretKey();261 await rpc(host, owner, "claim");262263 const doc = await (await SELF.fetch(`http://${host}/.well-known/nostr/nip96.json`)).json<any>();264 expect(doc.api_url).toBe(`https://${host}/nip96`);265 expect(doc.download_url).toBe(`https://${host}`);266 expect(doc.supported_nips).toContain(98);267 expect(doc.plans.free.max_byte_size).toBe(25 * 1024 * 1024);268 expect(doc.content_types).toContain("image/png");269270 const bytes = new TextEncoder().encode("through the other door");271 const sha = bytesToHex(sha256(bytes));272 let r = await nip96Upload(host, bob, bytes, { payload: sha, caption: "a caption", size: String(bytes.length) });273 expect([r.status, r.body.status, r.body.message]).toEqual([201, "success", "Upload successful."]);274 expect(r.body.nip94_event.tags).toEqual([["url", `https://${host}/${sha}.txt`], ["m", "text/plain"], ["x", sha], ["ox", sha], ["size", String(bytes.length)]]);275 expect(r.body.nip94_event.content).toBe("a caption");276 r = await nip96Upload(host, bob, bytes);277 expect([r.status, r.body.message]).toEqual([200, "File already stored."]);278279 // Served through both doors, listed through both doors.280 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);281 const viaNip96 = await SELF.fetch(`http://${host}/nip96/${sha}.txt`);282 expect([viaNip96.status, await viaNip96.text()]).toEqual([200, "through the other door"]);283 const listURL = `http://${host}/nip96?page=0&count=10`;284 const list = await (await SELF.fetch(listURL, { headers: { authorization: nip98(bob, listURL, "GET") } })).json<any>();285 expect([list.total, list.page, list.count]).toEqual([1, 0, 10]);286 expect(list.files[0].tags).toEqual(r.body.nip94_event.tags);287 expect((await rpc(host, owner, "listblobs")).result.map((b: any) => b.sha256)).toEqual([sha]);288 expect((await SELF.fetch(`http://${host}/nip96`)).status).toBe(401);289290 // Refusals: wrong payload, wrong size field, no file, stranger deleting.291 expect((await nip96Upload(host, bob, new TextEncoder().encode("other"), { payload: sha })).status).toBe(403);292 expect((await nip96Upload(host, bob, bytes, { size: "3" })).status).toBe(400);293 const empty = new FormData();294 empty.append("caption", "no file");295 expect((await SELF.fetch(`http://${host}/nip96`, { method: "POST", headers: { authorization: nip98(bob, `http://${host}/nip96`, "POST") }, body: empty })).status).toBe(400);296 const stranger = generateSecretKey();297 const delURL = `http://${host}/nip96/${sha}`;298 expect((await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(stranger, delURL, "DELETE") } })).status).toBe(403);299 // The owner has the storage role; the uploader has the file.300 const del = await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(bob, delURL, "DELETE") } });301 expect([del.status, (await del.json<any>()).status]).toEqual([200, "success"]);302 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);303 expect((await SELF.fetch(delURL, { method: "DELETE", headers: { authorization: nip98(owner, delURL, "DELETE") } })).status).toBe(404);304 expect((await rpc(host, owner, "listblobs")).result).toEqual([]);305306 // A Blossom upload shows up in the NIP-96 listing and the owner can delete it there.307 const up = await upload(host, bob, "blossom side");308 const again = await (await SELF.fetch(listURL, { headers: { authorization: nip98(bob, listURL, "GET") } })).json<any>();309 expect(again.files.map((f: any) => f.tags.find((t: string[]) => t[0] === "x")[1])).toEqual([up.sha]);310 const ownerDel = `http://${host}/nip96/${up.sha}`;311 expect((await SELF.fetch(ownerDel, { method: "DELETE", headers: { authorization: nip98(owner, ownerDel, "DELETE") } })).status).toBe(200);312 });313314 it("applies the same gates as Blossom", async () => {315 const bob = generateSecretKey();316 const bytes = new TextEncoder().encode("gated");317 let r = await nip96Upload("nip96-unclaimed.bind.ws", bob, bytes);318 expect([r.status, r.reason.split(":")[0]]).toEqual([403, "restricted"]);319320 const host = "nip96-gates.bind.ws";321 const owner = generateSecretKey();322 await rpc(host, owner, "claim");323 await rpc(host, owner, "banpubkey", getPublicKey(bob), "no");324 r = await nip96Upload(host, bob, bytes);325 expect([r.status, r.reason.split(":")[0]]).toEqual([403, "blocked"]);326 await rpc(host, owner, "setpolicy", { maxBlobMB: 1 });327 r = await nip96Upload(host, owner, new Uint8Array(1024 * 1024 + 1));328 expect(r.status).toBe(413);329 expect((await SELF.fetch(`http://${host}/.well-known/nostr/nip96.json`).then((x) => x.json<any>())).plans.free.max_byte_size).toBe(1024 * 1024);330 });331});