clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / src / nip96.ts
nip96.ts 6.0K
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 role12import { sha256 } from "@noble/hashes/sha2.js";13import { tagValues } from "./event.ts";14import { bytesToHex } from "./negentropy.ts";15import { verifyNIP98 } from "./auth.ts";16import { can } from "./roles.ts";17import { EXT, blobBlocked, blobURL, blossom, nip94Tags, storeBlob, type Blob } from "./blossom.ts";18import type { Relay } from "./relay.ts";1920const SHA_RE = /^\/nip96\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/;21const MAX_PAGE = 100;2223export 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 });3132 if (req.method === "OPTIONS") return new Response(null, { headers: cors });3334 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 }4546 // Downloads are the Blossom door under a different prefix; the request47 // goes over unchanged so a signature over this URL still fits.48 const m = SHA_RE.exec(url.pathname);49 if (m && (req.method === "GET" || req.method === "HEAD")) return blossom(relay, req);5051 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, "");52 if (typeof auth === "string") return fail(auth, 401);5354 if (m && req.method === "DELETE") {55 const sha = m[1];56 const blob = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];57 if (!blob) return fail("not found", 404);58 if (blob.uploader !== auth.pubkey && !can(relay.settings.roleOf(auth.pubkey), "storage")) return fail("restricted: not the uploader", 403);59 await relay.deleteBlob(sha);60 return json({ status: "success", message: "File deleted." });61 }6263 if (url.pathname !== "/nip96") return fail("not found", 404);6465 if (req.method === "GET") {66 const gate = relay.settings.mayRead([auth.pubkey]);67 if (gate) return fail(gate, 403);68 const count = Math.max(1, Math.min(MAX_PAGE, Number(url.searchParams.get("count")) || 20));69 const page = Math.max(0, Math.floor(Number(url.searchParams.get("page")) || 0));70 const total = relay.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM blobs WHERE uploader=?`, auth.pubkey).one().n;71 const rows = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE uploader=? ORDER BY uploaded DESC LIMIT ? OFFSET ?`, auth.pubkey, count, page * count).toArray();72 return json({ count, total, page, files: rows.map((b) => ({ tags: nip94Tags(host, b), content: "", created_at: b.uploaded })) });73 }7475 if (req.method !== "POST") return fail("method not allowed", 405);76 const gate = relay.mayUpload(auth.pubkey, host);77 if (gate) return fail(gate, 403);78 const max = relay.settings.policy.maxBlobMB * 1024 * 1024;79 const tooBig = `invalid: file exceeds ${relay.settings.policy.maxBlobMB} MB`;80 if (Number(req.headers.get("content-length")) > max) return fail(tooBig, 413);81 let form: FormData;82 try {83 form = await req.formData();84 } catch {85 return fail("invalid: body is not multipart form data", 400);86 }87 const file = form.get("file");88 if (!(file instanceof File)) return fail("invalid: form needs a file field", 400);89 const declared = String(form.get("size") ?? "");90 if (declared !== "" && Number(declared) !== file.size) return fail("invalid: size field does not match the file", 400);91 if (file.size === 0) return fail("invalid: empty file", 400);92 if (file.size > max) return fail(tooBig, 413);93 const body = new Uint8Array(await file.arrayBuffer());94 const digest = sha256(body);95 const sha = bytesToHex(digest);96 // NIP-96 puts the file's hash in the token's payload tag, hex per NIP-9897 // or base64 per NIP-96's own wording; either is accepted, a mismatch is not.98 const payload = tagValues(auth, "payload")[0];99 if (payload !== undefined && payload !== sha && payload !== btoa(String.fromCharCode(...digest))) return fail("restricted: token payload does not match the file", 403);100 if (blobBlocked(relay, sha)) return fail("blocked: this file was removed by a moderator", 403);101 const type = (file.type || String(form.get("content_type") ?? "") || "application/octet-stream").split(";")[0].trim().toLowerCase() || "application/octet-stream";102 const { blob, created } = await storeBlob(relay, body, type, auth.pubkey, now);103 relay.meterBytes(body.length, 0);104 return json(105 {106 status: "success",107 message: created ? "Upload successful." : "File already stored.",108 nip94_event: { tags: nip94Tags(host, blob), content: String(form.get("caption") ?? "").slice(0, 2000) },109 url: blobURL(host, blob),110 },111 created ? 201 : 200,112 );113}