clone

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

feat/standalone-relay
tinyrelay / tinyclient / blossom-manifests.js
blossom-manifests.js 22.0K
1// BUD-16 and BUD-17 MessagePack manifests. Go templates embed this browser2// source directly so the same codec serves pages and file controls.3(() => {4 "use strict";5 // Manifest codec and tree reader. The API encodes and validates file and6 // directory manifests, builds chunked trees and reads them through caller7 // supplied fetch functions.8 const CHUNK_SIZE = 2 * 1024 * 1024;9 const MAX_LINKS = 174;10 const MAX_DEPTH = 64;11 const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;12 const textEncoder = new TextEncoder(),13 textDecoder = new TextDecoder("utf-8", {fatal: true});14 const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;15 const hashBytes = h => globalThis.tiny.util.fromHex(h, "expected lowercase SHA-256 hash");16 const keyBytes = k => {17 if (k === undefined) return;18 const b = asBytes(k);19 if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");20 return b;21 };22 const wellFormed = value => {23 for (let i = 0; i < value.length; i++) {24 const c = value.charCodeAt(i);25 if (c >= 55296 && c <= 56319) {26 if (++i >= value.length || value.charCodeAt(i) < 56320 || value.charCodeAt(i) > 57343) return false;27 } else if (c >= 56320 && c <= 57343) return false;28 }29 return true;30 };31 const nameOK = n =>32 typeof n === "string" &&33 n.length > 0 &&34 n !== "." &&35 n !== ".." &&36 wellFormed(n) &&37 !n.includes("/") &&38 !n.includes("\0") &&39 textEncoder.encode(n).length <= 1024;40 const int = n => Number.isSafeInteger(n) && n >= 0;41 const utf8cmp = (a, b) => {42 const x = textEncoder.encode(a),43 y = textEncoder.encode(b);44 for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];45 return x.length - y.length;46 };47 class MessagePackWriter {48 constructor() {49 this.output = [];50 }51 push(...x) {52 this.output.push(...x);53 }54 u8(n) {55 this.output.push(n & 255);56 }57 bytes() {58 return Uint8Array.from(this.output);59 }60 }61 const writeInteger = (w, n) => {62 if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");63 if (n >= 0) {64 if (n < 128) w.u8(n);65 else if (n < 256) w.push(204, n);66 else if (n < 65536) w.push(205, n >> 8, n);67 else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);68 else {69 w.u8(207);70 let x = BigInt(n);71 for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));72 }73 } else if (n >= -32) w.u8(256 + n);74 else if (n >= -128) w.push(208, n & 255);75 else if (n >= -32768) w.push(209, (n >> 8) & 255, n & 255);76 else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);77 else {78 w.u8(211);79 let x = BigInt.asUintN(64, BigInt(n));80 for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));81 }82 };83 const writeValue = (w, v) => {84 if (v === null) return w.u8(192);85 if (v === false) return w.u8(194);86 if (v === true) return w.u8(195);87 if (typeof v === "number") {88 if (Number.isInteger(v)) return writeInteger(w, v);89 if (!Number.isFinite(v)) throw Error("metadata number must be finite");90 w.u8(203);91 const b = new DataView(new ArrayBuffer(8));92 b.setFloat64(0, v);93 for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));94 return;95 }96 if (typeof v === "string") {97 const b = textEncoder.encode(v);98 if (b.length < 32) w.u8(160 | b.length);99 else if (b.length < 256) w.push(217, b.length);100 else if (b.length < 65536) w.push(218, b.length >> 8, b.length);101 else throw new Error("string too large");102 w.push(...b);103 return;104 }105 if (v instanceof Uint8Array) {106 if (v.length < 256) w.push(196, v.length);107 else if (v.length < 65536) w.push(197, v.length >> 8, v.length);108 else throw new Error("binary value too large");109 w.push(...v);110 return;111 }112 if (Array.isArray(v)) {113 if (v.length < 16) w.u8(144 | v.length);114 else if (v.length < 65536) w.push(220, v.length >> 8, v.length);115 else throw new Error("array too large");116 v.forEach(x => writeValue(w, x));117 return;118 }119 if (v && typeof v === "object") {120 const keys = Object.keys(v).sort(utf8cmp);121 if (keys.length < 16) w.u8(128 | keys.length);122 else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);123 else throw new Error("map too large");124 keys.forEach(k => {125 writeValue(w, k);126 writeValue(w, v[k]);127 });128 return;129 }130 throw new Error("unsupported manifest value");131 };132 class MessagePackReader {133 constructor(b) {134 this.input = asBytes(b);135 this.offset = 0;136 }137 need(n) {138 if (this.offset + n > this.input.length) throw new Error("truncated MessagePack");139 }140 u8() {141 this.need(1);142 return this.input[this.offset++];143 }144 n(len) {145 this.need(len);146 const x = this.input.slice(this.offset, this.offset + len);147 this.offset += len;148 return x;149 }150 }151 const readMap = (r, n, depth) => {152 const o = Object.create(null);153 for (let i = 0; i < n; i++) {154 const k = readValue(r, depth + 1);155 if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");156 o[k] = readValue(r, depth + 1);157 }158 return o;159 };160 const readValue = (r, depth = 0) => {161 if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");162 const t = r.u8();163 if (t <= 127) return t;164 if (t >= 224) return t - 256;165 if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));166 if ((t & 240) === 144) return Array.from({length: t & 15}, () => readValue(r, depth + 1));167 if ((t & 240) === 128) return readMap(r, t & 15, depth);168 if (t === 192) return null;169 if (t === 194) return false;170 if (t === 195) return true;171 let n;172 if (t === 204) n = r.u8();173 else if (t === 205) n = (r.u8() << 8) | r.u8();174 else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();175 else if (t === 207) {176 let x = 0n;177 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());178 if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");179 n = Number(x);180 } else if (t === 208) n = (r.u8() << 24) >> 24;181 else if (t === 209) {182 n = (r.u8() << 8) | r.u8();183 if (n & 32768) n -= 65536;184 } else if (t === 210) {185 n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();186 if (n >= 2147483648) n -= 4294967296;187 } else if (t === 211) {188 let x = 0n;189 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());190 if (x & 0x8000000000000000n) x -= 0x10000000000000000n;191 if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER))192 throw Error("integer exceeds safe range");193 n = Number(x);194 } else if (t === 202) {195 const b = new ArrayBuffer(4),196 d = new DataView(b);197 for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());198 n = d.getFloat32(0);199 } else if (t === 203) {200 const b = new ArrayBuffer(8),201 d = new DataView(b);202 for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());203 n = d.getFloat64(0);204 } else if (t === 217) n = textDecoder.decode(r.n(r.u8()));205 else if (t === 218) n = textDecoder.decode(r.n((r.u8() << 8) | r.u8()));206 else if (t === 196) n = r.n(r.u8());207 else if (t === 197) n = r.n((r.u8() << 8) | r.u8());208 else if (t === 220) {209 n = (r.u8() << 8) | r.u8();210 return Array.from({length: n}, () => readValue(r, depth + 1));211 } else if (t === 222) {212 n = (r.u8() << 8) | r.u8();213 return readMap(r, n, depth);214 } else throw Error("unsupported MessagePack type");215 return n;216 };217 const validateMeta = (m, depth = 0) => {218 if (m === undefined) return;219 if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array)220 throw Error("metadata must be a JSON map");221 const keys = Object.keys(m);222 if (keys.length > 64) throw Error("too many metadata keys");223 for (const [k, v] of Object.entries(m)) {224 if (typeof k !== "string" || textEncoder.encode(k).length > 256) throw Error("invalid metadata key");225 if (226 v === undefined ||227 typeof v === "bigint" ||228 typeof v === "function" ||229 typeof v === "symbol" ||230 (typeof v === "number" && !Number.isFinite(v))231 )232 throw Error("metadata is not JSON-compatible");233 if (v && typeof v === "object") {234 if (Array.isArray(v)) {235 if (v.length > 256) throw Error("metadata array too large");236 v.forEach(x => validateMeta({x}, depth + 1));237 } else validateMeta(v, depth + 1);238 }239 }240 };241 const validateLink = (x, nodeType) => {242 if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");243 if (Object.keys(x).some(k => !["h", "k", "m", "n", "s", "t"].includes(k)))244 throw Error("unknown manifest link field");245 if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");246 if (x.k !== undefined) keyBytes(x.k);247 if (x.m !== undefined) validateMeta(x.m);248 if (!int(x.s)) throw Error("invalid link size");249 if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");250 if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");251 if (nodeType === 1 && x.n !== undefined) throw Error("file links cannot have names");252 if (nodeType === 3 && x.n !== undefined) throw Error("fanout links cannot have names");253 if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");254 if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");255 };256 const validateNode = (node, depth = 0) => {257 if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");258 if (259 !node ||260 typeof node !== "object" ||261 Array.isArray(node) ||262 !Array.isArray(node.l) ||263 !int(node.t) ||264 ![1, 2, 3].includes(node.t) ||265 Object.keys(node).some(k => k !== "l" && k !== "t")266 )267 throw Error("invalid manifest node");268 if (node.l.length > MAX_LINKS) throw Error("too many manifest links");269 const seen = new Set(),270 links = node.l;271 for (const x of links) {272 validateLink(x, node.t);273 if (node.t === 2) {274 if (seen.has(x.n)) throw Error("duplicate directory entry");275 seen.add(x.n);276 }277 if (node.t === 3) {278 if (279 !x.m ||280 !int(x.m.count) ||281 x.m.count < 1 ||282 !nameOK(x.m.first) ||283 !nameOK(x.m.last) ||284 utf8cmp(x.m.first, x.m.last) > 0285 )286 throw Error("invalid fanout bounds");287 }288 }289 if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0))290 throw Error("directory links are not canonical");291 return node;292 };293 const encodeManifest = node => {294 validateNode(node);295 const w = new MessagePackWriter();296 w.u8(130);297 writeValue(w, "l");298 if (node.l.length < 16) w.u8(144 | node.l.length);299 else w.push(220, node.l.length >> 8, node.l.length);300 for (const x of node.l) {301 const fields = [302 "h",303 ...(x.k !== undefined ? ["k"] : []),304 ...(x.m !== undefined ? ["m"] : []),305 ...(x.n !== undefined ? ["n"] : []),306 "s",307 "t"308 ];309 if (fields.length < 16) w.u8(128 | fields.length);310 else w.push(222, fields.length >> 8, fields.length);311 for (const k of fields) {312 writeValue(w, k);313 writeValue(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);314 }315 }316 writeValue(w, "t");317 writeValue(w, node.t);318 const out = w.bytes();319 if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");320 return out;321 };322 const decodeManifest = (bytes, options = {}) => {323 const b = asBytes(bytes);324 if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");325 const r = new MessagePackReader(b),326 node = readValue(r);327 if (r.offset !== b.length) throw Error("trailing MessagePack data");328 validateNode(node);329 if (node.t === 3)330 for (const x of node.l) {331 if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");332 }333 return node;334 };335 const manifestHash = async node => hex(await sha256(encodeManifest(node)));336 const leaf = (hash, size, key) => {337 const x = {h: hashBytes(hash), s: size, t: 0};338 if (key !== undefined) x.k = keyBytes(key);339 return x;340 };341 const storeNode = async (node, options, manifests) => {342 const bytes = encodeManifest(node),343 computed = hex(await sha256(bytes));344 let result = {hash: computed};345 if (options.store) {346 const stored = await options.store({hash: computed, bytes, node});347 if (stored) {348 if (typeof stored === "string") result.hash = stored;349 else result = {...result, ...stored};350 }351 }352 if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");353 if (result.key !== undefined) keyBytes(result.key);354 manifests.push({hash: result.hash, bytes, node, key: result.key});355 return result;356 };357 const buildFile = async (chunks, options = {}) => {358 const max = options.maxLinks ?? MAX_LINKS;359 if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");360 const leaves = chunks.map(c =>361 typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key)362 );363 if (leaves.some(x => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");364 const manifests = [];365 let level = leaves;366 while (level.length > max) {367 const next = [];368 for (let i = 0; i < level.length; i += max) {369 const group = level.slice(i, i + max),370 child = {l: group, t: 1},371 stored = await storeNode(child, options, manifests);372 next.push({373 h: hashBytes(stored.hash),374 ...(stored.key ? {k: keyBytes(stored.key)} : {}),375 s: group.reduce((n, x) => n + x.s, 0),376 t: 1377 });378 }379 level = next;380 }381 const root = {l: level, t: 1};382 await storeNode(root, options, manifests);383 return options.returnDetails ? {root, manifests} : root;384 };385 const buildDirectory = async (entries, options = {}) => {386 if (!Array.isArray(entries)) throw Error("entries must be an array");387 const max = options.maxLinks ?? MAX_LINKS;388 if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");389 const sorted = entries390 .map(e => {391 const x = {392 h: hashBytes(e.hash),393 n: e.name,394 s: e.size || 0,395 t: e.type || 0396 };397 if (e.key !== undefined) x.k = keyBytes(e.key);398 if (e.metadata !== undefined) x.m = e.metadata;399 return x;400 })401 .sort((a, b) => utf8cmp(a.n, b.n));402 for (let i = 1; i < sorted.length; i++)403 if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");404 const manifests = [];405 if (sorted.length <= max) {406 const directory = {l: sorted, t: 2};407 await storeNode(directory, options, manifests);408 return options.returnDetails ? {root: directory, manifests} : directory;409 }410 let level = [];411 for (let i = 0; i < sorted.length; i += max) {412 const l = sorted.slice(i, i + max),413 child = {l, t: 2},414 stored = await storeNode(child, options, manifests);415 level.push({416 h: hashBytes(stored.hash),417 ...(stored.key ? {k: keyBytes(stored.key)} : {}),418 m: {count: l.length, first: l[0].n, last: l[l.length - 1].n},419 s: l.reduce((n, x) => n + x.s, 0),420 t: 2421 });422 }423 while (level.length > max) {424 const next = [];425 for (let i = 0; i < level.length; i += max) {426 const l = level.slice(i, i + max),427 child = {l, t: 3},428 stored = await storeNode(child, options, manifests);429 next.push({430 h: hashBytes(stored.hash),431 ...(stored.key ? {k: keyBytes(stored.key)} : {}),432 m: {433 count: l.reduce((n, x) => n + x.m.count, 0),434 first: l[0].m.first,435 last: l[l.length - 1].m.last436 },437 s: l.reduce((n, x) => n + x.s, 0),438 t: 3439 });440 }441 level = next;442 }443 const root = {l: level, t: 3};444 await storeNode(root, options, manifests);445 return options.returnDetails ? {root, manifests} : root;446 };447 const flattenDirectory = (node, out = []) => {448 validateNode(node);449 if (node.t === 2) {450 out.push(...node.l);451 return out;452 }453 if (node.t !== 3) throw Error("not a directory");454 for (const x of node.l) if (x.child) flattenDirectory(x.child, out);455 return out;456 };457 const resolveDirectory = async (root, fetchNode, options = {}) => {458 if (typeof fetchNode !== "function") throw Error("fetchNode is required");459 const maxDepth = options.maxDepth ?? MAX_DEPTH,460 maxEntries = options.maxEntries ?? 1e5,461 maxManifests = options.maxManifests ?? 1e4,462 maxBytes = options.maxBytes ?? 64 * 1024 * 1024;463 if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0))464 throw Error("invalid directory limits");465 let count = 0,466 manifestCount = 0,467 fetched = 0;468 const active = new Set();469 const walk = async (node, depth) => {470 if (depth > maxDepth) throw Error("directory recursion limit exceeded");471 if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");472 validateNode(node);473 if (node.t === 2) {474 count += node.l.length;475 if (count > maxEntries) throw Error("directory entry limit exceeded");476 return node.l.slice();477 }478 if (node.t !== 3) throw Error("not a directory");479 const result = [];480 let previous = "";481 for (const link of node.l) {482 if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");483 previous = link.m.last;484 const linkHash = hex(link.h);485 if (active.has(linkHash)) throw Error("manifest cycle detected");486 active.add(linkHash);487 let raw = asBytes(await fetchNode(linkHash, link));488 fetched += raw.length;489 if (fetched > maxBytes) throw Error("directory byte limit exceeded");490 if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");491 if (link.k) {492 if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");493 raw = asBytes(await options.decrypt(raw, link));494 }495 const child = decodeManifest(raw);496 if (child.t !== link.t) throw Error("fanout child type mismatch");497 const links = await walk(child, depth + 1);498 active.delete(linkHash);499 if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last)500 throw Error("fanout bounds mismatch");501 result.push(...links);502 }503 for (let i = 1; i < result.length; i++)504 if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");505 return result;506 };507 return walk(root, 0);508 };509 const readFile = async (root, fetchBlob, options = {}) => {510 if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");511 const maxDepth = options.maxDepth ?? MAX_DEPTH,512 maxBytes = options.maxBytes ?? 512 * 1024 * 1024,513 maxManifests = options.maxManifests ?? 1e4;514 const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;515 if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0))516 throw Error("invalid file limits");517 let used = 0,518 fetched = 0,519 manifests = 0;520 const active = new Set(),521 parts = [];522 const readNode = async (node, depth) => {523 if (depth > maxDepth) throw Error("file recursion limit exceeded");524 validateNode(node);525 if (node.t !== 1) throw Error("not a file manifest");526 if (++manifests > maxManifests) throw Error("manifest count limit exceeded");527 for (const link of node.l) {528 const hash = hex(link.h);529 if (active.has(hash)) throw Error("manifest cycle detected");530 active.add(hash);531 let raw = asBytes(await fetchBlob(hash, link));532 fetched += raw.length;533 if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");534 if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");535 if (link.t === 1) {536 if (typeof options.decrypt !== "function" && link.k)537 throw Error("encrypted manifest requires decrypt callback");538 if (link.k) raw = asBytes(await options.decrypt(raw, link));539 const child = decodeManifest(raw);540 if (child.t !== 1) throw Error("file child type mismatch");541 const before = used;542 await readNode(child, depth + 1);543 if (used - before !== link.s) throw Error("file manifest size mismatch");544 } else {545 if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");546 const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;547 if (data.length !== link.s) throw Error("file chunk size mismatch");548 used += data.length;549 if (used > maxBytes) throw Error("file byte limit exceeded");550 parts.push(data);551 }552 active.delete(hash);553 }554 };555 await readNode(root, 0);556 const out = new Uint8Array(used);557 let at = 0;558 for (const p of parts) {559 out.set(p, at);560 at += p.length;561 }562 return out;563 };564 const api = Object.freeze({565 CHUNK_SIZE,566 MAX_LINKS,567 encodeManifest,568 decodeManifest,569 encode: encodeManifest,570 decode: decodeManifest,571 manifestHash,572 buildFile,573 buildDirectory,574 flattenDirectory,575 resolveDirectory,576 readFile,577 sha256,578 hex579 });580 globalThis.tiny = globalThis.tiny || {};581 globalThis.tiny.blossom = {...globalThis.tiny.blossom, manifests: api};582})();