1// Blossom media (BUD-01, BUD-02): every relay is also its members' media2// server. Blobs live in R2 under <relay>/<sha256>; descriptors live in the3// relay's database so storage counts toward fuel and the owner can see and4// remove them. Authorization is a kind 24242 event in the Authorization header.5// Descriptors carry NIP-94 tags (BUD-08). Reports land in the same queue as6// NIP-56 reports (BUD-09). NIP-96 (nip96.ts) is a second door to the same7// bucket and table.8//9// PUT /upload t=upload, x=<sha256 of body> -> descriptor10// HEAD /upload t=upload; X-SHA-256, X-Content-Type, X-Content-Length (BUD-06)11// PUT /mirror t=upload, x=<sha256>; body {url} -> descriptor (BUD-04)12// PUT /report body: kind 1984 with x tags (BUD-09)13// GET /<sha256>[.ext] the read rule; t=get or NIP-98 when reads are not open14// HEAD /<sha256>[.ext] same15// DELETE /<sha256> t=delete, x=<sha256>; uploader or owner16// GET /list/<pubkey> the read rule; t=list or NIP-98 when reads are not open17//18// Files follow the relay's read rule (Settings.mayRead), the same gate as19// events: on a members-only relay a blob is a member's, not a public URL.20import { sha256 } from "@noble/hashes/sha2.js";21import { verifyBlossom, whoAsks, denyStatus } from "./auth.ts";22export { verifyBlossom } from "./auth.ts";23import { tagValues, validate, type Event } from "./event.ts";24import { bytesToHex } from "./negentropy.ts";25import { localName } from "./pull.ts";26import { type Relay } from "./relay.ts";27import { notify } from "./notify.ts";28import { KIND_REPORT } from "./kinds.ts";2930export type Blob = {31 sha256: string;32 size: number;33 type: string;34 uploader: string;35 uploaded: number;36};3738export const EXT: Record<string, string> = {39 "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg",40 "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav", "audio/mp4": "m4a",41 "application/pdf": "pdf", "text/plain": "txt", "application/json": "json", "text/markdown": "md",42};43const TYPE_BY_EXT: Record<string, string> = Object.fromEntries(Object.entries(EXT).map(([type, ext]) => [ext, type]));44const SHA_RE = /^\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/;45const HEX64 = /^[0-9a-f]{64}$/;4647export function isBlobPath(path: string): boolean {48 return SHA_RE.test(path);49}5051export function blobURL(host: string, b: Blob): string {52 const ext = EXT[b.type];53 return `https://${host}/${b.sha256}${ext ? "." + ext : ""}`;54}5556// nip94Tags is what the relay can vouch for about a blob without decoding57// it. Nothing is transformed here, so ox equals x. Shared with NIP-96.58export function nip94Tags(host: string, b: Blob): string[][] {59 return [["url", blobURL(host, b)], ["m", b.type], ["x", b.sha256], ["ox", b.sha256], ["size", String(b.size)]];60}6162export function descriptor(host: string, b: Blob) {63 return { url: blobURL(host, b), sha256: b.sha256, size: b.size, type: b.type, uploaded: b.uploaded, nip94: nip94Tags(host, b) };64}6566// blobBlocked says whether a hash was removed by a moderator: a resolved67// report puts the sha256 on the banned id list, so it cannot come back68// through any door.69export function blobBlocked(relay: Relay, sha: string): boolean {70 return relay.settings.isEventBanned(sha);71}7273// storeBlob puts bytes in R2 and a row in the table unless the hash is74// already there. Returns the descriptor row and whether it is new.75export async function storeBlob(relay: Relay, body: Uint8Array, type: string, uploader: string, now: number): Promise<{ blob: Blob; created: boolean }> {76 const sha = bytesToHex(sha256(body));77 const existing = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];78 if (existing) return { blob: existing, created: false };79 await relay.media.put(`${relay.slug}/${sha}`, body, { httpMetadata: { contentType: type } });80 relay.sql.exec(`INSERT INTO blobs(sha256,size,type,uploader,uploaded) VALUES(?,?,?,?,?)`, sha, body.length, type, uploader, now);81 return { blob: { sha256: sha, size: body.length, type, uploader, uploaded: now }, created: true };82}8384// fileReport files a kind 1984 that names blobs in x tags (BUD-09) into the85// reports queue, one row per blob this relay holds, with the uploader as86// the reported pubkey. Returns how many were filed, or a reason.87export function fileReport(relay: Relay, e: Event): number | string {88 if (relay.settings.policy.owner === "") return relay.settings.isLeased() ? "restricted: this temporary relay has no owner to report to" : "restricted: this relay is unclaimed";89 if (relay.settings.isBanned(e.pubkey)) return "blocked: this pubkey is banned from this relay";90 const xs = e.tags.filter((t) => t[0] === "x" && HEX64.test(t[1] ?? ""));91 if (xs.length === 0) return "invalid: report needs an x tag with a blob sha256";92 const fallbackType = e.tags.find((t) => (t[0] === "e" || t[0] === "p") && t[2])?.[2] ?? "";93 let filed = 0;94 for (const [i, x] of xs.entries()) {95 const blob = relay.sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, x[1]).toArray()[0];96 if (!blob) continue;97 const id = i === 0 ? e.id : `${e.id}.${i}`;98 relay.sql.exec(99 `INSERT OR IGNORE INTO reports(id,reporter,target_pubkey,target_event,type,content,at) VALUES(?,?,?,?,?,?,?)`,100 id, e.pubkey, blob.uploader, blob.sha256, (x[2] || fallbackType).slice(0, 32), e.content.slice(0, 2000), e.created_at,101 );102 filed++;103 }104 if (filed === 0) return "not found: this relay holds none of the reported blobs";105 void notify(relay, "reports", `New report on ${relay.slug}: ${filed} file${filed === 1 ? "" : "s"} reported by ${e.pubkey.slice(0, 8)}.${e.content ? " " + e.content.slice(0, 300) : ""} Review it at https://${relay.slug}.${relay.domain}/#people`, "a report on " + relay.slug);106 return filed;107}108109export function blobBytes(sql: SqlStorage): number {110 return sql.exec<{ n: number | null }>(`SELECT sum(size) AS n FROM blobs`).one().n ?? 0;111}112113// fetchOrigin gets a blob for mirroring. Our own relays are reached through114// their object, which also works in wrangler dev and tests; anything else is115// fetched over https. Returns a Response or a reason.116export async function fetchOrigin(relay: Relay, raw: string, init?: RequestInit): Promise<Response | string> {117 let u: URL;118 try {119 u = new URL(raw);120 } catch {121 return "invalid: url is not a URL";122 }123 const local = localName(u, relay.domain);124 try {125 if (local) {126 const headers = new Headers(init?.headers);127 headers.set("x-relay-name", local);128 return await relay.relays.getByName(local).fetch(u.href, { ...init, headers });129 }130 if (u.protocol !== "https:") return "invalid: only https urls can be mirrored";131 return await relay.fetcher(u.href, { redirect: "follow", ...init });132 } catch (err) {133 return "error: could not fetch the origin: " + (err instanceof Error ? err.message : String(err));134 }135}136137// readCapped collects a body, giving up as soon as it passes max bytes.138export async function readCapped(body: ReadableStream<Uint8Array> | null, max: number): Promise<Uint8Array | "too big"> {139 if (!body) return new Uint8Array();140 const parts: Uint8Array[] = [];141 let total = 0;142 const reader = body.getReader();143 for (;;) {144 const { done, value } = await reader.read();145 if (done) break;146 total += value.length;147 if (total > max) {148 await reader.cancel();149 return "too big";150 }151 parts.push(value);152 }153 const out = new Uint8Array(total);154 let at = 0;155 for (const p of parts) {156 out.set(p, at);157 at += p.length;158 }159 return out;160}161162export async function blossom(relay: Relay, req: Request): Promise<Response> {163 const url = new URL(req.url);164 const host = url.host;165 const now = Math.floor(Date.now() / 1000);166 const sql = relay.sql;167 const json = (b: unknown, status = 200, extra: Record<string, string> = {}) =>168 new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", "access-control-allow-origin": "*", ...extra } });169 const reason = (msg: string, status: number) => json({ error: msg }, status, { "x-reason": msg });170 const key = (sha: string) => `${relay.slug}/${sha}`;171 const maxBytes = () => relay.settings.policy.maxBlobMB * 1024 * 1024;172 const tooBig = () => `invalid: blob exceeds ${relay.settings.policy.maxBlobMB} MB`;173174 // BUD-06: would this upload be accepted? No body either way.175 if (req.method === "HEAD" && url.pathname === "/upload") {176 const head = (status: number, msg = "") => new Response(null, { status, headers: { "access-control-allow-origin": "*", ...(msg ? { "x-reason": msg } : {}) } });177 const sha = (req.headers.get("x-sha-256") ?? "").trim().toLowerCase();178 if (!HEX64.test(sha)) return head(400, "invalid: X-SHA-256 must be a lowercase hex sha256");179 const lengthHeader = req.headers.get("x-content-length");180 if (lengthHeader === null || lengthHeader.trim() === "") return head(411, "invalid: X-Content-Length is required");181 const length = Number(lengthHeader);182 if (!Number.isInteger(length) || length < 0) return head(400, "invalid: X-Content-Length must be a whole number of bytes");183 const auth = verifyBlossom(req.headers.get("authorization") ?? "", "upload", now);184 if (typeof auth === "string") return head(401, auth);185 const gate = relay.mayUpload(auth.pubkey, host);186 if (gate) return head(403, gate);187 if (length === 0) return head(400, "invalid: empty body");188 if (length > maxBytes()) return head(413, tooBig());189 const claimed = tagValues(auth, "x");190 if (claimed.length && !claimed.includes(sha)) return head(400, "invalid: token x tag does not match the blob");191 return head(200);192 }193194 // BUD-04: copy a blob from a URL instead of receiving the bytes.195 if (req.method === "PUT" && url.pathname === "/mirror") {196 const auth = verifyBlossom(req.headers.get("authorization") ?? "", "upload", now);197 if (typeof auth === "string") return reason(auth, 401);198 const gate = relay.mayUpload(auth.pubkey, host);199 if (gate) return reason(gate, 403);200 let from = "";201 try {202 from = String((JSON.parse(await req.text()) as { url?: unknown }).url ?? "");203 } catch {204 return reason("invalid: body is not JSON", 400);205 }206 if (!from) return reason("invalid: body needs a url", 400);207 const origin = await fetchOrigin(relay, from);208 if (typeof origin === "string") return reason(origin, origin.startsWith("invalid:") ? 400 : 502);209 if (!origin.ok) return reason(`error: the origin answered ${origin.status}`, 502);210 const declared = Number(origin.headers.get("content-length"));211 if (declared > maxBytes()) return reason(tooBig(), 413);212 const body = await readCapped(origin.body, maxBytes());213 if (body === "too big") return reason(tooBig(), 413);214 if (body.length === 0) return reason("error: the origin sent an empty body", 502);215 const sha = bytesToHex(sha256(body));216 const claimed = tagValues(auth, "x");217 if (claimed.length && !claimed.includes(sha)) return reason("invalid: the mirrored blob does not match the token x tag", 409);218 if (blobBlocked(relay, sha)) return reason("blocked: this blob was removed by a moderator", 403);219 const originType = (origin.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();220 const ext = new URL(from).pathname.split(".").pop()?.toLowerCase() ?? "";221 const type = originType && originType !== "application/octet-stream" ? originType : (TYPE_BY_EXT[ext] ?? originType) || "application/octet-stream";222 const { blob, created } = await storeBlob(relay, body, type, auth.pubkey, now);223 relay.meterBytes(body.length, 0);224 return json(descriptor(host, blob), created ? 201 : 200);225 }226227 if (req.method === "PUT" && url.pathname === "/upload") {228 const auth = verifyBlossom(req.headers.get("authorization") ?? "", "upload", now);229 if (typeof auth === "string") return reason(auth, 401);230 const gate = relay.mayUpload(auth.pubkey, host);231 if (gate) return reason(gate, 403);232 const max = maxBytes();233 const declared = Number(req.headers.get("content-length"));234 if (declared > max) return reason(`invalid: blob exceeds ${relay.settings.policy.maxBlobMB} MB`, 413);235 const body = new Uint8Array(await req.arrayBuffer());236 if (body.length === 0) return reason("invalid: empty body", 400);237 if (body.length > max) return reason(`invalid: blob exceeds ${relay.settings.policy.maxBlobMB} MB`, 413);238 const sha = bytesToHex(sha256(body));239 const claimed = tagValues(auth, "x");240 if (claimed.length && !claimed.includes(sha)) return reason("invalid: token x tag does not match the blob", 400);241 if (blobBlocked(relay, sha)) return reason("blocked: this blob was removed by a moderator", 403);242 const type = (req.headers.get("content-type") ?? "application/octet-stream").split(";")[0].trim().toLowerCase() || "application/octet-stream";243 const { blob } = await storeBlob(relay, body, type, auth.pubkey, now);244 relay.meterBytes(body.length, 0);245 return json(descriptor(host, blob));246 }247248 // BUD-09: a signed NIP-56 report naming blobs, into the moderation queue.249 if (req.method === "PUT" && url.pathname === "/report") {250 let e: Event;251 try {252 e = JSON.parse(await req.text());253 } catch {254 return reason("invalid: body is not JSON", 400);255 }256 const bad = validate(e);257 if (bad) return reason(bad, 400);258 if (e.kind !== KIND_REPORT) return reason("invalid: report must be kind 1984", 400);259 if (Math.abs(now - e.created_at) > 3600) return reason("invalid: report is too old or from the future", 400);260 // The reporter must be someone the read rule admits, before any hash is261 // looked up: whether this relay holds a file is a member's to know.262 const gate = relay.settings.mayRead([e.pubkey]);263 if (gate) return reason(gate, denyStatus(gate));264 const r = fileReport(relay, e);265 if (typeof r === "string") return reason(r, r.startsWith("not found") ? 404 : r.startsWith("invalid") ? 400 : 403);266 return json({ ok: true, filed: r });267 }268269 if (req.method === "GET" && url.pathname.startsWith("/list/")) {270 const pk = url.pathname.slice(6);271 if (!/^[0-9a-f]{64}$/.test(pk)) return reason("invalid: bad pubkey", 400);272 const who = whoAsks(req, "", "list");273 if (typeof who === "string") return reason(who, 401);274 const gate = relay.settings.mayRead(who.pubkeys);275 if (gate) return reason(gate, denyStatus(gate));276 const rows = sql.exec<Blob>(`SELECT * FROM blobs WHERE uploader=? ORDER BY uploaded DESC LIMIT 500`, pk).toArray();277 return json(rows.map((b) => descriptor(host, b)));278 }279280 // /nip96/<sha> is the same door (nip96.ts hands it over unchanged).281 const m = SHA_RE.exec(url.pathname.startsWith("/nip96/") ? url.pathname.slice("/nip96".length) : url.pathname);282 if (!m) return reason("not found", 404);283 const sha = m[1];284 const blob = sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];285286 if (req.method === "DELETE") {287 const auth = verifyBlossom(req.headers.get("authorization") ?? "", "delete", now);288 if (typeof auth === "string") return reason(auth, 401);289 if (!tagValues(auth, "x").includes(sha)) return reason("invalid: token x tag does not name this blob", 400);290 if (!blob) return reason("not found", 404);291 if (blob.uploader !== auth.pubkey && !relay.settings.isOwner(auth.pubkey)) return reason("restricted: not the uploader", 403);292 await relay.deleteBlob(sha);293 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });294 }295296 if (req.method !== "GET" && req.method !== "HEAD") return reason("method not allowed", 405);297 // The read rule, before the lookup: a stranger learns nothing, not even298 // whether the hash is here. A gated answer is not for shared caches.299 const open = relay.settings.policy.reads === "open";300 const who = whoAsks(req, "", "get", sha);301 if (typeof who === "string") return reason(who, 401);302 const gate = relay.settings.mayRead(who.pubkeys);303 if (gate) return reason(gate, denyStatus(gate));304 if (!blob) return reason("not found", 404);305 const headers: Record<string, string> = {306 "content-type": blob.type,307 "content-length": String(blob.size),308 "accept-ranges": "bytes",309 "cache-control": open ? "public, max-age=31536000, immutable" : "private, no-store",310 "access-control-allow-origin": "*",311 "x-content-type-options": "nosniff",312 "content-security-policy": "default-src 'none'",313 etag: `"${sha}"`,314 };315 if (req.method === "HEAD") return new Response(null, { headers });316 const ranged = req.headers.has("range");317 const obj = await relay.media.get(key(sha), ranged ? { range: req.headers } : undefined);318 if (!obj) return reason("not found", 404);319 relay.meterBytes(0, obj.size);320 if (ranged && obj.range) {321 const r = obj.range as { offset?: number; length?: number; suffix?: number };322 const start = r.suffix !== undefined ? blob.size - r.suffix : (r.offset ?? 0);323 const len = r.suffix !== undefined ? r.suffix : (r.length ?? blob.size - start);324 headers["content-range"] = `bytes ${start}-${start + len - 1}/${blob.size}`;325 headers["content-length"] = String(len);326 return new Response(obj.body, { status: 206, headers });327 }328 return new Response(obj.body, { headers });329}