clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / conformance / blossom.test.ts
blossom.test.ts 10.0K
1// Files, from outside: a Blossom upload, the download with and without a2// range, the listing and the delete. The one door that touches the media3// bucket, so a host whose bucket is not R2 (docs/16) is checked here.4import { describe, it, expect } from "vitest";5import { sha256 } from "@noble/hashes/sha2.js";6import { newEvent, newKey, pub, now, rand, hex, HTTP_URL } from "./helpers.ts";78// A Blossom token: kind 24242 with the verb and the hash, base64 in the header.9function token(sk: Uint8Array, verb: "upload" | "get" | "list" | "delete", sha?: string): string {10 const tags = [["t", verb], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]];11 return "Nostr " + Buffer.from(JSON.stringify(newEvent(sk, 24242, verb, tags))).toString("base64");12}1314describe("Blossom media", () => {15 it("advertises the BUD-06 browser and upload preflight contract", async () => {16 const preflight = await fetch(`${HTTP_URL}/upload`, {17 method: "OPTIONS",18 headers: {19 origin: "https://client.example",20 "access-control-request-method": "PUT",21 "access-control-request-headers": "authorization, content-type, x-content-length, x-content-type",22 },23 });24 expect(preflight.status).toBe(204);25 expect(preflight.headers.get("access-control-allow-origin")).toBe("*");26 expect(preflight.headers.get("access-control-allow-methods")).toContain("PUT");27 expect(preflight.headers.get("access-control-allow-headers")?.toLowerCase()).toContain("x-content-type");2829 const sk = newKey();30 const body = new TextEncoder().encode(`preflight ${rand()}`);31 const sha = hex(sha256(body));32 const head = await fetch(`${HTTP_URL}/upload`, {33 method: "HEAD",34 headers: {35 authorization: token(sk, "upload", sha),36 "x-sha-256": sha,37 "x-content-length": String(body.length),38 "x-content-type": "text/plain",39 },40 });41 expect(head.status).toBe(200);42 expect(await head.text()).toBe("");43 });4445 it("accepts a BUD-13 hash-addressed upload and rejects a mismatched hash", async () => {46 const sk = newKey();47 const body = new TextEncoder().encode(`path upload ${rand()}`);48 const sha = hex(sha256(body));49 const upload = await fetch(`${HTTP_URL}/${sha}.txt`, {50 method: "PUT",51 headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" },52 body,53 });54 expect(upload.status, await upload.clone().text()).toBe(201);55 expect((await upload.json<any>()).sha256).toBe(sha);5657 const mismatchHash = "0".repeat(64);58 const mismatch = await fetch(`${HTTP_URL}/${mismatchHash}`, {59 method: "PUT",60 headers: { authorization: token(sk, "upload", mismatchHash), "content-type": "text/plain" },61 body,62 });63 expect(mismatch.status).toBe(409);6465 const del = await fetch(`${HTTP_URL}/${sha}`, {66 method: "DELETE",67 headers: { authorization: token(sk, "delete", sha) },68 });69 expect(del.status).toBe(204);70 });7172 it("advertises and completes BUD-14 uploads out of order with overlap", async () => {73 const sk = newKey();74 const body = new TextEncoder().encode(`bud14 ${rand()} multipart body`);75 const sha = hex(sha256(body));76 const split = Math.floor(body.length / 2);7778 const preflight = await fetch(`${HTTP_URL}/${sha}.bin`, {79 method: "OPTIONS",80 headers: {81 origin: "https://client.example",82 "access-control-request-method": "PATCH",83 "access-control-request-headers": "authorization, content-type, upload-type, upload-length, upload-offset",84 },85 });86 expect(preflight.status).toBe(204);87 expect(preflight.headers.get("allow")).toContain("PATCH");88 expect(preflight.headers.get("access-control-allow-origin")).toBe("*");89 expect(preflight.headers.get("access-control-allow-methods")).toContain("PATCH");9091 // The authorization is scoped to the final hash. It is reused for both92 // chunks; individual chunk hashes must not be required by BUD-14.93 const auth = token(sk, "upload", sha);94 const secondOffset = Math.max(1, split - 2);95 const second = body.slice(secondOffset);96 const first = body.slice(0, split + 2);97 const send = (offset: number, chunk: Uint8Array) => fetch(`${HTTP_URL}/${sha}.bin`, {98 method: "PATCH",99 headers: {100 authorization: auth,101 "content-type": "application/octet-stream",102 "upload-type": "text/plain",103 "upload-length": String(body.length),104 "upload-offset": String(offset),105 "content-length": String(chunk.length),106 },107 body: chunk,108 });109110 const chunkScoped = await fetch(`${HTTP_URL}/${sha}.bin`, {111 method: "PATCH",112 headers: {113 authorization: token(sk, "upload", hex(sha256(second))),114 "content-type": "application/octet-stream",115 "upload-type": "text/plain",116 "upload-length": String(body.length),117 "upload-offset": String(secondOffset),118 "content-length": String(second.length),119 },120 body: second,121 });122 expect(chunkScoped.status).toBe(401);123124 // Send the later range first, then an overlapping earlier range.125 const later = await send(secondOffset, second);126 expect(later.status).toBe(204);127 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404);128 const complete = await send(0, first);129 expect(complete.status, await complete.clone().text()).toBe(201);130 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);131 expect(new Uint8Array(await (await fetch(`${HTTP_URL}/${sha}`)).arrayBuffer())).toEqual(body);132133 const del = await fetch(`${HTTP_URL}/${sha}`, {134 method: "DELETE",135 headers: { authorization: token(sk, "delete", sha) },136 });137 expect(del.status).toBe(204);138 });139140 it("rejects a complete BUD-14 upload whose reconstructed bytes have the wrong hash", async () => {141 const sk = newKey();142 const body = new TextEncoder().encode(`bud14 mismatch ${rand()}`);143 const claimed = hex(sha256(new TextEncoder().encode(`${body} wrong`)));144 const auth = token(sk, "upload", claimed);145 const response = await fetch(`${HTTP_URL}/${claimed}`, {146 method: "PATCH",147 headers: {148 authorization: auth,149 "content-type": "application/octet-stream",150 "upload-type": "text/plain",151 "upload-length": String(body.length),152 "upload-offset": "0",153 "content-length": String(body.length),154 },155 body,156 });157 expect(response.status).toBe(409);158 expect((await fetch(`${HTTP_URL}/${claimed}`)).status).toBe(404);159 });160161 it("keeps repeated BUD-14 ranges idempotent while an upload is partial", async () => {162 const sk = newKey();163 const body = new TextEncoder().encode(`bud14 duplicate ${rand()}`);164 const sha = hex(sha256(body));165 const auth = token(sk, "upload", sha);166 const send = (offset: number, chunk: Uint8Array) => fetch(`${HTTP_URL}/${sha}`, {167 method: "PATCH",168 headers: {169 authorization: auth,170 "content-type": "application/octet-stream",171 "upload-type": "text/plain",172 "upload-length": String(body.length),173 "upload-offset": String(offset),174 "content-length": String(chunk.length),175 },176 body: chunk,177 });178 const first = body.slice(0, Math.floor(body.length / 2));179 expect((await send(0, first)).status).toBe(204);180 expect((await send(0, first)).status).toBe(204);181 const finish = await send(first.length, body.slice(first.length));182 expect(finish.status, await finish.clone().text()).toBe(201);183 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);184 expect((await fetch(`${HTTP_URL}/${sha}`, {185 method: "DELETE",186 headers: { authorization: token(sk, "delete", sha) },187 })).status).toBe(204);188 });189190 it("uploads a file, serves it whole and by range, lists it, and deletes it", async () => {191 const sk = newKey();192 const body = new TextEncoder().encode(`a small file ${rand()} with enough bytes for a range`);193 const sha = hex(sha256(body));194195 const put = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });196 expect(put.status, await put.clone().text()).toBe(200);197 const desc: any = await put.json();198 expect([desc.sha256, desc.size, desc.type]).toEqual([sha, body.length, "text/plain"]);199 expect(desc.url).toMatch(new RegExp(`/${sha}\\.txt$`));200201 // The whole file, with its type and hash-derived etag.202 const get = await fetch(`${HTTP_URL}/${sha}.txt`);203 expect(get.status).toBe(200);204 expect(get.headers.get("content-type")).toBe("text/plain");205 expect(new Uint8Array(await get.arrayBuffer())).toEqual(body);206207 // A byte range: the second word.208 const part = await fetch(`${HTTP_URL}/${sha}`, { headers: { range: "bytes=2-6" } });209 expect(part.status).toBe(206);210 expect(part.headers.get("content-range")).toBe(`bytes 2-6/${body.length}`);211 expect(await part.text()).toBe("small");212213 // HEAD says it is there without a body.214 const head = await fetch(`${HTTP_URL}/${sha}`, { method: "HEAD" });215 expect(head.status).toBe(200);216 expect(await head.text()).toBe("");217218 // The uploader's listing carries it.219 const list = await fetch(`${HTTP_URL}/list/${pub(sk)}`, { headers: { authorization: token(sk, "list") } });220 expect(list.status).toBe(200);221 expect((await list.json<any>()).map((d: any) => d.sha256)).toContain(sha);222223 // The uploader deletes it; the same bytes can come back afterwards.224 const del = await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });225 expect(del.status).toBe(204);226 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(404);227 const again = await fetch(`${HTTP_URL}/upload`, { method: "PUT", headers: { authorization: token(sk, "upload", sha), "content-type": "text/plain" }, body });228 expect(again.status).toBe(200);229 expect((await fetch(`${HTTP_URL}/${sha}`)).status).toBe(200);230 await fetch(`${HTTP_URL}/${sha}`, { method: "DELETE", headers: { authorization: token(sk, "delete", sha) } });231 });232});