clone

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

share browser helpers through the tiny namespace

Dami committed efc0c44
commit
efc0c4439bc2070f5cd7a788d2a8d0468a39e62f
parent
96951c95682adfc827b1c651f2feaa2d396e574c
author
Dami <dami.etoile@gmail.com>
21 files changed, +1690 -783
Minternal/webui/blossom-encryption.js103++-
Minternal/webui/blossom-manifests.js1056++++++++++++++++++------------
Minternal/webui/blossom-upload.js284++++++--
Minternal/webui/bridge.js7+-
Minternal/webui/components.js34+-
Minternal/webui/file-messages.js350++++++++--
Minternal/webui/file-workspace.js349++++++++--
Minternal/webui/page.html1+
Minternal/webui/private-services.js185++++-
Minternal/webui/templates.go5+-
Ainternal/webui/tiny.js18+
Mpackage.json2+-
Mscripts/blossom-encryption.test.mjs5+-
Mscripts/blossom-manifests-review.test.mjs5+-
Mscripts/blossom-manifests.test.mjs5+-
Mscripts/blossom-upload.test.mjs5+-
Mscripts/file-messages.test.mjs18+-
Mscripts/file-workspace.test.mjs9+-
Mscripts/private-services.test.mjs3+-
Mscripts/signer.test.mjs4+-
Ascripts/tiny-namespace.test.mjs25+
M internal/webui/blossom-encryption.js +70 -33
@@ -1,5 +1,5 @@
11 // BUD-15 draft (June 2026), BUD-15: Client-side CHK encrypted blobs.2-// https://raw.githubusercontent.com/mmalmi/blossom/codex/bud-15-chk-encryption/buds/15.md2+// https://github.com/hzrd149/blossom/pull/10433 // Keys are client-only values. Callers must not include k in requests to a server.44 (() => {55 "use strict";
@@ -9,65 +9,102 @@
99 const salt = encoder.encode("hashtree-chk");1010 const info = encoder.encode("encryption-key");1111 12- const hex = bytes => Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");13- const bytes = value => {14- if (value instanceof Uint8Array) return value;15- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);16- throw new TypeError("expected Uint8Array");17- };18- const fromHex = value => {19- if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw new Error("expected a 64-character lowercase hex key");12+ const {bytes, hex, sha256: digest} = globalThis.tiny.util;13+ const fromHex = (value) => {14+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))15+ throw new Error("expected a 64-character lowercase hex key");2016 const result = new Uint8Array(32);21- for (let index = 0; index < result.length; index++) result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);17+ for (let index = 0; index < result.length; index++)18+ result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);2219 return result;2320 };24- const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);25- const digest = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));26- const aesKey = async key => {27- const material = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);28- return crypto.subtle.deriveKey({name: "HKDF", hash: "SHA-256", salt, info}, material,29- {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]);21+ const equal = (left, right) =>22+ left.length === right.length &&23+ left.every((value, index) => value === right[index]);24+ const aesKey = async (key) => {25+ const material = await crypto.subtle.importKey("raw", key, "HKDF", false, [26+ "deriveKey",27+ ]);28+ return crypto.subtle.deriveKey(29+ { name: "HKDF", hash: "SHA-256", salt, info },30+ material,31+ { name: "AES-GCM", length: 256 },32+ false,33+ ["encrypt", "decrypt"],34+ );3035 };3136 32- const encryptCHK = async plaintext => {37+ const encryptCHK = async (plaintext) => {3338 const input = bytes(plaintext);3439 const key = await digest(input);35- const encrypted = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(key), input));36- return {ciphertext: encrypted, key: hex(key), hash: hex(await digest(encrypted))};40+ const encrypted = new Uint8Array(41+ await crypto.subtle.encrypt(42+ { name: "AES-GCM", iv: zeroNonce },43+ await aesKey(key),44+ input,45+ ),46+ );47+ return {48+ ciphertext: encrypted,49+ key: hex(key),50+ hash: hex(await digest(encrypted)),51+ };3752 };3853 3954 const decryptCHK = async (ciphertext, key, hash) => {4055 const input = bytes(ciphertext);4156 const chkKey = fromHex(key);42- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");43- if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch");57+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))58+ throw new Error("expected a 64-character lowercase blob hash");59+ if (hex(await digest(input)) !== hash)60+ throw new Error("ciphertext hash mismatch");4461 let plain;4562 try {46- plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(chkKey), input));63+ plain = new Uint8Array(64+ await crypto.subtle.decrypt(65+ { name: "AES-GCM", iv: zeroNonce },66+ await aesKey(chkKey),67+ input,68+ ),69+ );4770 } catch {4871 throw new Error("CHK decryption failed");4972 }50- if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch");73+ if (!equal(await digest(plain), chkKey))74+ throw new Error("plaintext hash mismatch");5175 return plain;5276 };5377 5478 const createURI = (hash, extension, key) => {55- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");56- const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "") : "");79+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))80+ throw new Error("expected a 64-character lowercase blob hash");81+ const path =82+ "blossom:" +83+ hash +84+ (extension ? "." + String(extension).replace(/^\.+/, "") : "");5785 fromHex(key);58- const params = new URLSearchParams({enc: "chk-v1", k: key});86+ const params = new URLSearchParams({ enc: "chk-v1", k: key });5987 return path + "?" + params;6088 };6189 62- const parseURI = value => {63- if (typeof value !== "string" || !value.startsWith("blossom:")) throw new Error("expected a Blossom URI");64- const parsed = new URL(value.slice("blossom:".length), "https://blossom.invalid");65- const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/);66- if (!match || parsed.searchParams.get("enc") !== "chk-v1") throw new Error("invalid CHK Blossom URI");90+ const parseURI = (value) => {91+ if (typeof value !== "string" || !value.startsWith("blossom:"))92+ throw new Error("expected a Blossom URI");93+ const parsed = new URL(94+ value.slice("blossom:".length),95+ "https://blossom.invalid",96+ );97+ const match = parsed.pathname.match(98+ /^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/,99+ );100+ if (!match || parsed.searchParams.get("enc") !== "chk-v1")101+ throw new Error("invalid CHK Blossom URI");67102 const key = parsed.searchParams.get("k");68103 fromHex(key);69- return {hash: match[1], extension: match[2] || "", key};104+ return { hash: match[1], extension: match[2] || "", key };70105 };71106 72- globalThis.TinyBlossomEncryption = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});107+ const api = Object.freeze({ encryptCHK, decryptCHK, createURI, parseURI });108+ globalThis.tiny = globalThis.tiny || {};109+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, encryption: api };73110 })();
M internal/webui/blossom-manifests.js +617 -439
@@ -1,466 +1,644 @@
1+// BUD-16 and BUD-17 MessagePack manifests. This is maintained browser source,2+// embedded directly by the Go templates; it is intentionally not bundled.13 (() => {2- (() => {3- "use strict";4- const CHUNK_SIZE = 2 * 1024 * 1024;5- const MAX_LINKS = 174;6- const MAX_DEPTH = 64;7- const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;8- const te = new TextEncoder(), td = new TextDecoder("utf-8", { fatal: true });9- const asBytes = (v) => v instanceof Uint8Array ? v : ArrayBuffer.isView(v) ? new Uint8Array(v.buffer, v.byteOffset, v.byteLength) : (() => {10- throw new TypeError("expected bytes");11- })();12- const hashBytes = (h) => {13- if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h)) throw new Error("expected lowercase SHA-256 hash");14- return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));15- };16- const keyBytes = (k) => {17- if (k === void 0) 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) => typeof n === "string" && n.length > 0 && n !== "." && n !== ".." && wellFormed(n) && !n.includes("/") && !n.includes("\0") && te.encode(n).length <= 1024;32- const int = (n) => Number.isSafeInteger(n) && n >= 0;33- const utf8cmp = (a, b) => {34- const x = te.encode(a), y = te.encode(b);35- for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];36- return x.length - y.length;37- };38- class Writer {39- constructor() {40- this.a = [];41- }42- push(...x) {43- this.a.push(...x);44- }45- u8(n) {46- this.a.push(n & 255);47- }48- bytes() {49- return Uint8Array.from(this.a);50- }4+ "use strict";5+ const CHUNK_SIZE = 2 * 1024 * 1024;6+ const MAX_LINKS = 174;7+ const MAX_DEPTH = 64;8+ const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;9+ const textEncoder = new TextEncoder(),10+ textDecoder = new TextDecoder("utf-8", { fatal: true });11+ const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;12+ const hashBytes = (h) => {13+ if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h))14+ throw new Error("expected lowercase SHA-256 hash");15+ return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));16+ };17+ const keyBytes = (k) => {18+ if (k === undefined) return;19+ const b = asBytes(k);20+ if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");21+ return b;22+ };23+ const wellFormed = (value) => {24+ for (let i = 0; i < value.length; i++) {25+ const c = value.charCodeAt(i);26+ if (c >= 55296 && c <= 56319) {27+ if (28+ ++i >= value.length ||29+ value.charCodeAt(i) < 56320 ||30+ value.charCodeAt(i) > 5734331+ )32+ return false;33+ } else if (c >= 56320 && c <= 57343) return false;5134 }52- const putInt = (w, n) => {53- if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");54- if (n >= 0) {55- if (n < 128) w.u8(n);56- else if (n < 256) w.push(204, n);57- else if (n < 65536) w.push(205, n >> 8, n);58- else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);59- else {60- w.u8(207);61- let x = BigInt(n);62- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));63- }64- } else if (n >= -32) w.u8(256 + n);65- else if (n >= -128) w.push(208, n & 255);66- else if (n >= -32768) w.push(209, n >> 8 & 255, n & 255);67- else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);35+ return true;36+ };37+ const nameOK = (n) =>38+ typeof n === "string" &&39+ n.length > 0 &&40+ n !== "." &&41+ n !== ".." &&42+ wellFormed(n) &&43+ !n.includes("/") &&44+ !n.includes("\0") &&45+ textEncoder.encode(n).length <= 1024;46+ const int = (n) => Number.isSafeInteger(n) && n >= 0;47+ const utf8cmp = (a, b) => {48+ const x = textEncoder.encode(a),49+ y = textEncoder.encode(b);50+ for (let i = 0; i < Math.min(x.length, y.length); i++)51+ if (x[i] !== y[i]) return x[i] - y[i];52+ return x.length - y.length;53+ };54+ class MessagePackWriter {55+ constructor() {56+ this.output = [];57+ }58+ push(...x) {59+ this.output.push(...x);60+ }61+ u8(n) {62+ this.output.push(n & 255);63+ }64+ bytes() {65+ return Uint8Array.from(this.output);66+ }67+ }68+ const writeInteger = (w, n) => {69+ if (!Number.isSafeInteger(n))70+ throw new Error("manifest integers must be safe integers");71+ if (n >= 0) {72+ if (n < 128) w.u8(n);73+ else if (n < 256) w.push(204, n);74+ else if (n < 65536) w.push(205, n >> 8, n);75+ else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);6876 else {69- w.u8(211);70- let x = BigInt.asUintN(64, BigInt(n));71- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));72- }73- };74- const put = (w, v) => {75- if (v === null) return w.u8(192);76- if (v === false) return w.u8(194);77- if (v === true) return w.u8(195);78- if (typeof v === "number") {79- if (Number.isInteger(v)) return putInt(w, v);80- if (!Number.isFinite(v)) throw Error("metadata number must be finite");81- w.u8(203);82- const b = new DataView(new ArrayBuffer(8));83- b.setFloat64(0, v);84- for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));85- return;86- }87- if (typeof v === "string") {88- const b = te.encode(v);89- if (b.length < 32) w.u8(160 | b.length);90- else if (b.length < 256) w.push(217, b.length);91- else if (b.length < 65536) w.push(218, b.length >> 8, b.length);92- else throw new Error("string too large");93- w.push(...b);94- return;95- }96- if (v instanceof Uint8Array) {97- if (v.length < 256) w.push(196, v.length);98- else if (v.length < 65536) w.push(197, v.length >> 8, v.length);99- else throw new Error("binary value too large");100- w.push(...v);101- return;102- }103- if (Array.isArray(v)) {104- if (v.length < 16) w.u8(144 | v.length);105- else if (v.length < 65536) w.push(220, v.length >> 8, v.length);106- else throw new Error("array too large");107- v.forEach((x) => put(w, x));108- return;77+ w.u8(207);78+ let x = BigInt(n);79+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));10980 }81+ } else if (n >= -32) w.u8(256 + n);82+ else if (n >= -128) w.push(208, n & 255);83+ else if (n >= -32768) w.push(209, (n >> 8) & 255, n & 255);84+ else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);85+ else {86+ w.u8(211);87+ let x = BigInt.asUintN(64, BigInt(n));88+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));89+ }90+ };91+ const writeValue = (w, v) => {92+ if (v === null) return w.u8(192);93+ if (v === false) return w.u8(194);94+ if (v === true) return w.u8(195);95+ if (typeof v === "number") {96+ if (Number.isInteger(v)) return writeInteger(w, v);97+ if (!Number.isFinite(v)) throw Error("metadata number must be finite");98+ w.u8(203);99+ const b = new DataView(new ArrayBuffer(8));100+ b.setFloat64(0, v);101+ for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));102+ return;103+ }104+ if (typeof v === "string") {105+ const b = textEncoder.encode(v);106+ if (b.length < 32) w.u8(160 | b.length);107+ else if (b.length < 256) w.push(217, b.length);108+ else if (b.length < 65536) w.push(218, b.length >> 8, b.length);109+ else throw new Error("string too large");110+ w.push(...b);111+ return;112+ }113+ if (v instanceof Uint8Array) {114+ if (v.length < 256) w.push(196, v.length);115+ else if (v.length < 65536) w.push(197, v.length >> 8, v.length);116+ else throw new Error("binary value too large");117+ w.push(...v);118+ return;119+ }120+ if (Array.isArray(v)) {121+ if (v.length < 16) w.u8(144 | v.length);122+ else if (v.length < 65536) w.push(220, v.length >> 8, v.length);123+ else throw new Error("array too large");124+ v.forEach((x) => writeValue(w, x));125+ return;126+ }127+ if (v && typeof v === "object") {128+ const keys = Object.keys(v).sort(utf8cmp);129+ if (keys.length < 16) w.u8(128 | keys.length);130+ else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);131+ else throw new Error("map too large");132+ keys.forEach((k) => {133+ writeValue(w, k);134+ writeValue(w, v[k]);135+ });136+ return;137+ }138+ throw new Error("unsupported manifest value");139+ };140+ class MessagePackReader {141+ constructor(b) {142+ this.input = asBytes(b);143+ this.offset = 0;144+ }145+ need(n) {146+ if (this.offset + n > this.input.length) throw new Error("truncated MessagePack");147+ }148+ u8() {149+ this.need(1);150+ return this.input[this.offset++];151+ }152+ n(len) {153+ this.need(len);154+ const x = this.input.slice(this.offset, this.offset + len);155+ this.offset += len;156+ return x;157+ }158+ }159+ const readMap = (r, n, depth) => {160+ const o = Object.create(null);161+ for (let i = 0; i < n; i++) {162+ const k = readValue(r, depth + 1);163+ if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k))164+ throw Error("invalid map key");165+ o[k] = readValue(r, depth + 1);166+ }167+ return o;168+ };169+ const readValue = (r, depth = 0) => {170+ if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");171+ const t = r.u8();172+ if (t <= 127) return t;173+ if (t >= 224) return t - 256;174+ if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));175+ if ((t & 240) === 144)176+ return Array.from({ length: t & 15 }, () => readValue(r, depth + 1));177+ if ((t & 240) === 128) return readMap(r, t & 15, depth);178+ if (t === 192) return null;179+ if (t === 194) return false;180+ if (t === 195) return true;181+ let n;182+ if (t === 204) n = r.u8();183+ else if (t === 205) n = (r.u8() << 8) | r.u8();184+ else if (t === 206)185+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();186+ else if (t === 207) {187+ let x = 0n;188+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());189+ if (x > BigInt(Number.MAX_SAFE_INTEGER))190+ throw Error("integer exceeds safe range");191+ n = Number(x);192+ } else if (t === 208) n = (r.u8() << 24) >> 24;193+ else if (t === 209) {194+ n = (r.u8() << 8) | r.u8();195+ if (n & 32768) n -= 65536;196+ } else if (t === 210) {197+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();198+ if (n >= 2147483648) n -= 4294967296;199+ } else if (t === 211) {200+ let x = 0n;201+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());202+ if (x & 0x8000000000000000n) x -= 0x10000000000000000n;203+ if (204+ x > BigInt(Number.MAX_SAFE_INTEGER) ||205+ x < BigInt(Number.MIN_SAFE_INTEGER)206+ )207+ throw Error("integer exceeds safe range");208+ n = Number(x);209+ } else if (t === 202) {210+ const b = new ArrayBuffer(4),211+ d = new DataView(b);212+ for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());213+ n = d.getFloat32(0);214+ } else if (t === 203) {215+ const b = new ArrayBuffer(8),216+ d = new DataView(b);217+ for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());218+ n = d.getFloat64(0);219+ } else if (t === 217) n = textDecoder.decode(r.n(r.u8()));220+ else if (t === 218) n = textDecoder.decode(r.n((r.u8() << 8) | r.u8()));221+ else if (t === 196) n = r.n(r.u8());222+ else if (t === 197) n = r.n((r.u8() << 8) | r.u8());223+ else if (t === 220) {224+ n = (r.u8() << 8) | r.u8();225+ return Array.from({ length: n }, () => readValue(r, depth + 1));226+ } else if (t === 222) {227+ n = (r.u8() << 8) | r.u8();228+ return readMap(r, n, depth);229+ } else throw Error("unsupported MessagePack type");230+ return n;231+ };232+ const validateMeta = (m, depth = 0) => {233+ if (m === undefined) return;234+ if (235+ depth > MAX_DEPTH ||236+ !m ||237+ Array.isArray(m) ||238+ typeof m !== "object" ||239+ m instanceof Uint8Array240+ )241+ throw Error("metadata must be a JSON map");242+ const keys = Object.keys(m);243+ if (keys.length > 64) throw Error("too many metadata keys");244+ for (const [k, v] of Object.entries(m)) {245+ if (typeof k !== "string" || textEncoder.encode(k).length > 256)246+ throw Error("invalid metadata key");247+ if (248+ v === undefined ||249+ typeof v === "bigint" ||250+ typeof v === "function" ||251+ typeof v === "symbol" ||252+ (typeof v === "number" && !Number.isFinite(v))253+ )254+ throw Error("metadata is not JSON-compatible");110255 if (v && typeof v === "object") {111- const keys = Object.keys(v).sort(utf8cmp);112- if (keys.length < 16) w.u8(128 | keys.length);113- else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);114- else throw new Error("map too large");115- keys.forEach((k) => {116- put(w, k);117- put(w, v[k]);118- });119- return;256+ if (Array.isArray(v)) {257+ if (v.length > 256) throw Error("metadata array too large");258+ v.forEach((x) => validateMeta({ x }, depth + 1));259+ } else validateMeta(v, depth + 1);120260 }121- throw new Error("unsupported manifest value");122- };123- class Reader {124- constructor(b) {125- this.b = asBytes(b);126- this.i = 0;127- }128- need(n) {129- if (this.i + n > this.b.length) throw new Error("truncated MessagePack");261+ }262+ };263+ const validateLink = (x, nodeType) => {264+ if (!x || typeof x !== "object" || Array.isArray(x))265+ throw Error("invalid manifest link");266+ if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k)))267+ throw Error("unknown manifest link field");268+ if (!(x.h instanceof Uint8Array) || x.h.length !== 32)269+ throw Error("link hash must be 32 bytes");270+ if (x.k !== undefined) keyBytes(x.k);271+ if (x.m !== undefined) validateMeta(x.m);272+ if (!int(x.s)) throw Error("invalid link size");273+ if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");274+ if (nodeType === 1 && x.t !== 0 && x.t !== 1)275+ throw Error("invalid file link type");276+ if (nodeType === 1 && x.n !== undefined)277+ throw Error("file links cannot have names");278+ if (nodeType === 3 && x.n !== undefined)279+ throw Error("fanout links cannot have names");280+ if (nodeType === 3 && x.t !== 2 && x.t !== 3)281+ throw Error("invalid fanout child type");282+ if (nodeType === 2 && !nameOK(x.n))283+ throw Error("invalid directory entry name");284+ };285+ const validateNode = (node, depth = 0) => {286+ if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");287+ if (288+ !node ||289+ typeof node !== "object" ||290+ Array.isArray(node) ||291+ !Array.isArray(node.l) ||292+ !int(node.t) ||293+ ![1, 2, 3].includes(node.t) ||294+ Object.keys(node).some((k) => k !== "l" && k !== "t")295+ )296+ throw Error("invalid manifest node");297+ if (node.l.length > MAX_LINKS) throw Error("too many manifest links");298+ const seen = new Set(),299+ links = node.l;300+ for (const x of links) {301+ validateLink(x, node.t);302+ if (node.t === 2) {303+ if (seen.has(x.n)) throw Error("duplicate directory entry");304+ seen.add(x.n);130305 }131- u8() {132- this.need(1);133- return this.b[this.i++];306+ if (node.t === 3) {307+ if (308+ !x.m ||309+ !int(x.m.count) ||310+ x.m.count < 1 ||311+ !nameOK(x.m.first) ||312+ !nameOK(x.m.last) ||313+ utf8cmp(x.m.first, x.m.last) > 0314+ )315+ throw Error("invalid fanout bounds");134316 }135- n(len) {136- this.need(len);137- const x = this.b.slice(this.i, this.i + len);138- this.i += len;139- return x;317+ }318+ if (319+ node.t === 2 &&320+ links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)321+ )322+ throw Error("directory links are not canonical");323+ return node;324+ };325+ const encodeManifest = (node) => {326+ validateNode(node);327+ const w = new MessagePackWriter();328+ w.u8(130);329+ writeValue(w, "l");330+ if (node.l.length < 16) w.u8(144 | node.l.length);331+ else w.push(220, node.l.length >> 8, node.l.length);332+ for (const x of node.l) {333+ const fields = [334+ "h",335+ ...(x.k !== undefined ? ["k"] : []),336+ ...(x.m !== undefined ? ["m"] : []),337+ ...(x.n !== undefined ? ["n"] : []),338+ "s",339+ "t",340+ ];341+ if (fields.length < 16) w.u8(128 | fields.length);342+ else w.push(222, fields.length >> 8, fields.length);343+ for (const k of fields) {344+ writeValue(w, k);345+ writeValue(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);140346 }141347 }142- const readMap = (r, n, depth) => {143- const o = /* @__PURE__ */ Object.create(null);144- for (let i = 0; i < n; i++) {145- const k = get(r, depth + 1);146- if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");147- o[k] = get(r, depth + 1);348+ writeValue(w, "t");349+ writeValue(w, node.t);350+ const out = w.bytes();351+ if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");352+ return out;353+ };354+ const decodeManifest = (bytes, options = {}) => {355+ const b = asBytes(bytes);356+ if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");357+ const r = new MessagePackReader(b),358+ node = readValue(r);359+ if (r.offset !== b.length) throw Error("trailing MessagePack data");360+ validateNode(node);361+ if (node.t === 3)362+ for (const x of node.l) {363+ if (x.m.count > Number(options.maxEntries || 1e9))364+ throw Error("fanout count limit exceeded");148365 }149- return o;150- };151- const get = (r, depth = 0) => {152- if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");153- const t = r.u8();154- if (t <= 127) return t;155- if (t >= 224) return t - 256;156- if ((t & 224) === 160) return td.decode(r.n(t & 31));157- if ((t & 240) === 144) return Array.from({ length: t & 15 }, () => get(r, depth + 1));158- if ((t & 240) === 128) return readMap(r, t & 15, depth);159- if (t === 192) return null;160- if (t === 194) return false;161- if (t === 195) return true;162- let n;163- if (t === 204) n = r.u8();164- else if (t === 205) n = r.u8() << 8 | r.u8();165- else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();166- else if (t === 207) {167- let x = 0n;168- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());169- if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");170- n = Number(x);171- } else if (t === 208) n = r.u8() << 24 >> 24;172- else if (t === 209) {173- n = r.u8() << 8 | r.u8();174- if (n & 32768) n -= 65536;175- } else if (t === 210) {176- n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();177- if (n >= 2147483648) n -= 4294967296;178- } else if (t === 211) {179- let x = 0n;180- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());181- if (x & 0x8000000000000000n) x -= 0x10000000000000000n;182- if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER)) throw Error("integer exceeds safe range");183- n = Number(x);184- } else if (t === 202) {185- const b = new ArrayBuffer(4), d = new DataView(b);186- for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());187- n = d.getFloat32(0);188- } else if (t === 203) {189- const b = new ArrayBuffer(8), d = new DataView(b);190- for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());191- n = d.getFloat64(0);192- } else if (t === 217) n = td.decode(r.n(r.u8()));193- else if (t === 218) n = td.decode(r.n(r.u8() << 8 | r.u8()));194- else if (t === 196) n = r.n(r.u8());195- else if (t === 197) n = r.n(r.u8() << 8 | r.u8());196- else if (t === 220) {197- n = r.u8() << 8 | r.u8();198- return Array.from({ length: n }, () => get(r, depth + 1));199- } else if (t === 222) {200- n = r.u8() << 8 | r.u8();201- return readMap(r, n, depth);202- } else throw Error("unsupported MessagePack type");203- return n;204- };205- const validateMeta = (m, depth = 0) => {206- if (m === void 0) return;207- if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array) throw Error("metadata must be a JSON map");208- const keys = Object.keys(m);209- if (keys.length > 64) throw Error("too many metadata keys");210- for (const [k, v] of Object.entries(m)) {211- if (typeof k !== "string" || te.encode(k).length > 256) throw Error("invalid metadata key");212- if (v === void 0 || typeof v === "bigint" || typeof v === "function" || typeof v === "symbol" || typeof v === "number" && !Number.isFinite(v)) throw Error("metadata is not JSON-compatible");213- if (v && typeof v === "object") {214- if (Array.isArray(v)) {215- if (v.length > 256) throw Error("metadata array too large");216- v.forEach((x) => validateMeta({ x }, depth + 1));217- } else validateMeta(v, depth + 1);218- }366+ return node;367+ };368+ const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));369+ const leaf = (hash, size, key) => {370+ const x = { h: hashBytes(hash), s: size, t: 0 };371+ if (key !== undefined) x.k = keyBytes(key);372+ return x;373+ };374+ const storeNode = async (node, options, manifests) => {375+ const bytes = encodeManifest(node),376+ computed = hex(await sha256(bytes));377+ let result = { hash: computed };378+ if (options.store) {379+ const stored = await options.store({ hash: computed, bytes, node });380+ if (stored) {381+ if (typeof stored === "string") result.hash = stored;382+ else result = { ...result, ...stored };219383 }220- };221- const validateLink = (x, nodeType) => {222- if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");223- if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k))) throw Error("unknown manifest link field");224- if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");225- if (x.k !== void 0) keyBytes(x.k);226- if (x.m !== void 0) validateMeta(x.m);227- if (!int(x.s)) throw Error("invalid link size");228- if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");229- if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");230- if (nodeType === 1 && x.n !== void 0) throw Error("file links cannot have names");231- if (nodeType === 3 && x.n !== void 0) throw Error("fanout links cannot have names");232- if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");233- if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");234- };235- const validateNode = (node, depth = 0) => {236- if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");237- if (!node || typeof node !== "object" || Array.isArray(node) || !Array.isArray(node.l) || !int(node.t) || ![1, 2, 3].includes(node.t) || Object.keys(node).some((k) => k !== "l" && k !== "t")) throw Error("invalid manifest node");238- if (node.l.length > MAX_LINKS) throw Error("too many manifest links");239- const seen = /* @__PURE__ */ new Set(), links = node.l;240- for (const x of links) {241- validateLink(x, node.t);242- if (node.t === 2) {243- if (seen.has(x.n)) throw Error("duplicate directory entry");244- seen.add(x.n);245- }246- if (node.t === 3) {247- if (!x.m || !int(x.m.count) || x.m.count < 1 || !nameOK(x.m.first) || !nameOK(x.m.last) || utf8cmp(x.m.first, x.m.last) > 0) throw Error("invalid fanout bounds");248- }384+ }385+ if (!/^[0-9a-f]{64}$/.test(result.hash))386+ throw Error("store returned invalid manifest hash");387+ if (result.key !== undefined) keyBytes(result.key);388+ manifests.push({ hash: result.hash, bytes, node, key: result.key });389+ return result;390+ };391+ const buildFile = async (chunks, options = {}) => {392+ const max = options.maxLinks ?? MAX_LINKS;393+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)394+ throw Error("invalid maxLinks");395+ const leaves = chunks.map((c) =>396+ typeof c === "string"397+ ? leaf(c, options.chunkSize || 0)398+ : leaf(c.hash, c.size, c.key),399+ );400+ if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE))401+ throw Error("invalid chunk size");402+ const manifests = [];403+ let level = leaves;404+ while (level.length > max) {405+ const next = [];406+ for (let i = 0; i < level.length; i += max) {407+ const group = level.slice(i, i + max),408+ child = { l: group, t: 1 },409+ stored = await storeNode(child, options, manifests);410+ next.push({411+ h: hashBytes(stored.hash),412+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),413+ s: group.reduce((n, x) => n + x.s, 0),414+ t: 1,415+ });249416 }250- if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)) throw Error("directory links are not canonical");251- return node;252- };253- const encodeManifest = (node) => {254- validateNode(node);255- const w = new Writer();256- w.u8(130);257- put(w, "l");258- if (node.l.length < 16) w.u8(144 | node.l.length);259- else w.push(220, node.l.length >> 8, node.l.length);260- for (const x of node.l) {261- const fields = ["h", ...x.k !== void 0 ? ["k"] : [], ...x.m !== void 0 ? ["m"] : [], ...x.n !== void 0 ? ["n"] : [], "s", "t"];262- if (fields.length < 16) w.u8(128 | fields.length);263- else w.push(222, fields.length >> 8, fields.length);264- for (const k of fields) {265- put(w, k);266- put(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);267- }417+ level = next;418+ }419+ const root = { l: level, t: 1 };420+ await storeNode(root, options, manifests);421+ return options.returnDetails ? { root, manifests } : root;422+ };423+ const buildDirectory = async (entries, options = {}) => {424+ if (!Array.isArray(entries)) throw Error("entries must be an array");425+ const max = options.maxLinks ?? MAX_LINKS;426+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)427+ throw Error("invalid maxLinks");428+ const sorted = entries429+ .map((e) => {430+ const x = {431+ h: hashBytes(e.hash),432+ n: e.name,433+ s: e.size || 0,434+ t: e.type || 0,435+ };436+ if (e.key !== undefined) x.k = keyBytes(e.key);437+ if (e.metadata !== undefined) x.m = e.metadata;438+ return x;439+ })440+ .sort((a, b) => utf8cmp(a.n, b.n));441+ for (let i = 1; i < sorted.length; i++)442+ if (sorted[i - 1].n === sorted[i].n)443+ throw Error("duplicate directory entry");444+ const manifests = [];445+ if (sorted.length <= max) {446+ const directory = { l: sorted, t: 2 };447+ await storeNode(directory, options, manifests);448+ return options.returnDetails ? { root: directory, manifests } : directory;449+ }450+ let level = [];451+ for (let i = 0; i < sorted.length; i += max) {452+ const l = sorted.slice(i, i + max),453+ child = { l, t: 2 },454+ stored = await storeNode(child, options, manifests);455+ level.push({456+ h: hashBytes(stored.hash),457+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),458+ m: { count: l.length, first: l[0].n, last: l[l.length - 1].n },459+ s: l.reduce((n, x) => n + x.s, 0),460+ t: 2,461+ });462+ }463+ while (level.length > max) {464+ const next = [];465+ for (let i = 0; i < level.length; i += max) {466+ const l = level.slice(i, i + max),467+ child = { l, t: 3 },468+ stored = await storeNode(child, options, manifests);469+ next.push({470+ h: hashBytes(stored.hash),471+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),472+ m: {473+ count: l.reduce((n, x) => n + x.m.count, 0),474+ first: l[0].m.first,475+ last: l[l.length - 1].m.last,476+ },477+ s: l.reduce((n, x) => n + x.s, 0),478+ t: 3,479+ });268480 }269- put(w, "t");270- put(w, node.t);271- const out = w.bytes();272- if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");481+ level = next;482+ }483+ const root = { l: level, t: 3 };484+ await storeNode(root, options, manifests);485+ return options.returnDetails ? { root, manifests } : root;486+ };487+ const flattenDirectory = (node, out = []) => {488+ validateNode(node);489+ if (node.t === 2) {490+ out.push(...node.l);273491 return out;274- };275- const decodeManifest = (bytes, options = {}) => {276- const b = asBytes(bytes);277- if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");278- const r = new Reader(b), node = get(r);279- if (r.i !== b.length) throw Error("trailing MessagePack data");492+ }493+ if (node.t !== 3) throw Error("not a directory");494+ for (const x of node.l) if (x.child) flattenDirectory(x.child, out);495+ return out;496+ };497+ const resolveDirectory = async (root, fetchNode, options = {}) => {498+ if (typeof fetchNode !== "function") throw Error("fetchNode is required");499+ const maxDepth = options.maxDepth ?? MAX_DEPTH,500+ maxEntries = options.maxEntries ?? 1e5,501+ maxManifests = options.maxManifests ?? 1e4,502+ maxBytes = options.maxBytes ?? 64 * 1024 * 1024;503+ if (504+ [maxDepth, maxEntries, maxManifests, maxBytes].some(505+ (value) => !Number.isSafeInteger(value) || value < 0,506+ )507+ )508+ throw Error("invalid directory limits");509+ let count = 0,510+ manifestCount = 0,511+ fetched = 0;512+ const active = new Set();513+ const walk = async (node, depth) => {514+ if (depth > maxDepth) throw Error("directory recursion limit exceeded");515+ if (++manifestCount > maxManifests)516+ throw Error("manifest count limit exceeded");280517 validateNode(node);281- if (node.t === 3) for (const x of node.l) {282- if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");518+ if (node.t === 2) {519+ count += node.l.length;520+ if (count > maxEntries) throw Error("directory entry limit exceeded");521+ return node.l.slice();283522 }284- return node;285- };286- const sha256 = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", asBytes(b)));287- const hex = (b) => Array.from(asBytes(b), (x) => x.toString(16).padStart(2, "0")).join("");288- const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));289- const leaf = (hash, size, key) => {290- const x = { h: hashBytes(hash), s: size, t: 0 };291- if (key !== void 0) x.k = keyBytes(key);292- return x;293- };294- const storeNode = async (node, options, manifests) => {295- const bytes = encodeManifest(node), computed = hex(await sha256(bytes));296- let result = { hash: computed };297- if (options.store) {298- const stored = await options.store({ hash: computed, bytes, node });299- if (stored) {300- if (typeof stored === "string") result.hash = stored;301- else result = { ...result, ...stored };523+ if (node.t !== 3) throw Error("not a directory");524+ const result = [];525+ let previous = "";526+ for (const link of node.l) {527+ if (previous && utf8cmp(previous, link.m.first) >= 0)528+ throw Error("fanout bounds are not ordered");529+ previous = link.m.last;530+ const linkHash = hex(link.h);531+ if (active.has(linkHash)) throw Error("manifest cycle detected");532+ active.add(linkHash);533+ let raw = asBytes(await fetchNode(linkHash, link));534+ fetched += raw.length;535+ if (fetched > maxBytes) throw Error("directory byte limit exceeded");536+ if (hex(await sha256(raw)) !== linkHash)537+ throw Error("manifest hash mismatch");538+ if (link.k) {539+ if (typeof options.decrypt !== "function")540+ throw Error("encrypted manifest requires decrypt callback");541+ raw = asBytes(await options.decrypt(raw, link));302542 }543+ const child = decodeManifest(raw);544+ if (child.t !== link.t) throw Error("fanout child type mismatch");545+ const links = await walk(child, depth + 1);546+ active.delete(linkHash);547+ if (548+ links.length !== link.m.count ||549+ links[0]?.n !== link.m.first ||550+ links.at(-1)?.n !== link.m.last551+ )552+ throw Error("fanout bounds mismatch");553+ result.push(...links);303554 }304- if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");305- if (result.key !== void 0) keyBytes(result.key);306- manifests.push({ hash: result.hash, bytes, node, key: result.key });555+ for (let i = 1; i < result.length; i++)556+ if (utf8cmp(result[i - 1].n, result[i].n) >= 0)557+ throw Error("duplicate or unordered directory entry");307558 return result;308559 };309- const buildFile = async (chunks, options = {}) => {310- const max = options.maxLinks ?? MAX_LINKS;311- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");312- const leaves = chunks.map((c) => typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key));313- if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");314- const manifests = [];315- let level = leaves;316- while (level.length > max) {317- const next = [];318- for (let i = 0; i < level.length; i += max) {319- const group = level.slice(i, i + max), child = { l: group, t: 1 }, stored = await storeNode(child, options, manifests);320- next.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, s: group.reduce((n, x) => n + x.s, 0), t: 1 });321- }322- level = next;323- }324- const root = { l: level, t: 1 };325- await storeNode(root, options, manifests);326- return options.returnDetails ? { root, manifests } : root;327- };328- const buildDirectory = async (entries, options = {}) => {329- if (!Array.isArray(entries)) throw Error("entries must be an array");330- const max = options.maxLinks ?? MAX_LINKS;331- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");332- const sorted = entries.map((e) => {333- const x = { h: hashBytes(e.hash), n: e.name, s: e.size || 0, t: e.type || 0 };334- if (e.key !== void 0) x.k = keyBytes(e.key);335- if (e.metadata !== void 0) x.m = e.metadata;336- return x;337- }).sort((a, b) => utf8cmp(a.n, b.n));338- for (let i = 1; i < sorted.length; i++) if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");339- const manifests = [];340- if (sorted.length <= max) {341- const root2 = { l: sorted, t: 2 };342- await storeNode(root2, options, manifests);343- return options.returnDetails ? { root: root2, manifests } : root2;344- }345- let level = [];346- for (let i = 0; i < sorted.length; i += max) {347- const l = sorted.slice(i, i + max), child = { l, t: 2 }, stored = await storeNode(child, options, manifests);348- level.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, m: { count: l.length, first: l[0].n, last: l[l.length - 1].n }, s: l.reduce((n, x) => n + x.s, 0), t: 2 });349- }350- while (level.length > max) {351- const next = [];352- for (let i = 0; i < level.length; i += max) {353- const l = level.slice(i, i + max), child = { l, t: 3 }, stored = await storeNode(child, options, manifests);354- next.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, m: { count: l.reduce((n, x) => n + x.m.count, 0), first: l[0].m.first, last: l[l.length - 1].m.last }, s: l.reduce((n, x) => n + x.s, 0), t: 3 });355- }356- level = next;357- }358- const root = { l: level, t: 3 };359- await storeNode(root, options, manifests);360- return options.returnDetails ? { root, manifests } : root;361- };362- const flattenDirectory = (node, out = []) => {560+ return walk(root, 0);561+ };562+ const readFile = async (root, fetchBlob, options = {}) => {563+ if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");564+ const maxDepth = options.maxDepth ?? MAX_DEPTH,565+ maxBytes = options.maxBytes ?? 512 * 1024 * 1024,566+ maxManifests = options.maxManifests ?? 1e4;567+ const maxFetchedBytes =568+ options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;569+ if (570+ [maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(571+ (value) => !Number.isSafeInteger(value) || value < 0,572+ )573+ )574+ throw Error("invalid file limits");575+ let used = 0,576+ fetched = 0,577+ manifests = 0;578+ const active = new Set(),579+ parts = [];580+ const readNode = async (node, depth) => {581+ if (depth > maxDepth) throw Error("file recursion limit exceeded");363582 validateNode(node);364- if (node.t === 2) {365- out.push(...node.l);366- return out;367- }368- if (node.t !== 3) throw Error("not a directory");369- for (const x of node.l) if (x.child) flattenDirectory(x.child, out);370- return out;371- };372- const resolveDirectory = async (root, fetchNode, options = {}) => {373- if (typeof fetchNode !== "function") throw Error("fetchNode is required");374- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxEntries = options.maxEntries ?? 1e5, maxManifests = options.maxManifests ?? 1e4, maxBytes = options.maxBytes ?? 64 * 1024 * 1024;375- if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid directory limits");376- let count = 0, manifestCount = 0, fetched = 0;377- const active = /* @__PURE__ */ new Set();378- const walk = async (node, depth) => {379- if (depth > maxDepth) throw Error("directory recursion limit exceeded");380- if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");381- validateNode(node);382- if (node.t === 2) {383- count += node.l.length;384- if (count > maxEntries) throw Error("directory entry limit exceeded");385- return node.l.slice();386- }387- if (node.t !== 3) throw Error("not a directory");388- const result = [];389- let previous = "";390- for (const link of node.l) {391- if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");392- previous = link.m.last;393- const linkHash = hex(link.h);394- if (active.has(linkHash)) throw Error("manifest cycle detected");395- active.add(linkHash);396- let raw = asBytes(await fetchNode(linkHash, link));397- fetched += raw.length;398- if (fetched > maxBytes) throw Error("directory byte limit exceeded");399- if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");400- if (link.k) {401- if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");402- raw = asBytes(await options.decrypt(raw, link));403- }583+ if (node.t !== 1) throw Error("not a file manifest");584+ if (++manifests > maxManifests)585+ throw Error("manifest count limit exceeded");586+ for (const link of node.l) {587+ const hash = hex(link.h);588+ if (active.has(hash)) throw Error("manifest cycle detected");589+ active.add(hash);590+ let raw = asBytes(await fetchBlob(hash, link));591+ fetched += raw.length;592+ if (fetched > maxFetchedBytes)593+ throw Error("file fetched byte limit exceeded");594+ if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");595+ if (link.t === 1) {596+ if (typeof options.decrypt !== "function" && link.k)597+ throw Error("encrypted manifest requires decrypt callback");598+ if (link.k) raw = asBytes(await options.decrypt(raw, link));404599 const child = decodeManifest(raw);405- if (child.t !== link.t) throw Error("fanout child type mismatch");406- const links = await walk(child, depth + 1);407- active.delete(linkHash);408- if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last) throw Error("fanout bounds mismatch");409- result.push(...links);410- }411- for (let i = 1; i < result.length; i++) if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");412- return result;413- };414- return walk(root, 0);415- };416- const readFile = async (root, fetchBlob, options = {}) => {417- if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");418- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxBytes = options.maxBytes ?? 512 * 1024 * 1024, maxManifests = options.maxManifests ?? 1e4;419- const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;420- if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid file limits");421- let used = 0, fetched = 0, manifests = 0;422- const active = /* @__PURE__ */ new Set(), parts = [];423- const readNode = async (node, depth) => {424- if (depth > maxDepth) throw Error("file recursion limit exceeded");425- validateNode(node);426- if (node.t !== 1) throw Error("not a file manifest");427- if (++manifests > maxManifests) throw Error("manifest count limit exceeded");428- for (const link of node.l) {429- const hash = hex(link.h);430- if (active.has(hash)) throw Error("manifest cycle detected");431- active.add(hash);432- let raw = asBytes(await fetchBlob(hash, link));433- fetched += raw.length;434- if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");435- if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");436- if (link.t === 1) {437- if (typeof options.decrypt !== "function" && link.k) throw Error("encrypted manifest requires decrypt callback");438- if (link.k) raw = asBytes(await options.decrypt(raw, link));439- const child = decodeManifest(raw);440- if (child.t !== 1) throw Error("file child type mismatch");441- const before = used;442- await readNode(child, depth + 1);443- if (used - before !== link.s) throw Error("file manifest size mismatch");444- } else {445- if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");446- const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;447- if (data.length !== link.s) throw Error("file chunk size mismatch");448- used += data.length;449- if (used > maxBytes) throw Error("file byte limit exceeded");450- parts.push(data);451- }452- active.delete(hash);600+ if (child.t !== 1) throw Error("file child type mismatch");601+ const before = used;602+ await readNode(child, depth + 1);603+ if (used - before !== link.s)604+ throw Error("file manifest size mismatch");605+ } else {606+ if (link.k && typeof options.decrypt !== "function")607+ throw Error("encrypted chunk requires decrypt callback");608+ const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;609+ if (data.length !== link.s) throw Error("file chunk size mismatch");610+ used += data.length;611+ if (used > maxBytes) throw Error("file byte limit exceeded");612+ parts.push(data);453613 }454- };455- await readNode(root, 0);456- const out = new Uint8Array(used);457- let at = 0;458- for (const p of parts) {459- out.set(p, at);460- at += p.length;614+ active.delete(hash);461615 }462- return out;463616 };464- globalThis.TinyBlossomManifests = Object.freeze({ CHUNK_SIZE, MAX_LINKS, encodeManifest, decodeManifest, encode: encodeManifest, decode: decodeManifest, manifestHash, buildFile, buildDirectory, flattenDirectory, resolveDirectory, readFile, sha256, hex });465- })();617+ await readNode(root, 0);618+ const out = new Uint8Array(used);619+ let at = 0;620+ for (const p of parts) {621+ out.set(p, at);622+ at += p.length;623+ }624+ return out;625+ };626+ const api = Object.freeze({627+ CHUNK_SIZE,628+ MAX_LINKS,629+ encodeManifest,630+ decodeManifest,631+ encode: encodeManifest,632+ decode: decodeManifest,633+ manifestHash,634+ buildFile,635+ buildDirectory,636+ flattenDirectory,637+ resolveDirectory,638+ readFile,639+ sha256,640+ hex,641+ });642+ globalThis.tiny = globalThis.tiny || {};643+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, manifests: api };466644 })();
M internal/webui/blossom-upload.js +219 -65
@@ -1,80 +1,159 @@
11 // BUD-14 draft multipart uploads.2-// https://raw.githubusercontent.com/hzrd149/blossom/multi-part-uploads-2/buds/14.md2+// https://github.com/hzrd149/blossom/pull/10233 // Upload state is deliberately held by the caller. It does not survive a page44 // navigation because BUD-14 has no offset discovery protocol.55 (() => {66 "use strict";77 8- const sha256 = async bytes => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));9- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");10- const asBytes = async value => {8+ const {sha256, hex} = globalThis.tiny.util;9+ const asBytes = async (value) => {1110 if (value instanceof Uint8Array) return value;12- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);11+ if (ArrayBuffer.isView(value))12+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);1313 if (value instanceof ArrayBuffer) return new Uint8Array(value);1414 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());1515 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");1616 };17- const responseBody = async response => {18- try { return await response.json(); } catch { return null; }17+ const responseBody = async (response) => {18+ try {19+ return await response.json();20+ } catch {21+ return null;22+ }23+ };24+ const abortError = () => {25+ const error = new Error("upload canceled");26+ error.name = "AbortError";27+ return error;1928 };20- const abortError = () => { const error = new Error("upload canceled"); error.name = "AbortError"; return error; };2129 const verifyDescriptor = (descriptor, hash, size) => {22- if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size) throw new Error("invalid Blossom upload descriptor");30+ if (31+ !descriptor ||32+ typeof descriptor !== "object" ||33+ descriptor.sha256 !== hash ||34+ descriptor.size !== size35+ )36+ throw new Error("invalid Blossom upload descriptor");2337 return descriptor;2438 };2539 2640 const uploadTask = async (source, options = {}, controller) => {2741 const bytes = await asBytes(source);2842 const hash = options.hash || hex(await sha256(bytes));29- if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");30- if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");43+ if (!/^[0-9a-f]{64}$/.test(hash))44+ throw new Error("expected a lowercase SHA-256 hash");45+ if (options.hash && hash !== hex(await sha256(bytes)))46+ throw new Error("upload hash mismatch");3147 const type = options.type || source.type || "application/octet-stream";3248 const base = options.url || options.endpoint;33- if (typeof base !== "string" || base === "") throw new Error("upload URL is required");49+ if (typeof base !== "string" || base === "")50+ throw new Error("upload URL is required");3451 let parsed;35- try { parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid"); } catch { throw new Error("invalid upload URL"); }36- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("upload URL must use HTTP or HTTPS");37- if (parsed.username || parsed.password || parsed.hash || parsed.search) throw new Error("upload URL must not contain credentials, query, or fragment");38- const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";39- if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");52+ try {53+ parsed = new URL(54+ base,55+ globalThis.location?.href || "https://blossom.invalid",56+ );57+ } catch {58+ throw new Error("invalid upload URL");59+ }60+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")61+ throw new Error("upload URL must use HTTP or HTTPS");62+ if (parsed.username || parsed.password || parsed.hash || parsed.search)63+ throw new Error(64+ "upload URL must not contain credentials, query, or fragment",65+ );66+ const extension = options.extension67+ ? String(options.extension).replace(/^\.+/, "")68+ : "";69+ if (extension && !/^[a-z0-9]{1,8}$/.test(extension))70+ throw new Error("invalid file extension");4071 const path = parsed.pathname;41- const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path) ? base : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");72+ const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(73+ path,74+ )75+ ? base76+ : base.replace(/\/$/, "") +77+ "/" +78+ hash +79+ (extension ? "." + extension : "");4280 const url = target;4381 const fetcher = options.fetch || globalThis.fetch;4482 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");45- const chunkSize = Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;46- const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;47- const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;83+ const chunkSize =84+ Number.isSafeInteger(options.chunkSize) && options.chunkSize > 085+ ? options.chunkSize86+ : 5 * 1024 * 1024;87+ const timeoutMs =88+ Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 089+ ? options.timeoutMs90+ : 30000;91+ const retries =92+ Number.isSafeInteger(options.retries) && options.retries >= 093+ ? options.retries94+ : 0;4895 const state = options.state instanceof Map ? options.state : new Map();49- const notify = (offset, length, status) => options.onState?.({offset, length, status, state});50- const progress = sent => options.onProgress?.({sent, total: bytes.byteLength, fraction: bytes.byteLength ? sent / bytes.byteLength : 1});96+ const notify = (offset, length, status) =>97+ options.onState?.({ offset, length, status, state });98+ const progress = (sent) =>99+ options.onProgress?.({100+ sent,101+ total: bytes.byteLength,102+ fraction: bytes.byteLength ? sent / bytes.byteLength : 1,103+ });51104 const request = async (method, body, headers, authBody = body) => {52105 if (controller.signal.aborted) throw abortError();53106 const requestController = new AbortController();54107 let timer, rejectCanceled;55- const cancel = () => { requestController.abort(); rejectCanceled?.(abortError()); };56- controller.signal.addEventListener("abort", cancel, {once: true});108+ const cancel = () => {109+ requestController.abort();110+ rejectCanceled?.(abortError());111+ };112+ controller.signal.addEventListener("abort", cancel, { once: true });57113 try {58114 return await Promise.race([59115 (async () => {60- const requestHeaders = {...headers};116+ const requestHeaders = { ...headers };61117 if (options.authorize && method !== "OPTIONS") {62- const value = await options.authorize(url, method, authBody, {hash, type});63- if (typeof value === "string") requestHeaders.authorization = value;64- else if (value && typeof value === "object") Object.assign(requestHeaders, value);118+ const value = await options.authorize(url, method, authBody, {119+ hash,120+ type,121+ });122+ if (typeof value === "string")123+ requestHeaders.authorization = value;124+ else if (value && typeof value === "object")125+ Object.assign(requestHeaders, value);65126 }66127 if (requestController.signal.aborted) throw abortError();67- const response = await fetcher(url, {method, headers: requestHeaders, body: method === "OPTIONS" ? undefined : body, signal: requestController.signal});128+ const response = await fetcher(url, {129+ method,130+ headers: requestHeaders,131+ body: method === "OPTIONS" ? undefined : body,132+ signal: requestController.signal,133+ });68134 // Keep the request deadline and cancellation active until the69135 // descriptor body has arrived, not only until headers arrive.70- const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;136+ const descriptor =137+ response.status === 200 || response.status === 201138+ ? await responseBody(response)139+ : null;71140 if (!descriptor) await response.body?.cancel?.();72- return {status: response.status, ok: response.ok, headers: response.headers, json: async () => descriptor};141+ return {142+ status: response.status,143+ ok: response.ok,144+ headers: response.headers,145+ json: async () => descriptor,146+ };73147 })(),74148 new Promise((_, reject) => {75- timer = setTimeout(() => { requestController.abort(); reject(Error("upload request timed out")); }, timeoutMs);149+ timer = setTimeout(() => {150+ requestController.abort();151+ reject(Error("upload request timed out"));152+ }, timeoutMs);153+ }),154+ new Promise((_, reject) => {155+ rejectCanceled = reject;76156 }),77- new Promise((_, reject) => { rejectCanceled = reject; })78157 ]);79158 } finally {80159 clearTimeout(timer);
@@ -88,7 +167,10 @@
88167 if (controller.signal.aborted) throw abortError();89168 try {90169 const result = await action();91- if (!result.ok && result.status >= 500 && attempt++ < retries) { notify(offset, length, "retrying"); continue; }170+ if (!result.ok && result.status >= 500 && attempt++ < retries) {171+ notify(offset, length, "retrying");172+ continue;173+ }92174 return result;93175 } catch (error) {94176 if (controller.signal.aborted) throw abortError();
@@ -102,45 +184,112 @@
102184 if (options.probe !== false) {103185 try {104186 const response = await request("OPTIONS", null, {});105- supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");106- } catch { supportsPatch = false; }187+ supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(188+ response.headers?.get?.("allow") || "",189+ );190+ } catch {191+ supportsPatch = false;192+ }107193 }108194 if (!supportsPatch) {109- const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);110- if (!response.ok) throw new Error("Blossom upload failed: " + response.status);111- return {hash, descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength), state};195+ const response = await retry(196+ () => request("PUT", bytes, { "content-type": type }),197+ 0,198+ bytes.byteLength,199+ );200+ if (!response.ok)201+ throw new Error("Blossom upload failed: " + response.status);202+ return {203+ hash,204+ descriptor: verifyDescriptor(205+ await responseBody(response),206+ hash,207+ bytes.byteLength,208+ ),209+ state,210+ };112211 }113212 114213 const metadata = state.meta;115- if (!metadata || metadata.hash !== hash || metadata.size !== bytes.byteLength || metadata.chunkSize !== chunkSize || metadata.url !== url || metadata.type !== type || Date.now() - (metadata.lastAcceptedAt || 0) >= 60000) {214+ if (215+ !metadata ||216+ metadata.hash !== hash ||217+ metadata.size !== bytes.byteLength ||218+ metadata.chunkSize !== chunkSize ||219+ metadata.url !== url ||220+ metadata.type !== type ||221+ Date.now() - (metadata.lastAcceptedAt || 0) >= 60000222+ ) {116223 state.clear();117- state.meta = {hash, size: bytes.byteLength, chunkSize, url, type, lastAcceptedAt: 0};224+ state.meta = {225+ hash,226+ size: bytes.byteLength,227+ chunkSize,228+ url,229+ type,230+ lastAcceptedAt: 0,231+ };118232 }119233 let sent = 0;120234 let replayed = false;121235 while (true) {122- for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {123- const end = Math.min(offset + chunkSize, bytes.byteLength);124- const chunk = bytes.slice(offset, end);125- const length = chunk.byteLength;126- const prior = state.get(offset);127- if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) { sent = end; progress(sent); continue; }128- const result = await retry(() => request("PATCH", chunk, {129- "upload-type": type,130- "upload-length": String(bytes.byteLength),131- "upload-offset": String(offset),132- "content-type": "application/octet-stream"133- }, options.authorizationMode === "final-hash" ? bytes : chunk), offset, length);134- if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);135- state.set(offset, {offset, length, status: result.status});136- state.meta.lastAcceptedAt = Date.now();137- sent = end;138- notify(offset, length, result.status === 204 ? "uploaded" : "complete");139- progress(sent);140- if (result.status !== 204) return {hash, descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength), state};141- if (bytes.byteLength === 0) break;236+ for (237+ let offset = 0;238+ offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0);239+ offset += chunkSize240+ ) {241+ const end = Math.min(offset + chunkSize, bytes.byteLength);242+ const chunk = bytes.slice(offset, end);243+ const length = chunk.byteLength;244+ const prior = state.get(offset);245+ if (246+ !replayed &&247+ prior?.status === 204 &&248+ Date.now() - (state.meta.lastAcceptedAt || 0) < 60000249+ ) {250+ sent = end;251+ progress(sent);252+ continue;253+ }254+ const result = await retry(255+ () =>256+ request(257+ "PATCH",258+ chunk,259+ {260+ "upload-type": type,261+ "upload-length": String(bytes.byteLength),262+ "upload-offset": String(offset),263+ "content-type": "application/octet-stream",264+ },265+ options.authorizationMode === "final-hash" ? bytes : chunk,266+ ),267+ offset,268+ length,269+ );270+ if (![200, 201, 204].includes(result.status))271+ throw new Error("Blossom chunk upload failed: " + result.status);272+ state.set(offset, { offset, length, status: result.status });273+ state.meta.lastAcceptedAt = Date.now();274+ sent = end;275+ notify(offset, length, result.status === 204 ? "uploaded" : "complete");276+ progress(sent);277+ if (result.status !== 204)278+ return {279+ hash,280+ descriptor: verifyDescriptor(281+ await responseBody(result),282+ hash,283+ bytes.byteLength,284+ ),285+ state,286+ };287+ if (bytes.byteLength === 0) break;288+ }289+ if (!replayed) {290+ replayed = true;291+ continue;142292 }143- if (!replayed) { replayed = true; continue; }144293 throw new Error("multipart upload ended without completion");145294 }146295 };
@@ -149,12 +298,17 @@
149298 const controller = new AbortController();150299 if (options.signal) {151300 if (options.signal.aborted) controller.abort();152- else options.signal.addEventListener("abort", () => controller.abort(), {once: true});301+ else302+ options.signal.addEventListener("abort", () => controller.abort(), {303+ once: true,304+ });153305 }154306 const task = uploadTask(source, options, controller);155307 task.cancel = () => controller.abort();156308 return task;157309 };158310 159- globalThis.TinyBlossomUpload = Object.freeze({upload});311+ const api = Object.freeze({ upload });312+ globalThis.tiny = globalThis.tiny || {};313+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, upload: api };160314 })();
M internal/webui/bridge.js +4 -3
@@ -18,8 +18,8 @@
1818 if (status) status.textContent = text;1919 };2020 const errorText = err => err instanceof Error ? err.message : String(err);21- const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");22- const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));21+ const {hex, sha256} = window.tiny.util;22+ const sha256hex = async bytes => hex(await sha256(bytes));2323 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };2424 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");2525 const validRelay = value => {
@@ -102,7 +102,7 @@
102102 document.getElementById("menu")?.focus();103103 });104104 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});105- window.tiny = {root, localPath, sha256hex, authorization, signedFetch};105+ Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});106106 window.tinySignedFetch = signedFetch;107107 108108 const signIn = async () => {
@@ -127,6 +127,7 @@
127127 const setSigner = signer => {128128 window.tinySigner = signer;129129 window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};130+ document.dispatchEvent(new CustomEvent("tiny:signer"));130131 if (document.getElementById("signer-status")) {131132 say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");132133 }
M internal/webui/components.js +20 -14
@@ -244,7 +244,7 @@
244244 blossomURI() {245245 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));246246 const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin";247- const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? TinyBlossomEncryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();247+ const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? tiny.blossom.encryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();248248 return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host);249249 }250250 shareLink() {
@@ -280,8 +280,8 @@
280280 const plaintextHash = await tiny.sha256hex(plaintext);281281 let ciphertext, fragment;282282 if (scheme === "chk") {283- if (!globalThis.TinyBlossomEncryption) throw Error("CHK encryption is unavailable. Reload this page.");284- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);283+ if (!tiny.blossom.encryption) throw Error("CHK encryption is unavailable. Reload this page.");284+ const encrypted = await tiny.blossom.encryption.encryptCHK(plaintext);285285 ciphertext = encrypted.ciphertext;286286 fragment = "enc=chk-v1&key=" + encrypted.key;287287 } else {
@@ -313,8 +313,8 @@
313313 const actualHash = await tiny.sha256hex(ciphertext);314314 this.uploadController = new AbortController();315315 let descriptor;316- if (globalThis.TinyBlossomUpload?.upload) {317- const task = TinyBlossomUpload.upload(ciphertext, {316+ if (tiny.blossom.upload?.upload) {317+ const task = tiny.blossom.upload.upload(ciphertext, {318318 url: new URL(tiny.localPath("/"), location.href).href, hash: actualHash,319319 type: "application/octet-stream", state, signal: this.uploadController.signal,320320 authorize: (url, verb, body) => tiny.authorization(url, verb, body),
@@ -362,7 +362,7 @@
362362 const encrypted = new Uint8Array(await response.arrayBuffer());363363 if (await tiny.sha256hex(encrypted) !== this.hash()) throw Error("ciphertext hash mismatch");364364 let plain;365- if (params.get("enc") === "chk-v1") plain = await TinyBlossomEncryption.decryptCHK(encrypted, params.get("key"), this.hash());365+ if (params.get("enc") === "chk-v1") plain = await tiny.blossom.encryption.decryptCHK(encrypted, params.get("key"), this.hash());366366 else {367367 const key = await crypto.subtle.importKey("raw", fromB64url(params.get("key")), "AES-GCM", false, ["decrypt"]);368368 plain = await crypto.subtle.decrypt({name: "AES-GCM", iv: fromB64url(params.get("iv"))}, key, encrypted);
@@ -379,15 +379,15 @@
379379 if (!recipient) { this.say("Enter a public key or npub.", true); return; }380380 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));381381 const signer = globalThis.tinySigner || globalThis.nostr;382- if (!globalThis.TinyFileMessages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }382+ if (!tiny.files.messages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }383383 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }384384 this.sharing = true;385385 try {386386 const key = fromB64url(params.get("key")), nonce = fromB64url(params.get("iv"));387387 if (key.length !== 32 || nonce.length !== 12) throw Error("Invalid file key or nonce.");388- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");388+ const {hex} = tiny.util;389389 this.say("Preparing sealed NIP-17 delivery…");390- await TinyFileMessages.share({recipient, fileURL: new URL(tiny.localPath("/files/raw?hash=" + this.hash()), location.href).href, ciphertextHash: this.hash(), plaintextHash: params.get("ox") || undefined, key: hex(key), nonce: hex(nonce), mimeType: params.get("type") || this.type(), size: Number(this.getAttribute("size") || 0) || undefined}, {signer});390+ await tiny.files.messages.share({recipient, fileURL: new URL(tiny.localPath("/files/raw?hash=" + this.hash()), location.href).href, ciphertextHash: this.hash(), plaintextHash: params.get("ox") || undefined, key: hex(key), nonce: hex(nonce), mimeType: params.get("type") || this.type(), size: Number(this.getAttribute("size") || 0) || undefined}, {signer});391391 this.say("Encrypted message delivered; sender copy saved.");392392 } catch (error) { this.say("NIP-17 delivery failed: " + error.message, true); } finally { this.sharing = false; }393393 }
@@ -467,8 +467,11 @@
467467 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});468468 const result = await response.json();469469 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");470- this.report("Published. Reload to see the update.");470+ this.report("Published.");471471 form.reset();472+ // Refresh through the shared navigation so the thread shows the new473+ // event without a full reload.474+ await tiny.navigate?.(globalThis.location?.href);472475 }473476 }474477
@@ -708,11 +711,12 @@
708711 this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));709712 }710713 711- // whenSigned waits briefly for a signer, since a remote one resumes async.712- whenSigned(fn, tries = 20) {714+ // whenSigned runs once a signer is available; the bridge announces a715+ // connected or resumed remote signer with the tiny:signer event.716+ whenSigned(fn) {713717 if (window.nostr?.signEvent) return fn();714- if (tries <= 0) { this.say("connect a signer, then reload this page"); return; }715- setTimeout(() => this.whenSigned(fn, tries - 1), 500);718+ this.say("connect a signer to load this list");719+ document.addEventListener("tiny:signer", () => fn(), {once: true});716720 }717721 718722 say(text, error) {
@@ -929,6 +933,8 @@
929933 throw error;930934 } finally { if (serial === navigationSerial) setBusy(false); }931935 };936+ // navigate re-requests a page in place; components use it after a publish.937+ tiny.navigate = href => load(new URL(href, location.href), false);932938 document.addEventListener("fx:config", event => {933939 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");934940 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;
M internal/webui/file-messages.js +270 -80
@@ -7,7 +7,12 @@
77 88 function api() {99 const signer = root.NostrSigner;10- if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {10+ if (11+ !signer ||12+ !signer.nip44 ||13+ !signer.finalizeEvent ||14+ !signer.generateSecretKey15+ ) {1116 throw new Error("NIP-44 support is unavailable");1217 }1318 return signer;
@@ -16,7 +21,11 @@
1621 function relayURL(value) {1722 if (typeof value !== "string" || value.length > 2048) return null;1823 let url;19- try { url = new URL(value); } catch { return null; }24+ try {25+ url = new URL(value);26+ } catch {27+ return null;28+ }2029 if (url.protocol !== "wss:" && url.protocol !== "ws:") return null;2130 if (url.username || url.password || url.hash) return null;2231 return url.toString().replace(/\/$/, "");
@@ -24,15 +33,27 @@
2433 2534 function relayList(event, pubkey) {2635 if (Array.isArray(event)) {27- event = event.filter((candidate) => candidate?.kind === 10050 && candidate.pubkey === pubkey)36+ event = event37+ .filter(38+ (candidate) =>39+ candidate?.kind === 10050 && candidate.pubkey === pubkey,40+ )2841 .sort((a, b) => b.created_at - a.created_at)[0];2942 }3043 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {3144 throw new Error("recipient has no valid kind 10050 relay list");3245 }3346 const signer = api();34- if (!signer.verifyEvent(event)) throw new Error("invalid kind 10050 relay list signature");35- const relays = [...new Set(event.tags.filter((tag) => tag[0] === "relay").map((tag) => relayURL(tag[1])).filter(Boolean))];47+ if (!signer.verifyEvent(event))48+ throw new Error("invalid kind 10050 relay list signature");49+ const relays = [50+ ...new Set(51+ event.tags52+ .filter((tag) => tag[0] === "relay")53+ .map((tag) => relayURL(tag[1]))54+ .filter(Boolean),55+ ),56+ ];3657 if (!relays.length) throw new Error("kind 10050 relay list is empty");3758 return relays.slice(0, MAX_RELAYS);3859 }
@@ -41,7 +62,8 @@
4162 const text = String(value || "").trim();4263 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();4364 const decoded = api().decodeNpub?.(text);44- if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();65+ if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded))66+ return decoded.toLowerCase();4567 throw new Error("public key must be a 64 character hex key or npub");4668 }4769
@@ -49,10 +71,15 @@
4971 const ms = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;5072 let timer;5173 try {52- return await Promise.race([Promise.resolve(value), new Promise((_, reject) => {53- timer = setTimeout(() => reject(new Error(label + " timed out")), ms);54- })]);55- } finally { clearTimeout(timer); }74+ return await Promise.race([75+ Promise.resolve(value),76+ new Promise((_, reject) => {77+ timer = setTimeout(() => reject(new Error(label + " timed out")), ms);78+ }),79+ ]);80+ } finally {81+ clearTimeout(timer);82+ }5683 }5784 5885 function sent(result) {
@@ -63,70 +90,166 @@
6390 }6491 6592 function past(now) {66- return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));93+ return Math.max(94+ 0,95+ Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)),96+ );6797 }6898 6999 async function publicKey(signer, getPublicKey) {70- const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();71- if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw new Error("invalid signer public key");100+ const key = getPublicKey101+ ? await getPublicKey()102+ : await signer.getPublicKey();103+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key))104+ throw new Error("invalid signer public key");72105 return key.toLowerCase();73106 }74107 75108 async function queryRelayList(pubkey, options) {76- if (!root.tiny?.signedFetch) throw new Error("signed local query is unavailable");77- const response = await root.tiny.signedFetch("/query", "POST", JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]), { contentType: "application/json" });109+ if (!root.tiny?.signedFetch)110+ throw new Error("signed local query is unavailable");111+ const response = await root.tiny.signedFetch(112+ "/query",113+ "POST",114+ JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]),115+ { contentType: "application/json" },116+ );78117 return response.json();79118 }80119 81120 function publishEvent(event, relays, options) {82121 const signer = options.signer || root.tinySigner || root.nostr;83122 const WebSocketImpl = options.WebSocket || root.WebSocket;84- if (typeof WebSocketImpl !== "function") throw new Error("WebSocket support is unavailable");85- const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;86- return Promise.all(relays.map((url) => new Promise((resolve) => {87- let socket;88- let timer;89- let settled = false;90- let authID = "";91- let authAttempts = 0;92- let eventSent = false;93- const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timer); try { socket?.close(); } catch {} resolve(ok); };94- const sendEvent = () => { if (!settled && !eventSent) { eventSent = true; socket.send(JSON.stringify(["EVENT", event])); } };95- try {96- socket = new WebSocketImpl(url);97- timer = setTimeout(() => finish(false), timeout);98- socket.onopen = sendEvent;99- socket.onmessage = async (message) => {100- let frame;101- try { frame = JSON.parse(typeof message.data === "string" ? message.data : ""); } catch { return; }102- if (settled || !Array.isArray(frame)) return;103- if (frame[0] === "OK" && frame[1] === authID) {104- if (frame[2] === true) { eventSent = false; sendEvent(); } else finish(false);105- return;106- }107- if (frame[0] === "OK" && frame[1] === event.id) {108- if (frame[2] !== true && String(frame[3] || "").toLowerCase().startsWith("auth-required")) { eventSent = false; return; }109- finish(frame[2] === true); return;110- }111- if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;112- if (typeof frame[1] !== "string" || frame[1].length > 4096) { finish(false); return; }113- if (authAttempts++ >= 1) { finish(false); return; }114- try {115- const auth = await signer.signEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [["relay", url], ["challenge", frame[1]], ["method", "AUTH"]], content: "" });116- if (auth && !settled && auth.kind === 22242 && auth.content === "" && auth.tags.length === 3 && auth.tags[0][0] === "relay" && auth.tags[0][1] === url && auth.tags[1][0] === "challenge" && auth.tags[1][1] === frame[1] && auth.tags[2][0] === "method" && auth.tags[2][1] === "AUTH" && api().verifyEvent(auth) && auth.pubkey === await publicKey(signer)) { authID = auth.id; socket.send(JSON.stringify(["AUTH", auth])); }117- else finish(false);118- } catch { finish(false); }119- };120- socket.onerror = () => finish(false);121- socket.onclose = () => finish(false);122- } catch { finish(false); }123- })));123+ if (typeof WebSocketImpl !== "function")124+ throw new Error("WebSocket support is unavailable");125+ const timeout =126+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0127+ ? options.timeoutMs128+ : 10000;129+ return Promise.all(130+ relays.map(131+ (url) =>132+ new Promise((resolve) => {133+ let socket;134+ let timer;135+ let settled = false;136+ let authID = "";137+ let authAttempts = 0;138+ let eventSent = false;139+ const finish = (ok) => {140+ if (settled) return;141+ settled = true;142+ clearTimeout(timer);143+ try {144+ socket?.close();145+ } catch {}146+ resolve(ok);147+ };148+ const sendEvent = () => {149+ if (!settled && !eventSent) {150+ eventSent = true;151+ socket.send(JSON.stringify(["EVENT", event]));152+ }153+ };154+ try {155+ socket = new WebSocketImpl(url);156+ timer = setTimeout(() => finish(false), timeout);157+ socket.onopen = sendEvent;158+ socket.onmessage = async (message) => {159+ let frame;160+ try {161+ frame = JSON.parse(162+ typeof message.data === "string" ? message.data : "",163+ );164+ } catch {165+ return;166+ }167+ if (settled || !Array.isArray(frame)) return;168+ if (frame[0] === "OK" && frame[1] === authID) {169+ if (frame[2] === true) {170+ eventSent = false;171+ sendEvent();172+ } else finish(false);173+ return;174+ }175+ if (frame[0] === "OK" && frame[1] === event.id) {176+ if (177+ frame[2] !== true &&178+ String(frame[3] || "")179+ .toLowerCase()180+ .startsWith("auth-required")181+ ) {182+ eventSent = false;183+ return;184+ }185+ finish(frame[2] === true);186+ return;187+ }188+ if (189+ frame[0] !== "AUTH" ||190+ typeof signer?.signEvent !== "function"191+ )192+ return;193+ if (typeof frame[1] !== "string" || frame[1].length > 4096) {194+ finish(false);195+ return;196+ }197+ if (authAttempts++ >= 1) {198+ finish(false);199+ return;200+ }201+ try {202+ const auth = await signer.signEvent({203+ kind: 22242,204+ created_at: Math.floor(Date.now() / 1000),205+ tags: [206+ ["relay", url],207+ ["challenge", frame[1]],208+ ["method", "AUTH"],209+ ],210+ content: "",211+ });212+ if (213+ auth &&214+ !settled &&215+ auth.kind === 22242 &&216+ auth.content === "" &&217+ auth.tags.length === 3 &&218+ auth.tags[0][0] === "relay" &&219+ auth.tags[0][1] === url &&220+ auth.tags[1][0] === "challenge" &&221+ auth.tags[1][1] === frame[1] &&222+ auth.tags[2][0] === "method" &&223+ auth.tags[2][1] === "AUTH" &&224+ api().verifyEvent(auth) &&225+ auth.pubkey === (await publicKey(signer))226+ ) {227+ authID = auth.id;228+ socket.send(JSON.stringify(["AUTH", auth]));229+ } else finish(false);230+ } catch {231+ finish(false);232+ }233+ };234+ socket.onerror = () => finish(false);235+ socket.onclose = () => finish(false);236+ } catch {237+ finish(false);238+ }239+ }),240+ ),241+ );124242 }125243 126244 function unsignedRumor(input, sender, now) {127- if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw new Error("fileURL must be an HTTP(S) URL");128- if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw new Error("ciphertextHash must be SHA-256 hex");129- if (!input.key || !input.nonce) throw new Error("encrypted file messages require a decryption key and nonce");245+ if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL))246+ throw new Error("fileURL must be an HTTP(S) URL");247+ if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || ""))248+ throw new Error("ciphertextHash must be SHA-256 hex");249+ if (!input.key || !input.nonce)250+ throw new Error(251+ "encrypted file messages require a decryption key and nonce",252+ );130253 const tags = [["p", input.recipient]];131254 if (input.mimeType) tags.push(["file-type", input.mimeType]);132255 tags.push(["encryption-algorithm", "aes-gcm"]);
@@ -134,27 +257,51 @@
134257 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);135258 tags.push(["x", input.ciphertextHash.toLowerCase()]);136259 if (input.plaintextHash) {137- if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw new Error("plaintextHash must be SHA-256 hex");260+ if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash))261+ throw new Error("plaintextHash must be SHA-256 hex");138262 tags.push(["ox", input.plaintextHash.toLowerCase()]);139263 }140264 if (input.size != null) tags.push(["size", String(input.size)]);141- const rumor = { pubkey: sender, created_at: Math.floor(now), kind: 15, tags, content: input.fileURL };265+ const rumor = {266+ pubkey: sender,267+ created_at: Math.floor(now),268+ kind: 15,269+ tags,270+ content: input.fileURL,271+ };142272 rumor.id = api().getEventHash(rumor);143273 return rumor;144274 }145275 146276 async function encryptFor(signer, recipient, plaintext) {147- if (typeof signer.nip44Encrypt === "function") return signer.nip44Encrypt(recipient, plaintext);148- if (signer.nip44?.encrypt) return signer.nip44.encrypt(recipient, plaintext);277+ if (typeof signer.nip44Encrypt === "function")278+ return signer.nip44Encrypt(recipient, plaintext);279+ if (signer.nip44?.encrypt)280+ return signer.nip44.encrypt(recipient, plaintext);149281 throw new Error("signer has no NIP-44 encrypt method");150282 }151283 152284 async function seal(rumor, recipient, signer, now) {153285 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));154- const unsigned = { kind: 13, tags: [], content, created_at: past(now), pubkey: rumor.pubkey };286+ const unsigned = {287+ kind: 13,288+ tags: [],289+ content,290+ created_at: past(now),291+ pubkey: rumor.pubkey,292+ };155293 const event = await signer.signEvent(unsigned);156- if (!event || event.kind !== 13 || event.pubkey !== rumor.pubkey || event.tags.length || event.content !== unsigned.content || event.created_at !== unsigned.created_at) throw new Error("signer returned an invalid NIP-17 seal");157- if (!api().verifyEvent(event)) throw new Error("signer returned an invalid seal signature");294+ if (295+ !event ||296+ event.kind !== 13 ||297+ event.pubkey !== rumor.pubkey ||298+ event.tags.length ||299+ event.content !== unsigned.content ||300+ event.created_at !== unsigned.created_at301+ )302+ throw new Error("signer returned an invalid NIP-17 seal");303+ if (!api().verifyEvent(event))304+ throw new Error("signer returned an invalid seal signature");158305 return event;159306 }160307
@@ -164,46 +311,89 @@
164311 const pubkey = signer.getPublicKey(secret);165312 const key = signer.nip44.getConversationKey(secret, recipient);166313 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);167- const event = signer.finalizeEvent({ kind: 1059, tags: [["p", recipient]], content, created_at: past(now) }, secret);168- if (!signer.verifyEvent(event)) throw new Error("failed to verify gift wrap");314+ const event = signer.finalizeEvent(315+ { kind: 1059, tags: [["p", recipient]], content, created_at: past(now) },316+ secret,317+ );318+ if (!signer.verifyEvent(event))319+ throw new Error("failed to verify gift wrap");169320 return { event, secret, pubkey };170321 }171322 172323 async function build(input, options = {}) {173324 const signer = options.signer || root.tinySigner || root.nostr;174- if (!signer || typeof signer.signEvent !== "function" || (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)) throw new Error("a NIP-44 signer is required");325+ if (326+ !signer ||327+ typeof signer.signEvent !== "function" ||328+ (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)329+ )330+ throw new Error("a NIP-44 signer is required");175331 const recipient = normalizePubkey(input.recipient);176332 const sender = await publicKey(signer, options.getPublicKey);177- const now = (options.now || Date.now() / 1000);333+ const now = options.now || Date.now() / 1000;178334 const rumor = unsignedRumor({ ...input, recipient }, sender, now);179335 const receiverSeal = await seal(rumor, recipient, signer, now);180336 const senderSeal = await seal(rumor, sender, signer, now);181337 const receiver = wrap(receiverSeal, recipient, now).event;182338 const own = wrap(senderSeal, sender, now).event;183- return { rumor, events: [{ recipient, event: receiver }, { recipient: sender, event: own }] };339+ return {340+ rumor,341+ events: [342+ { recipient, event: receiver },343+ { recipient: sender, event: own },344+ ],345+ };184346 }185347 186348 async function share(input, options = {}) {187349 const signer = options.signer || root.tinySigner || root.nostr;188- const query = options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));189- const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));190- if (typeof query !== "function" || typeof publish !== "function") throw new Error("private NIP-17 delivery is unavailable");350+ const query =351+ options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));352+ const publish =353+ options.publish ||354+ ((event, relays) => publishEvent(event, relays, options));355+ if (typeof query !== "function" || typeof publish !== "function")356+ throw new Error("private NIP-17 delivery is unavailable");191357 const sender = await publicKey(signer, options.getPublicKey);192358 const recipient = normalizePubkey(input.recipient);193359 const timeout = options.timeoutMs;194- const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);195- const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);360+ const receiverList = relayList(361+ await bounded(query(recipient), timeout, "recipient relay lookup"),362+ recipient,363+ );364+ const senderList = relayList(365+ await bounded(query(sender), timeout, "sender relay lookup"),366+ sender,367+ );196368 const built = await build(input, options);197- if (built.rumor.pubkey !== sender) throw new Error("signer changed during file sharing; try again");369+ if (built.rumor.pubkey !== sender)370+ throw new Error("signer changed during file sharing; try again");198371 const deliveries = [];199372 for (const target of built.events) {200373 const relays = target.recipient === recipient ? receiverList : senderList;201- const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");202- if (!sent(delivery)) throw new Error("NIP-17 delivery failed on every inbox relay");374+ const delivery = await bounded(375+ publish(target.event, relays),376+ timeout,377+ "NIP-17 delivery",378+ );379+ if (!sent(delivery))380+ throw new Error("NIP-17 delivery failed on every inbox relay");203381 deliveries.push(delivery);204382 }205- return { ...built, relays: { recipient: receiverList, sender: senderList }, deliveries };383+ return {384+ ...built,385+ relays: { recipient: receiverList, sender: senderList },386+ deliveries,387+ };206388 }207389 208- root.TinyFileMessages = { build, share, relayList, queryRelayList, publishEvent };390+ const apiObject = Object.freeze({391+ build,392+ share,393+ relayList,394+ queryRelayList,395+ publishEvent,396+ });397+ root.tiny = root.tiny || {};398+ root.tiny.files = { ...root.tiny.files, messages: apiObject };209399 })(globalThis);
M internal/webui/file-workspace.js +266 -83
@@ -8,100 +8,184 @@
88 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;99 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;1010 const directoryType = "application/vnd.blossom.directory+msgpack";11- const codec = () => globalThis.TinyBlossomManifests;12- const hex = value => codec().hex(value);13- const fromHex = value => {14- if (!/^[0-9a-f]{64}$/.test(value || "")) throw Error("Invalid hash or encryption key.");15- return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));11+ const codec = () =>12+ globalThis.tiny?.blossom?.manifests;13+ const encryption = () =>14+ globalThis.tiny?.blossom?.encryption;15+ const upload = () =>16+ globalThis.tiny?.blossom?.upload;17+ const hex = (value) => globalThis.tiny.util.hex(value);18+ const fromHex = (value) => {19+ if (!/^[0-9a-f]{64}$/.test(value || ""))20+ throw Error("Invalid hash or encryption key.");21+ return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));1622 };1723 const element = (tag, text) => {1824 const node = document.createElement(tag);1925 if (text !== undefined) node.textContent = text;2026 return node;2127 };22- const checkAbort = signal => {23- if (signal?.aborted) throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});28+ const checkAbort = (signal) => {29+ if (signal?.aborted)30+ throw Object.assign(31+ Error("Upload canceled. Retry while this page stays open."),32+ { name: "AbortError" },33+ );2434 };25- const validName = name => typeof name === "string" && name.length > 0 && name.isWellFormed() && ![".", ".."].includes(name) && !/[\/\u0000]/.test(name) && new TextEncoder().encode(name).length <= 1024;26- const folderNode = () => ({directories: new Map(), files: new Map()});35+ const validName = (name) =>36+ typeof name === "string" &&37+ name.length > 0 &&38+ name.isWellFormed() &&39+ ![".", ".."].includes(name) &&40+ !/[\/\u0000]/.test(name) &&41+ new TextEncoder().encode(name).length <= 1024;42+ const folderNode = () => ({ directories: new Map(), files: new Map() });2743 2844 function selectionTree(files) {29- if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");45+ if (!files.length || files.length > MAX_FILES)46+ throw Error("Choose a folder with 1 to 10,000 files.");3047 const root = folderNode();3148 let total = 0;32- const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));33- const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);49+ const paths = files.map((file) =>50+ (file.webkitRelativePath || file.name).split("/"),51+ );52+ const commonRoot = paths.every(53+ (path) => path.length > 1 && path[0] === paths[0][0],54+ );3455 for (let index = 0; index < files.length; index++) {3556 const file = files[index];3657 const path = commonRoot ? paths[index].slice(1) : paths[index];37- if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");38- if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");58+ if (path.length > MAX_DEPTH || path.some((name) => !validName(name)))59+ throw Error("Invalid folder path.");60+ if (61+ !Number.isSafeInteger(file.size) ||62+ file.size < 0 ||63+ file.size > MAX_FILE_BYTES64+ )65+ throw Error("Files must be at most 256 MiB in the browser workspace.");3966 total += file.size;40- if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");67+ if (total > MAX_FOLDER_BYTES)68+ throw Error("The selected folder exceeds 1 GiB.");4169 let directory = root;4270 for (const name of path.slice(0, -1)) {43- if (directory.files.has(name)) throw Error("A file and folder use the same name.");44- if (!directory.directories.has(name)) directory.directories.set(name, folderNode());71+ if (directory.files.has(name))72+ throw Error("A file and folder use the same name.");73+ if (!directory.directories.has(name))74+ directory.directories.set(name, folderNode());4575 directory = directory.directories.get(name);4676 }4777 const name = path.at(-1);48- if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");78+ if (directory.files.has(name) || directory.directories.has(name))79+ throw Error("Duplicate folder entry.");4980 directory.files.set(name, file);5081 }51- return {root, name: commonRoot ? paths[0][0] : "folder"};82+ return { root, name: commonRoot ? paths[0][0] : "folder" };5283 }5384 5485 async function storeEncrypted(plaintext, type, signal) {5586 checkAbort(signal);56- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);87+ const encrypted = await encryption().encryptCHK(plaintext);5788 checkAbort(signal);5889 const url = new URL(tiny.localPath("/"), location.href).href;59- const result = await TinyBlossomUpload.upload(encrypted.ciphertext, {60- url, hash: encrypted.hash, type, signal,61- authorize: (target, method, body) => tiny.authorization(target, method, body)90+ const result = await upload().upload(encrypted.ciphertext, {91+ url,92+ hash: encrypted.hash,93+ type,94+ signal,95+ authorize: (target, method, body) =>96+ tiny.authorization(target, method, body),6297 });63- if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length) throw Error("Relay returned an unexpected blob descriptor.");64- return {hash: encrypted.hash, key: fromHex(encrypted.key)};98+ if (99+ result.descriptor.sha256 !== encrypted.hash ||100+ result.descriptor.size !== encrypted.ciphertext.length101+ )102+ throw Error("Relay returned an unexpected blob descriptor.");103+ return { hash: encrypted.hash, key: fromHex(encrypted.key) };65104 }66105 67- const manifestStore = signal => async ({bytes, node}) => storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);68- const rootReference = details => {106+ const manifestStore =107+ (signal) =>108+ async ({ bytes, node }) =>109+ storeEncrypted(110+ bytes,111+ node.t === 2 || node.t === 3112+ ? directoryType113+ : "application/octet-stream",114+ signal,115+ );116+ const rootReference = (details) => {69117 const stored = details.manifests.at(-1);70- return {hash: stored.hash, key: stored.key, type: details.root.t, size: details.root.l.reduce((sum, link) => sum + link.s, 0)};118+ return {119+ hash: stored.hash,120+ key: stored.key,121+ type: details.root.t,122+ size: details.root.l.reduce((sum, link) => sum + link.s, 0),123+ };71124 };72125 73126 async function storeFile(file, signal, progress) {74- if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");127+ if (file.size > MAX_FILE_BYTES)128+ throw Error("Files must be at most 256 MiB in the browser workspace.");75129 const chunks = [];76- for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {130+ for (131+ let offset = 0;132+ offset < file.size || offset === 0;133+ offset += codec().CHUNK_SIZE134+ ) {77135 checkAbort(signal);78- const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());79- const stored = await storeEncrypted(plain, "application/octet-stream", signal);80- chunks.push({...stored, size: plain.length});136+ const plain = new Uint8Array(137+ await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer(),138+ );139+ const stored = await storeEncrypted(140+ plain,141+ "application/octet-stream",142+ signal,143+ );144+ chunks.push({ ...stored, size: plain.length });81145 progress?.(plain.length);82146 if (file.size === 0) break;83147 }84- if (chunks.length === 1) return {...chunks[0], type: 0};85- return rootReference(await codec().buildFile(chunks, {store: manifestStore(signal), returnDetails: true}));148+ if (chunks.length === 1) return { ...chunks[0], type: 0 };149+ return rootReference(150+ await codec().buildFile(chunks, {151+ store: manifestStore(signal),152+ returnDetails: true,153+ }),154+ );86155 }87156 88157 async function storeDirectory(directory, signal, progress) {89158 const entries = [];90159 for (const [name, file] of directory.files) {91160 const stored = await storeFile(file, signal, progress);92- entries.push({...stored, name, metadata: {type: file.type || "application/octet-stream"}});161+ entries.push({162+ ...stored,163+ name,164+ metadata: { type: file.type || "application/octet-stream" },165+ });93166 }94167 for (const [name, child] of directory.directories) {95168 const stored = await storeDirectory(child, signal, progress);96- entries.push({...stored, name});169+ entries.push({ ...stored, name });97170 }98- return rootReference(await codec().buildDirectory(entries, {store: manifestStore(signal), returnDetails: true}));171+ return rootReference(172+ await codec().buildDirectory(entries, {173+ store: manifestStore(signal),174+ returnDetails: true,175+ }),176+ );99177 }100178 101179 const shareURL = (reference, name, type) => {102180 const url = new URL(tiny.localPath("/file"), location.href);103181 url.searchParams.set("hash", reference.hash);104- url.hash = new URLSearchParams({manifest: reference.hash, key: hex(reference.key), node: String(reference.type), name, type: type || "application/octet-stream"}).toString();182+ url.hash = new URLSearchParams({183+ manifest: reference.hash,184+ key: hex(reference.key),185+ node: String(reference.type),186+ name,187+ type: type || "application/octet-stream",188+ }).toString();105189 return url.href;106190 };107191
@@ -109,16 +193,24 @@
109193 connectedCallback() {110194 if (this.bound) return;111195 this.bound = true;112- this.innerHTML = '<h3>Folders and large files</h3><p>Encrypt folders and split large files into portable chunks. Deduplicated encryption reveals matching content and permits guesses about predictable files.</p><form data-folder><label>Folder <input type="file" name="folder" webkitdirectory directory multiple required></label><button>Encrypt and store folder</button></form><form data-chunked><label>File to split into chunks <input type="file" name="file" required></label><button>Encrypt and store file</button></form><p><button type="button" data-cancel disabled>Cancel upload</button> <button type="button" data-retry disabled>Retry upload</button></p><label>Share link <input data-tree-share readonly></label><p><a data-open hidden>Browse stored content</a></p><output role="status"></output>';196+ this.innerHTML =197+ '<h3>Folders and large files</h3><p>Encrypt folders and split large files into portable chunks. Deduplicated encryption reveals matching content and permits guesses about predictable files.</p><form data-folder><label>Folder <input type="file" name="folder" webkitdirectory directory multiple required></label><button>Encrypt and store folder</button></form><form data-chunked><label>File to split into chunks <input type="file" name="file" required></label><button>Encrypt and store file</button></form><p><button type="button" data-cancel disabled>Cancel upload</button> <button type="button" data-retry disabled>Retry upload</button></p><label>Share link <input data-tree-share readonly></label><p><a data-open hidden>Browse stored content</a></p><output role="status"></output>';113198 this.out = this.querySelector("output");114- for (const form of this.querySelectorAll("form")) form.addEventListener("submit", event => {115- event.preventDefault();116- this.run(form).catch(error => this.say(error.message, true));117- });118- this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());119- this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(error => this.say(error.message, true)));199+ for (const form of this.querySelectorAll("form"))200+ form.addEventListener("submit", (event) => {201+ event.preventDefault();202+ this.run(form).catch((error) => this.say(error.message, true));203+ });204+ this.querySelector("[data-cancel]").addEventListener("click", () =>205+ this.controller?.abort(),206+ );207+ this.querySelector("[data-retry]").addEventListener("click", () =>208+ this.run().catch((error) => this.say(error.message, true)),209+ );210+ }211+ disconnectedCallback() {212+ this.controller?.abort();120213 }121- disconnectedCallback() { this.controller?.abort(); }122214 say(message, error = false) {123215 this.out.textContent = message;124216 if (error) this.out.dataset.error = "";
@@ -129,9 +221,10 @@
129221 if (form) {130222 const folder = form.elements.namedItem("folder");131223 const input = folder || form.elements.namedItem("file");132- this.selection = {files: [...input.files], folder: Boolean(folder)};224+ this.selection = { files: [...input.files], folder: Boolean(folder) };133225 }134- if (!this.selection?.files.length) throw Error("Choose a file or folder first.");226+ if (!this.selection?.files.length)227+ throw Error("Choose a file or folder first.");135228 this.busy = true;136229 this.controller = new AbortController();137230 this.querySelector("[data-cancel]").disabled = false;
@@ -140,23 +233,38 @@
140233 try {141234 let reference, name, type;142235 let sent = 0;143- const progress = size => { sent += size; this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…"); };236+ const progress = (size) => {237+ sent += size;238+ this.say(239+ "Stored " +240+ sent.toLocaleString() +241+ " bytes. Encrypting the remaining content…",242+ );243+ };144244 this.say("Encrypting selected content…");145245 if (this.selection.folder) {146246 const selected = selectionTree(this.selection.files);147247 name = selected.name;148- reference = await storeDirectory(selected.root, this.controller.signal, progress);248+ reference = await storeDirectory(249+ selected.root,250+ this.controller.signal,251+ progress,252+ );149253 } else {150254 const file = this.selection.files[0];151- name = file.name; type = file.type;255+ name = file.name;256+ type = file.type;152257 reference = await storeFile(file, this.controller.signal, progress);153258 }154259 checkAbort(this.controller.signal);155260 const url = shareURL(reference, name, type);156261 this.querySelector("[data-tree-share]").value = url;157262 const open = this.querySelector("[data-open]");158- open.href = url; open.hidden = false;159- this.say("Stored. Keep the complete share link to decrypt and browse this content.");263+ open.href = url;264+ open.hidden = false;265+ this.say(266+ "Stored. Keep the complete share link to decrypt and browse this content.",267+ );160268 completed = true;161269 return url;162270 } finally {
@@ -170,79 +278,134 @@
170278 171279 async function fetchBlob(hash, signal) {172280 fromHex(hash);173- const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {signal});281+ const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {282+ signal,283+ });174284 if (!response.ok) throw Error("Download failed (" + response.status + ").");175- if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");285+ if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)286+ throw Error("Linked blob exceeds the browser fetch limit.");176287 const reader = response.body?.getReader();177288 if (!reader) {178289 const bytes = new Uint8Array(await response.arrayBuffer());179- if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");290+ if (bytes.length > MAX_FETCH_BYTES)291+ throw Error("Linked blob exceeds the browser fetch limit.");180292 return bytes;181293 }182294 const parts = [];183295 let size = 0;184296 try {185297 for (;;) {186- const {value, done} = await reader.read();298+ const { value, done } = await reader.read();187299 if (done) break;188300 size += value.length;189- if (size > MAX_FETCH_BYTES) { await reader.cancel(); throw Error("Linked blob exceeds the browser fetch limit."); }301+ if (size > MAX_FETCH_BYTES) {302+ await reader.cancel();303+ throw Error("Linked blob exceeds the browser fetch limit.");304+ }190305 parts.push(value);191306 }192- } finally { reader.releaseLock(); }307+ } finally {308+ reader.releaseLock();309+ }193310 const bytes = new Uint8Array(size);194311 let offset = 0;195- for (const part of parts) { bytes.set(part, offset); offset += part.length; }312+ for (const part of parts) {313+ bytes.set(part, offset);314+ offset += part.length;315+ }196316 return bytes;197317 }198318 199319 async function plaintext(reference, signal) {200320 const hash = hex(reference.h);201321 const raw = await fetchBlob(hash, signal);202- if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");203- return reference.k ? TinyBlossomEncryption.decryptCHK(raw, hex(reference.k), hash) : raw;322+ if (hex(await codec().sha256(raw)) !== hash)323+ throw Error("Blob hash mismatch.");324+ return reference.k325+ ? encryption().decryptCHK(raw, hex(reference.k), hash)326+ : raw;204327 }205- const decrypt = (raw, link) => TinyBlossomEncryption.decryptCHK(raw, hex(link.k), hex(link.h));328+ const decrypt = (raw, link) =>329+ encryption().decryptCHK(raw, hex(link.k), hex(link.h));206330 207331 class Root extends HTMLElement {208332 connectedCallback() {209333 if (this.bound) return;210334 this.bound = true;211335 const params = new URLSearchParams(location.hash.slice(1));212- if (!params.has("manifest") && this.getAttribute("type") !== directoryType) { this.hidden = true; return; }336+ if (337+ !params.has("manifest") &&338+ this.getAttribute("type") !== directoryType339+ ) {340+ this.hidden = true;341+ return;342+ }213343 this.hidden = false;214- this.load().catch(error => this.fail(error.message));344+ this.load().catch((error) => this.fail(error.message));215345 }216346 disconnectedCallback() {217347 this.controller?.abort();218348 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);219349 }350+ // fail reports an error without discarding a listing that has already351+ // rendered, so one failed download leaves the folder browsable.220352 fail(message) {221353 const error = element("p", "Content unavailable: " + message);222354 error.setAttribute("role", "alert");223- this.replaceChildren(error);355+ this.querySelector("p[role=alert]")?.remove();356+ if (this.querySelector("h2")) this.querySelector("h2").after(error);357+ else this.replaceChildren(error);224358 }225359 async load() {226360 this.controller?.abort();227361 this.controller = new AbortController();228362 const params = new URLSearchParams(location.hash.slice(1));229- const reference = {h: fromHex(params.get("manifest") || this.getAttribute("hash"))};363+ const reference = {364+ h: fromHex(params.get("manifest") || this.getAttribute("hash")),365+ };230366 if (params.has("key")) reference.k = fromHex(params.get("key"));231367 const type = params.has("node") ? Number(params.get("node")) : 2;232- if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");368+ if (![0, 1, 2, 3].includes(type))369+ throw Error("Unsupported manifest type.");233370 const data = await plaintext(reference, this.controller.signal);234371 const heading = element("h2", params.get("name") || "Stored content");235372 this.replaceChildren(heading);236- if (type === 0) { this.offerDownload(data, params.get("name") || "file", params.get("type")); return; }373+ if (type === 0) {374+ this.offerDownload(375+ data,376+ params.get("name") || "file",377+ params.get("type"),378+ );379+ return;380+ }237381 const root = codec().decodeManifest(data);238- if (root.t !== type) throw Error("Manifest type does not match its reference.");382+ if (root.t !== type)383+ throw Error("Manifest type does not match its reference.");239384 if (type === 1) {240385 const button = element("button", "Download decrypted file");241386 button.type = "button";242- button.addEventListener("click", () => this.download({...reference, t: 1, s: root.l.reduce((sum, link) => sum + link.s, 0), n: params.get("name") || "file", m: {type: params.get("type")}}).catch(error => this.fail(error.message)));243- this.append(button); return;387+ button.addEventListener("click", () =>388+ this.download({389+ ...reference,390+ t: 1,391+ s: root.l.reduce((sum, link) => sum + link.s, 0),392+ n: params.get("name") || "file",393+ m: { type: params.get("type") },394+ }).catch((error) => this.fail(error.message)),395+ );396+ this.append(button);397+ return;244398 }245- const entries = await codec().resolveDirectory(root, (hash) => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxEntries: MAX_FILES, maxBytes: 64 * 1024 * 1024});399+ const entries = await codec().resolveDirectory(400+ root,401+ (hash) => fetchBlob(hash, this.controller.signal),402+ {403+ decrypt,404+ maxDepth: MAX_DEPTH,405+ maxEntries: MAX_FILES,406+ maxBytes: 64 * 1024 * 1024,407+ },408+ );246409 const list = element("ul");247410 if (!entries.length) this.append(element("p", "This folder is empty."));248411 for (const link of entries) {
@@ -251,14 +414,24 @@
251414 const open = element("a", link.n + "/");252415 const url = new URL(tiny.localPath("/file"), location.href);253416 url.searchParams.set("hash", hex(link.h));254- const fragment = new URLSearchParams({manifest: hex(link.h), node: String(link.t), name: link.n});417+ const fragment = new URLSearchParams({418+ manifest: hex(link.h),419+ node: String(link.t),420+ name: link.n,421+ });255422 if (link.k) fragment.set("key", hex(link.k));256- url.hash = fragment.toString(); open.href = url.href;423+ url.hash = fragment.toString();424+ open.href = url.href;257425 item.append(open);258426 } else {259- const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");427+ const button = element(428+ "button",429+ link.n + " (" + link.s.toLocaleString() + " bytes)",430+ );260431 button.type = "button";261- button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));432+ button.addEventListener("click", () =>433+ this.download(link).catch((error) => this.fail(error.message)),434+ );262435 item.append(button);263436 }264437 list.append(item);
@@ -270,18 +443,28 @@
270443 let data;271444 if (link.t === 0) data = await plaintext(link, this.controller.signal);272445 else if (link.t === 1) {273- const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));274- data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES});446+ const manifest = codec().decodeManifest(447+ await plaintext(link, this.controller.signal),448+ );449+ data = await codec().readFile(450+ manifest,451+ (hash) => fetchBlob(hash, this.controller.signal),452+ { decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES },453+ );275454 } else throw Error("Unsupported file link.");276- if (data.length !== link.s) throw Error("File size does not match its directory entry.");455+ if (data.length !== link.s)456+ throw Error("File size does not match its directory entry.");277457 this.offerDownload(data, link.n, link.m?.type);278458 return data;279459 }280460 offerDownload(data, name, type) {281461 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);282- this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));462+ this.downloadURL = URL.createObjectURL(463+ new Blob([data], { type: type || "application/octet-stream" }),464+ );283465 const link = element("a", "Save " + name);284- link.href = this.downloadURL; link.download = name;466+ link.href = this.downloadURL;467+ link.download = name;285468 this.append(link);286469 link.click();287470 }
M internal/webui/page.html +1 -0
M internal/webui/private-services.js +143 -42
@@ -3,21 +3,32 @@
33 (() => {44 "use strict";55 6- const validURL = value => {6+ const validURL = (value) => {77 try {88 const parsed = new URL(String(value).trim());9- return (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&10- parsed.hostname !== "" && !parsed.username && !parsed.password &&11- parsed.search === "" && parsed.hash === "";12- } catch { return false; }9+ return (10+ (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&11+ parsed.hostname !== "" &&12+ !parsed.username &&13+ !parsed.password &&14+ parsed.search === "" &&15+ parsed.hash === ""16+ );17+ } catch {18+ return false;19+ }1320 };14- const relay = value => {21+ const relay = (value) => {1522 const parsed = new URL(String(value || "").trim());1623 parsed.hostname = parsed.hostname.toLowerCase();17- if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/, "");24+ if (parsed.pathname.length > 1)25+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");1826 return parsed.toString().replace(/\/$/, "");1927 };20- const mergeRows = (rows, urls) => rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));28+ const mergeRows = (rows, urls) =>29+ rows30+ .filter((row) => !(Array.isArray(row) && row[0] === "g"))31+ .concat(urls.map((value) => ["g", value]));2132 const signer = () => window.tinySigner || window.nostr;2233 const publicKey = async () => {2334 const active = signer();
@@ -29,39 +40,76 @@
2940 const active = signer();3041 if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);3142 if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);32- if (window.tiny?.nip44Encrypt) return window.tiny.nip44Encrypt(pubkey, text);43+ if (window.tiny?.nip44Encrypt)44+ return window.tiny.nip44Encrypt(pubkey, text);3345 throw Error("The connected signer does not support NIP-44.");3446 };3547 const decrypt = async (pubkey, text) => {3648 const active = signer();3749 if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);3850 if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);39- if (window.tiny?.nip44Decrypt) return window.tiny.nip44Decrypt(pubkey, text);51+ if (window.tiny?.nip44Decrypt)52+ return window.tiny.nip44Decrypt(pubkey, text);4053 throw Error("The connected signer does not support NIP-44.");4154 };42- const readList = async pubkey => {43- const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);44- const response = await window.tiny.signedFetch("/query", "POST", body, {contentType: "application/json"});45- if (!response || response.ok === false) throw Error("Could not read the private relay list.");55+ const readList = async (pubkey) => {56+ const body = JSON.stringify([57+ { kinds: [10318], authors: [pubkey], limit: 1 },58+ ]);59+ const response = await window.tiny.signedFetch("/query", "POST", body, {60+ contentType: "application/json",61+ });62+ if (!response || response.ok === false)63+ throw Error("Could not read the private relay list.");4664 const rows = await response.json();47- if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");65+ if (!Array.isArray(rows) || rows.length > 1)66+ throw Error("The private relay list response is malformed.");4867 const event = rows[0];49- if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");68+ if (rows.length && !acceptEvent(event, pubkey))69+ throw Error("The private relay list event is invalid.");5070 return event;5171 };5272 const acceptEvent = (event, expectedPubkey) => {53- if (!event || event.kind !== 10318 || event.pubkey !== expectedPubkey || !Array.isArray(event.tags) || typeof event.content !== "string") return false;73+ if (74+ !event ||75+ event.kind !== 10318 ||76+ event.pubkey !== expectedPubkey ||77+ !Array.isArray(event.tags) ||78+ typeof event.content !== "string"79+ )80+ return false;5481 if (typeof window.NostrSigner?.verifyEvent !== "function") return false;55- try { return window.NostrSigner.verifyEvent(event) === true; } catch { return false; }82+ try {83+ return window.NostrSigner.verifyEvent(event) === true;84+ } catch {85+ return false;86+ }5687 };57- const publish = async event => {58- const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});88+ const publish = async (event) => {89+ const response = await window.tiny.signedFetch(90+ "/events",91+ "POST",92+ JSON.stringify(event),93+ { contentType: "application/json" },94+ );5995 const text = await response.text();6096 let value;61- try { value = JSON.parse(text); } catch { value = undefined; }62- if (value?.error || value?.accepted === false) throw Error(value.error || value.message || "The relay rejected the event.");97+ try {98+ value = JSON.parse(text);99+ } catch {100+ value = undefined;101+ }102+ if (value?.error || value?.accepted === false)103+ throw Error(104+ value.error || value.message || "The relay rejected the event.",105+ );63106 };64- const wireEvent = content => ({kind: 10318, created_at: Math.floor(Date.now() / 1000), tags: [], content});107+ const wireEvent = (content) => ({108+ kind: 10318,109+ created_at: Math.floor(Date.now() / 1000),110+ tags: [],111+ content,112+ });65113 66114 class PrivateServices extends HTMLElement {67115 connectedCallback() {
@@ -70,23 +118,33 @@
70118 this.form = this.querySelector("form");71119 this.lines = this.querySelector("textarea[name=lines]");72120 this.output = this.querySelector("output");73- this.form?.addEventListener("submit", event => {121+ this.form?.addEventListener("submit", (event) => {74122 event.preventDefault();75123 if (this.busy) return;76124 this.busy = true;77125 this.form.querySelector("button").disabled = true;78- this.save().catch(error => this.say("Error: " + error.message, true)).finally(() => {79- this.busy = false;80- this.form.querySelector("button").disabled = false;81- });126+ this.save()127+ .catch((error) => this.say("Error: " + error.message, true))128+ .finally(() => {129+ this.busy = false;130+ this.form.querySelector("button").disabled = false;131+ });82132 });83- this.whenSigned(() => this.load().then(() => { this.form.querySelector("button").disabled = false; }).catch(error => this.say("Error: " + error.message, true)));133+ this.whenSigned(() =>134+ this.load()135+ .then(() => {136+ this.form.querySelector("button").disabled = false;137+ })138+ .catch((error) => this.say("Error: " + error.message, true)),139+ );84140 }85141 86- whenSigned(fn, tries = 20) {142+ // whenSigned runs once a signer is available. The bridge announces a143+ // connected or resumed remote signer with the tiny:signer event.144+ whenSigned(fn) {87145 if (signer()?.signEvent || window.nostr?.signEvent) return fn();88- if (tries <= 0) { this.say("Connect a signer, then reload this page."); return; }89- setTimeout(() => this.whenSigned(fn, tries - 1), 500);146+ this.say("Connect a signer to edit this list.");147+ document.addEventListener("tiny:signer", () => fn(), { once: true });90148 }91149 92150 say(text, error) {
@@ -99,17 +157,37 @@
99157 this.say("Reading encrypted list…");100158 const pubkey = await publicKey();101159 const event = await readList(pubkey);102- if (!event) { this.say("No private relay list published yet."); return; }160+ if (!event) {161+ this.say("No private relay list published yet.");162+ return;163+ }103164 const rows = JSON.parse(await decrypt(pubkey, event.content));104- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");105- const urls = rows.filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])).map(row => relay(row[1]));165+ if (!Array.isArray(rows))166+ throw Error("The encrypted list is not a JSON array.");167+ const urls = rows168+ .filter(169+ (row) => Array.isArray(row) && row[0] === "g" && validURL(row[1]),170+ )171+ .map((row) => relay(row[1]));106172 this.lines.value = urls.join("\n");107- this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");173+ this.say(174+ "Loaded " +175+ urls.length +176+ " private relay" +177+ (urls.length === 1 ? "" : "s") +178+ ".",179+ );108180 }109181 110182 async save() {111- const rawURLs = this.lines.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean);112- if (rawURLs.some(value => !validURL(value))) throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");183+ const rawURLs = this.lines.value184+ .split(/\r?\n/)185+ .map((value) => value.trim())186+ .filter(Boolean);187+ if (rawURLs.some((value) => !validURL(value)))188+ throw Error(189+ "Use ws:// or wss:// URLs without credentials, queries or fragments.",190+ );113191 const urls = rawURLs.map(relay);114192 const unique = [...new Set(urls)];115193 const pubkey = await publicKey();
@@ -117,19 +195,42 @@
117195 let rows = [];118196 if (existing) {119197 rows = JSON.parse(await decrypt(pubkey, existing.content));120- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");198+ if (!Array.isArray(rows))199+ throw Error("The encrypted list is not a JSON array.");121200 }122201 rows = mergeRows(rows, unique);123202 const content = await encrypt(pubkey, JSON.stringify(rows));124203 const active = signer();125204 if (!active?.signEvent) throw Error("Connect a signer first.");126205 const event = await active.signEvent(wireEvent(content));127- if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) throw Error("The signer returned an invalid private relay list event.");206+ if (207+ !acceptEvent(event, pubkey) ||208+ event.content !== content ||209+ event.tags.length !== 0210+ )211+ throw Error("The signer returned an invalid private relay list event.");128212 await publish(event);129- this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");213+ this.say(214+ "Published " +215+ unique.length +216+ " private relay" +217+ (unique.length === 1 ? "" : "s") +218+ ".",219+ );130220 }131221 }132222 133- window.PrivateServices = {validURL, canonicalURL: relay, mergeRows, encrypt, decrypt, wireEvent, acceptEvent, readList};223+ const api = Object.freeze({224+ validURL,225+ canonicalURL: relay,226+ mergeRows,227+ encrypt,228+ decrypt,229+ wireEvent,230+ acceptEvent,231+ readList,232+ });233+ window.tiny = window.tiny || {};234+ window.tiny.files = { ...window.tiny.files, privateServices: api };134235 customElements.define("private-services", PrivateServices);135236 })();
M internal/webui/templates.go +4 -1
A internal/webui/tiny.js +18 -0
M package.json +1 -1
M scripts/blossom-encryption.test.mjs +3 -2
M scripts/blossom-manifests-review.test.mjs +3 -2
M scripts/blossom-manifests.test.mjs +3 -2
M scripts/blossom-upload.test.mjs +3 -2
M scripts/file-messages.test.mjs +10 -8
@@ -5,7 +5,9 @@ import vm from "node:vm";
55 import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";66 77 globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data };8+globalThis.tiny = {files: {}};89 vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), { filename: "file-messages.js" });10+const fileMessages = globalThis.tiny.files.messages;911 1012 const alice = generateSecretKey();1113 const bob = generateSecretKey();
@@ -19,7 +21,7 @@ const signer = {
1921 const list = (pubkey, relays = ["wss://inbox.example"]) => finalizeEvent({ kind: 10050, tags: relays.map((relay) => ["relay", relay]), content: "", created_at: 100 }, pubkey === alicePub ? alice : bob);2022 2123 test("build creates independently decryptable NIP-17 file gift wraps", async () => {22- const result = await TinyFileMessages.build({ fileURL: "https://files.example/abc", ciphertextHash: "a".repeat(64), plaintextHash: "b".repeat(64), mimeType: "image/png", key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, now: 200 });24+ const result = await fileMessages.build({ fileURL: "https://files.example/abc", ciphertextHash: "a".repeat(64), plaintextHash: "b".repeat(64), mimeType: "image/png", key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, now: 200 });2325 assert.equal(result.rumor.pubkey, alicePub);2426 assert.equal(result.rumor.id, getEventHash(result.rumor));2527 assert.equal(result.rumor.kind, 15);
@@ -43,7 +45,7 @@ test("build creates independently decryptable NIP-17 file gift wraps", async ()
4345 4446 test("share requires valid inbox lists and publishes only to those lists", async () => {4547 const published = [];46- const result = await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {48+ const result = await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {4749 signer,4850 queryRelayList: async (pubkey) => list(pubkey, pubkey === bobPub ? ["wss://bob.example"] : ["ws://127.0.0.1:7777"]),4951 publish: async (event, relays) => { published.push({ event, relays }); return true; },
@@ -57,7 +59,7 @@ test("share requires valid inbox lists and publishes only to those lists", async
5759 5860 test("share refuses to send when either inbox list is absent", async () => {5961 let sends = 0;60- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {62+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {6163 signer,6264 queryRelayList: async () => null,6365 publish: async () => { sends++; },
@@ -67,18 +69,18 @@ test("share refuses to send when either inbox list is absent", async () => {
6769 6870 test("share accepts npub recipients and rejects signer identity mismatches", async () => {6971 const published = [];70- await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {72+ await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {7173 signer,7274 queryRelayList: async (pubkey) => list(pubkey),7375 publish: async (event) => { published.push(event); return true; },7476 });7577 assert.equal(published.length, 2);7678 const mismatched = { ...signer, async signEvent(event) { return finalizeEvent(event, bob); } };77- await assert.rejects(() => TinyFileMessages.build({ fileURL: "https://files.example/a", ciphertextHash: "f".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer: mismatched }), /invalid NIP-17 seal/);79+ await assert.rejects(() => fileMessages.build({ fileURL: "https://files.example/a", ciphertextHash: "f".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer: mismatched }), /invalid NIP-17 seal/);7880 });7981 8082 test("share rejects an all-failed publish result", async () => {81- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {83+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {8284 signer,8385 queryRelayList: async (pubkey) => list(pubkey),8486 publish: async () => [false, false],
@@ -100,7 +102,7 @@ test("default transport queries locally and publishes only inbox WebSockets", as
100102 send(data) { const event = JSON.parse(data)[1]; setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK", event.id, true, ""]) }), 0); }101103 close() { this.closed = true; }102104 };103- const result = await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "2".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, timeoutMs: 1000 });105+ const result = await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "2".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, { signer, timeoutMs: 1000 });104106 assert.equal(queries.length, 2);105107 assert.deepEqual(sockets.map((socket) => socket.url), ["wss://bob.example", "wss://alice.example"]);106108 assert.deepEqual(result.deliveries, [[true], [true]]);
@@ -125,7 +127,7 @@ test("default transport answers one NIP-42 challenge and retries the event", asy
125127 }126128 close() {}127129 };128- const result = await TinyFileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });130+ const result = await fileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });129131 assert.deepEqual(result, [true]);130132 assert.equal(eventAttempts, 2);131133 delete globalThis.WebSocket;
M scripts/file-workspace.test.mjs +5 -4
@@ -50,13 +50,14 @@ const parse = (html, parent) => {
5050 const load = (workspace = false) => {5151 const document = new Node("document"); document.documentElement = new Node("html"); document.body = new Node("body"); document.createElement = name => name === "form" ? new Form() : new Node(name); document.addEventListener = () => {}; document.dispatchEvent = () => {};5252 const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};53- const window = {document, customElements, tiny: {}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};53+ const window = {document, customElements, tiny: {blossom: {}, files: {}}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};5454 class HTMLElement extends Node {}5555 Object.assign(globalThis, {window, document, customElements, HTMLElement, location: window.location, Blob, URL, CustomEvent: class {}});5656 globalThis.tiny = window.tiny;5757 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable"); }}}});5858 globalThis.btoa = value => Buffer.from(value, "binary").toString("base64"); globalThis.atob = value => Buffer.from(value, "base64").toString("binary");59- if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.TinyBlossomUpload; }59+ vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));60+ if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }6061 vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));6162 return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"]};6263 };
@@ -187,7 +188,7 @@ test("sharing a newly uploaded file uses its current key, hash and ciphertext si
187188 shareForm.querySelector("input").value = "b".repeat(64);188189 globalThis.nostr = {signEvent: async value => value};189190 let input;190- globalThis.TinyFileMessages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};191+ globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};191192 await element.share(shareForm);192193 assert.ok(input, element.output.textContent);193194 assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded));
@@ -241,7 +242,7 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up
241242 window.tiny.localPath = path => path;242243 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");243244 const sent = [];244- globalThis.TinyBlossomUpload = {upload: async (bytes, options) => {245+ globalThis.tiny.blossom.upload = {upload: async (bytes, options) => {245246 assert.equal(options.url, "https://relay.test/");246247 sent.push(bytes.slice());247248 if (sent.length === 1) throw Error("connection interrupted");
M scripts/private-services.test.mjs +2 -1
M scripts/signer.test.mjs +3 -1
A scripts/tiny-namespace.test.mjs +25 -0