tinyrelaycommit

Commit efc0c4439bc2

browse files at this commit | history

1diff --git a/internal/webui/blossom-encryption.js b/internal/webui/blossom-encryption.js
2index 0524813..8ca65cb 100644
3--- a/internal/webui/blossom-encryption.js
4+++ b/internal/webui/blossom-encryption.js
5@@ -1,5 +1,5 @@
6 // BUD-15 draft (June 2026), BUD-15: Client-side CHK encrypted blobs.
7-// https://raw.githubusercontent.com/mmalmi/blossom/codex/bud-15-chk-encryption/buds/15.md
8+// https://github.com/hzrd149/blossom/pull/104
9 // Keys are client-only values. Callers must not include k in requests to a server.
10 (() => {
11 "use strict";
12@@ -9,65 +9,102 @@
13 const salt = encoder.encode("hashtree-chk");
14 const info = encoder.encode("encryption-key");
15
16- const hex = bytes => Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
17- const bytes = value => {
18- if (value instanceof Uint8Array) return value;
19- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
20- throw new TypeError("expected Uint8Array");
21- };
22- const fromHex = value => {
23- if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw new Error("expected a 64-character lowercase hex key");
24+ const {bytes, hex, sha256: digest} = globalThis.tiny.util;
25+ const fromHex = (value) => {
26+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
27+ throw new Error("expected a 64-character lowercase hex key");
28 const result = new Uint8Array(32);
29- for (let index = 0; index < result.length; index++) result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);
30+ for (let index = 0; index < result.length; index++)
31+ result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);
32 return result;
33 };
34- const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
35- const digest = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));
36- const aesKey = async key => {
37- const material = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);
38- return crypto.subtle.deriveKey({name: "HKDF", hash: "SHA-256", salt, info}, material,
39- {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"]);
40+ const equal = (left, right) =>
41+ left.length === right.length &&
42+ left.every((value, index) => value === right[index]);
43+ const aesKey = async (key) => {
44+ const material = await crypto.subtle.importKey("raw", key, "HKDF", false, [
45+ "deriveKey",
46+ ]);
47+ return crypto.subtle.deriveKey(
48+ { name: "HKDF", hash: "SHA-256", salt, info },
49+ material,
50+ { name: "AES-GCM", length: 256 },
51+ false,
52+ ["encrypt", "decrypt"],
53+ );
54 };
55
56- const encryptCHK = async plaintext => {
57+ const encryptCHK = async (plaintext) => {
58 const input = bytes(plaintext);
59 const key = await digest(input);
60- const encrypted = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(key), input));
61- return {ciphertext: encrypted, key: hex(key), hash: hex(await digest(encrypted))};
62+ const encrypted = new Uint8Array(
63+ await crypto.subtle.encrypt(
64+ { name: "AES-GCM", iv: zeroNonce },
65+ await aesKey(key),
66+ input,
67+ ),
68+ );
69+ return {
70+ ciphertext: encrypted,
71+ key: hex(key),
72+ hash: hex(await digest(encrypted)),
73+ };
74 };
75
76 const decryptCHK = async (ciphertext, key, hash) => {
77 const input = bytes(ciphertext);
78 const chkKey = fromHex(key);
79- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");
80- if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch");
81+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
82+ throw new Error("expected a 64-character lowercase blob hash");
83+ if (hex(await digest(input)) !== hash)
84+ throw new Error("ciphertext hash mismatch");
85 let plain;
86 try {
87- plain = new Uint8Array(await crypto.subtle.decrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(chkKey), input));
88+ plain = new Uint8Array(
89+ await crypto.subtle.decrypt(
90+ { name: "AES-GCM", iv: zeroNonce },
91+ await aesKey(chkKey),
92+ input,
93+ ),
94+ );
95 } catch {
96 throw new Error("CHK decryption failed");
97 }
98- if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch");
99+ if (!equal(await digest(plain), chkKey))
100+ throw new Error("plaintext hash mismatch");
101 return plain;
102 };
103
104 const createURI = (hash, extension, key) => {
105- if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a 64-character lowercase blob hash");
106- const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "") : "");
107+ if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
108+ throw new Error("expected a 64-character lowercase blob hash");
109+ const path =
110+ "blossom:" +
111+ hash +
112+ (extension ? "." + String(extension).replace(/^\.+/, "") : "");
113 fromHex(key);
114- const params = new URLSearchParams({enc: "chk-v1", k: key});
115+ const params = new URLSearchParams({ enc: "chk-v1", k: key });
116 return path + "?" + params;
117 };
118
119- const parseURI = value => {
120- if (typeof value !== "string" || !value.startsWith("blossom:")) throw new Error("expected a Blossom URI");
121- const parsed = new URL(value.slice("blossom:".length), "https://blossom.invalid");
122- const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/);
123- if (!match || parsed.searchParams.get("enc") !== "chk-v1") throw new Error("invalid CHK Blossom URI");
124+ const parseURI = (value) => {
125+ if (typeof value !== "string" || !value.startsWith("blossom:"))
126+ throw new Error("expected a Blossom URI");
127+ const parsed = new URL(
128+ value.slice("blossom:".length),
129+ "https://blossom.invalid",
130+ );
131+ const match = parsed.pathname.match(
132+ /^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/,
133+ );
134+ if (!match || parsed.searchParams.get("enc") !== "chk-v1")
135+ throw new Error("invalid CHK Blossom URI");
136 const key = parsed.searchParams.get("k");
137 fromHex(key);
138- return {hash: match[1], extension: match[2] || "", key};
139+ return { hash: match[1], extension: match[2] || "", key };
140 };
141
142- globalThis.TinyBlossomEncryption = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});
143+ const api = Object.freeze({ encryptCHK, decryptCHK, createURI, parseURI });
144+ globalThis.tiny = globalThis.tiny || {};
145+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, encryption: api };
146 })();
147diff --git a/internal/webui/blossom-manifests.js b/internal/webui/blossom-manifests.js
148index 3390f32..3391d51 100644
149--- a/internal/webui/blossom-manifests.js
150+++ b/internal/webui/blossom-manifests.js
151@@ -1,466 +1,644 @@
152+// BUD-16 and BUD-17 MessagePack manifests. This is maintained browser source,
153+// embedded directly by the Go templates; it is intentionally not bundled.
154 (() => {
155- (() => {
156- "use strict";
157- const CHUNK_SIZE = 2 * 1024 * 1024;
158- const MAX_LINKS = 174;
159- const MAX_DEPTH = 64;
160- const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
161- const te = new TextEncoder(), td = new TextDecoder("utf-8", { fatal: true });
162- const asBytes = (v) => v instanceof Uint8Array ? v : ArrayBuffer.isView(v) ? new Uint8Array(v.buffer, v.byteOffset, v.byteLength) : (() => {
163- throw new TypeError("expected bytes");
164- })();
165- const hashBytes = (h) => {
166- if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h)) throw new Error("expected lowercase SHA-256 hash");
167- return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));
168- };
169- const keyBytes = (k) => {
170- if (k === void 0) return;
171- const b = asBytes(k);
172- if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");
173- return b;
174- };
175- const wellFormed = (value) => {
176- for (let i = 0; i < value.length; i++) {
177- const c = value.charCodeAt(i);
178- if (c >= 55296 && c <= 56319) {
179- if (++i >= value.length || value.charCodeAt(i) < 56320 || value.charCodeAt(i) > 57343) return false;
180- } else if (c >= 56320 && c <= 57343) return false;
181- }
182- return true;
183- };
184- const nameOK = (n) => typeof n === "string" && n.length > 0 && n !== "." && n !== ".." && wellFormed(n) && !n.includes("/") && !n.includes("\0") && te.encode(n).length <= 1024;
185- const int = (n) => Number.isSafeInteger(n) && n >= 0;
186- const utf8cmp = (a, b) => {
187- const x = te.encode(a), y = te.encode(b);
188- for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];
189- return x.length - y.length;
190- };
191- class Writer {
192- constructor() {
193- this.a = [];
194- }
195- push(...x) {
196- this.a.push(...x);
197- }
198- u8(n) {
199- this.a.push(n & 255);
200- }
201- bytes() {
202- return Uint8Array.from(this.a);
203- }
204+ "use strict";
205+ const CHUNK_SIZE = 2 * 1024 * 1024;
206+ const MAX_LINKS = 174;
207+ const MAX_DEPTH = 64;
208+ const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
209+ const textEncoder = new TextEncoder(),
210+ textDecoder = new TextDecoder("utf-8", { fatal: true });
211+ const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;
212+ const hashBytes = (h) => {
213+ if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h))
214+ throw new Error("expected lowercase SHA-256 hash");
215+ return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));
216+ };
217+ const keyBytes = (k) => {
218+ if (k === undefined) return;
219+ const b = asBytes(k);
220+ if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");
221+ return b;
222+ };
223+ const wellFormed = (value) => {
224+ for (let i = 0; i < value.length; i++) {
225+ const c = value.charCodeAt(i);
226+ if (c >= 55296 && c <= 56319) {
227+ if (
228+ ++i >= value.length ||
229+ value.charCodeAt(i) < 56320 ||
230+ value.charCodeAt(i) > 57343
231+ )
232+ return false;
233+ } else if (c >= 56320 && c <= 57343) return false;
234 }
235- const putInt = (w, n) => {
236- if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");
237- if (n >= 0) {
238- if (n < 128) w.u8(n);
239- else if (n < 256) w.push(204, n);
240- else if (n < 65536) w.push(205, n >> 8, n);
241- else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);
242- else {
243- w.u8(207);
244- let x = BigInt(n);
245- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));
246- }
247- } else if (n >= -32) w.u8(256 + n);
248- else if (n >= -128) w.push(208, n & 255);
249- else if (n >= -32768) w.push(209, n >> 8 & 255, n & 255);
250- else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);
251+ return true;
252+ };
253+ const nameOK = (n) =>
254+ typeof n === "string" &&
255+ n.length > 0 &&
256+ n !== "." &&
257+ n !== ".." &&
258+ wellFormed(n) &&
259+ !n.includes("/") &&
260+ !n.includes("\0") &&
261+ textEncoder.encode(n).length <= 1024;
262+ const int = (n) => Number.isSafeInteger(n) && n >= 0;
263+ const utf8cmp = (a, b) => {
264+ const x = textEncoder.encode(a),
265+ y = textEncoder.encode(b);
266+ for (let i = 0; i < Math.min(x.length, y.length); i++)
267+ if (x[i] !== y[i]) return x[i] - y[i];
268+ return x.length - y.length;
269+ };
270+ class MessagePackWriter {
271+ constructor() {
272+ this.output = [];
273+ }
274+ push(...x) {
275+ this.output.push(...x);
276+ }
277+ u8(n) {
278+ this.output.push(n & 255);
279+ }
280+ bytes() {
281+ return Uint8Array.from(this.output);
282+ }
283+ }
284+ const writeInteger = (w, n) => {
285+ if (!Number.isSafeInteger(n))
286+ throw new Error("manifest integers must be safe integers");
287+ if (n >= 0) {
288+ if (n < 128) w.u8(n);
289+ else if (n < 256) w.push(204, n);
290+ else if (n < 65536) w.push(205, n >> 8, n);
291+ else if (n < 4294967296) w.push(206, n >>> 24, n >>> 16, n >>> 8, n);
292 else {
293- w.u8(211);
294- let x = BigInt.asUintN(64, BigInt(n));
295- for (let i = 7; i >= 0; i--) w.u8(Number(x >> BigInt(i * 8) & 255n));
296- }
297- };
298- const put = (w, v) => {
299- if (v === null) return w.u8(192);
300- if (v === false) return w.u8(194);
301- if (v === true) return w.u8(195);
302- if (typeof v === "number") {
303- if (Number.isInteger(v)) return putInt(w, v);
304- if (!Number.isFinite(v)) throw Error("metadata number must be finite");
305- w.u8(203);
306- const b = new DataView(new ArrayBuffer(8));
307- b.setFloat64(0, v);
308- for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));
309- return;
310- }
311- if (typeof v === "string") {
312- const b = te.encode(v);
313- if (b.length < 32) w.u8(160 | b.length);
314- else if (b.length < 256) w.push(217, b.length);
315- else if (b.length < 65536) w.push(218, b.length >> 8, b.length);
316- else throw new Error("string too large");
317- w.push(...b);
318- return;
319- }
320- if (v instanceof Uint8Array) {
321- if (v.length < 256) w.push(196, v.length);
322- else if (v.length < 65536) w.push(197, v.length >> 8, v.length);
323- else throw new Error("binary value too large");
324- w.push(...v);
325- return;
326- }
327- if (Array.isArray(v)) {
328- if (v.length < 16) w.u8(144 | v.length);
329- else if (v.length < 65536) w.push(220, v.length >> 8, v.length);
330- else throw new Error("array too large");
331- v.forEach((x) => put(w, x));
332- return;
333+ w.u8(207);
334+ let x = BigInt(n);
335+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));
336 }
337+ } else if (n >= -32) w.u8(256 + n);
338+ else if (n >= -128) w.push(208, n & 255);
339+ else if (n >= -32768) w.push(209, (n >> 8) & 255, n & 255);
340+ else if (n >= -2147483648) w.push(210, n >>> 24, n >>> 16, n >>> 8, n);
341+ else {
342+ w.u8(211);
343+ let x = BigInt.asUintN(64, BigInt(n));
344+ for (let i = 7; i >= 0; i--) w.u8(Number((x >> BigInt(i * 8)) & 255n));
345+ }
346+ };
347+ const writeValue = (w, v) => {
348+ if (v === null) return w.u8(192);
349+ if (v === false) return w.u8(194);
350+ if (v === true) return w.u8(195);
351+ if (typeof v === "number") {
352+ if (Number.isInteger(v)) return writeInteger(w, v);
353+ if (!Number.isFinite(v)) throw Error("metadata number must be finite");
354+ w.u8(203);
355+ const b = new DataView(new ArrayBuffer(8));
356+ b.setFloat64(0, v);
357+ for (let i = 0; i < 8; i++) w.u8(b.getUint8(i));
358+ return;
359+ }
360+ if (typeof v === "string") {
361+ const b = textEncoder.encode(v);
362+ if (b.length < 32) w.u8(160 | b.length);
363+ else if (b.length < 256) w.push(217, b.length);
364+ else if (b.length < 65536) w.push(218, b.length >> 8, b.length);
365+ else throw new Error("string too large");
366+ w.push(...b);
367+ return;
368+ }
369+ if (v instanceof Uint8Array) {
370+ if (v.length < 256) w.push(196, v.length);
371+ else if (v.length < 65536) w.push(197, v.length >> 8, v.length);
372+ else throw new Error("binary value too large");
373+ w.push(...v);
374+ return;
375+ }
376+ if (Array.isArray(v)) {
377+ if (v.length < 16) w.u8(144 | v.length);
378+ else if (v.length < 65536) w.push(220, v.length >> 8, v.length);
379+ else throw new Error("array too large");
380+ v.forEach((x) => writeValue(w, x));
381+ return;
382+ }
383+ if (v && typeof v === "object") {
384+ const keys = Object.keys(v).sort(utf8cmp);
385+ if (keys.length < 16) w.u8(128 | keys.length);
386+ else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);
387+ else throw new Error("map too large");
388+ keys.forEach((k) => {
389+ writeValue(w, k);
390+ writeValue(w, v[k]);
391+ });
392+ return;
393+ }
394+ throw new Error("unsupported manifest value");
395+ };
396+ class MessagePackReader {
397+ constructor(b) {
398+ this.input = asBytes(b);
399+ this.offset = 0;
400+ }
401+ need(n) {
402+ if (this.offset + n > this.input.length) throw new Error("truncated MessagePack");
403+ }
404+ u8() {
405+ this.need(1);
406+ return this.input[this.offset++];
407+ }
408+ n(len) {
409+ this.need(len);
410+ const x = this.input.slice(this.offset, this.offset + len);
411+ this.offset += len;
412+ return x;
413+ }
414+ }
415+ const readMap = (r, n, depth) => {
416+ const o = Object.create(null);
417+ for (let i = 0; i < n; i++) {
418+ const k = readValue(r, depth + 1);
419+ if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k))
420+ throw Error("invalid map key");
421+ o[k] = readValue(r, depth + 1);
422+ }
423+ return o;
424+ };
425+ const readValue = (r, depth = 0) => {
426+ if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");
427+ const t = r.u8();
428+ if (t <= 127) return t;
429+ if (t >= 224) return t - 256;
430+ if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));
431+ if ((t & 240) === 144)
432+ return Array.from({ length: t & 15 }, () => readValue(r, depth + 1));
433+ if ((t & 240) === 128) return readMap(r, t & 15, depth);
434+ if (t === 192) return null;
435+ if (t === 194) return false;
436+ if (t === 195) return true;
437+ let n;
438+ if (t === 204) n = r.u8();
439+ else if (t === 205) n = (r.u8() << 8) | r.u8();
440+ else if (t === 206)
441+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
442+ else if (t === 207) {
443+ let x = 0n;
444+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
445+ if (x > BigInt(Number.MAX_SAFE_INTEGER))
446+ throw Error("integer exceeds safe range");
447+ n = Number(x);
448+ } else if (t === 208) n = (r.u8() << 24) >> 24;
449+ else if (t === 209) {
450+ n = (r.u8() << 8) | r.u8();
451+ if (n & 32768) n -= 65536;
452+ } else if (t === 210) {
453+ n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
454+ if (n >= 2147483648) n -= 4294967296;
455+ } else if (t === 211) {
456+ let x = 0n;
457+ for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
458+ if (x & 0x8000000000000000n) x -= 0x10000000000000000n;
459+ if (
460+ x > BigInt(Number.MAX_SAFE_INTEGER) ||
461+ x < BigInt(Number.MIN_SAFE_INTEGER)
462+ )
463+ throw Error("integer exceeds safe range");
464+ n = Number(x);
465+ } else if (t === 202) {
466+ const b = new ArrayBuffer(4),
467+ d = new DataView(b);
468+ for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());
469+ n = d.getFloat32(0);
470+ } else if (t === 203) {
471+ const b = new ArrayBuffer(8),
472+ d = new DataView(b);
473+ for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());
474+ n = d.getFloat64(0);
475+ } else if (t === 217) n = textDecoder.decode(r.n(r.u8()));
476+ else if (t === 218) n = textDecoder.decode(r.n((r.u8() << 8) | r.u8()));
477+ else if (t === 196) n = r.n(r.u8());
478+ else if (t === 197) n = r.n((r.u8() << 8) | r.u8());
479+ else if (t === 220) {
480+ n = (r.u8() << 8) | r.u8();
481+ return Array.from({ length: n }, () => readValue(r, depth + 1));
482+ } else if (t === 222) {
483+ n = (r.u8() << 8) | r.u8();
484+ return readMap(r, n, depth);
485+ } else throw Error("unsupported MessagePack type");
486+ return n;
487+ };
488+ const validateMeta = (m, depth = 0) => {
489+ if (m === undefined) return;
490+ if (
491+ depth > MAX_DEPTH ||
492+ !m ||
493+ Array.isArray(m) ||
494+ typeof m !== "object" ||
495+ m instanceof Uint8Array
496+ )
497+ throw Error("metadata must be a JSON map");
498+ const keys = Object.keys(m);
499+ if (keys.length > 64) throw Error("too many metadata keys");
500+ for (const [k, v] of Object.entries(m)) {
501+ if (typeof k !== "string" || textEncoder.encode(k).length > 256)
502+ throw Error("invalid metadata key");
503+ if (
504+ v === undefined ||
505+ typeof v === "bigint" ||
506+ typeof v === "function" ||
507+ typeof v === "symbol" ||
508+ (typeof v === "number" && !Number.isFinite(v))
509+ )
510+ throw Error("metadata is not JSON-compatible");
511 if (v && typeof v === "object") {
512- const keys = Object.keys(v).sort(utf8cmp);
513- if (keys.length < 16) w.u8(128 | keys.length);
514- else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);
515- else throw new Error("map too large");
516- keys.forEach((k) => {
517- put(w, k);
518- put(w, v[k]);
519- });
520- return;
521+ if (Array.isArray(v)) {
522+ if (v.length > 256) throw Error("metadata array too large");
523+ v.forEach((x) => validateMeta({ x }, depth + 1));
524+ } else validateMeta(v, depth + 1);
525 }
526- throw new Error("unsupported manifest value");
527- };
528- class Reader {
529- constructor(b) {
530- this.b = asBytes(b);
531- this.i = 0;
532- }
533- need(n) {
534- if (this.i + n > this.b.length) throw new Error("truncated MessagePack");
535+ }
536+ };
537+ const validateLink = (x, nodeType) => {
538+ if (!x || typeof x !== "object" || Array.isArray(x))
539+ throw Error("invalid manifest link");
540+ if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k)))
541+ throw Error("unknown manifest link field");
542+ if (!(x.h instanceof Uint8Array) || x.h.length !== 32)
543+ throw Error("link hash must be 32 bytes");
544+ if (x.k !== undefined) keyBytes(x.k);
545+ if (x.m !== undefined) validateMeta(x.m);
546+ if (!int(x.s)) throw Error("invalid link size");
547+ if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");
548+ if (nodeType === 1 && x.t !== 0 && x.t !== 1)
549+ throw Error("invalid file link type");
550+ if (nodeType === 1 && x.n !== undefined)
551+ throw Error("file links cannot have names");
552+ if (nodeType === 3 && x.n !== undefined)
553+ throw Error("fanout links cannot have names");
554+ if (nodeType === 3 && x.t !== 2 && x.t !== 3)
555+ throw Error("invalid fanout child type");
556+ if (nodeType === 2 && !nameOK(x.n))
557+ throw Error("invalid directory entry name");
558+ };
559+ const validateNode = (node, depth = 0) => {
560+ if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");
561+ if (
562+ !node ||
563+ typeof node !== "object" ||
564+ Array.isArray(node) ||
565+ !Array.isArray(node.l) ||
566+ !int(node.t) ||
567+ ![1, 2, 3].includes(node.t) ||
568+ Object.keys(node).some((k) => k !== "l" && k !== "t")
569+ )
570+ throw Error("invalid manifest node");
571+ if (node.l.length > MAX_LINKS) throw Error("too many manifest links");
572+ const seen = new Set(),
573+ links = node.l;
574+ for (const x of links) {
575+ validateLink(x, node.t);
576+ if (node.t === 2) {
577+ if (seen.has(x.n)) throw Error("duplicate directory entry");
578+ seen.add(x.n);
579 }
580- u8() {
581- this.need(1);
582- return this.b[this.i++];
583+ if (node.t === 3) {
584+ if (
585+ !x.m ||
586+ !int(x.m.count) ||
587+ x.m.count < 1 ||
588+ !nameOK(x.m.first) ||
589+ !nameOK(x.m.last) ||
590+ utf8cmp(x.m.first, x.m.last) > 0
591+ )
592+ throw Error("invalid fanout bounds");
593 }
594- n(len) {
595- this.need(len);
596- const x = this.b.slice(this.i, this.i + len);
597- this.i += len;
598- return x;
599+ }
600+ if (
601+ node.t === 2 &&
602+ links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)
603+ )
604+ throw Error("directory links are not canonical");
605+ return node;
606+ };
607+ const encodeManifest = (node) => {
608+ validateNode(node);
609+ const w = new MessagePackWriter();
610+ w.u8(130);
611+ writeValue(w, "l");
612+ if (node.l.length < 16) w.u8(144 | node.l.length);
613+ else w.push(220, node.l.length >> 8, node.l.length);
614+ for (const x of node.l) {
615+ const fields = [
616+ "h",
617+ ...(x.k !== undefined ? ["k"] : []),
618+ ...(x.m !== undefined ? ["m"] : []),
619+ ...(x.n !== undefined ? ["n"] : []),
620+ "s",
621+ "t",
622+ ];
623+ if (fields.length < 16) w.u8(128 | fields.length);
624+ else w.push(222, fields.length >> 8, fields.length);
625+ for (const k of fields) {
626+ writeValue(w, k);
627+ writeValue(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);
628 }
629 }
630- const readMap = (r, n, depth) => {
631- const o = /* @__PURE__ */ Object.create(null);
632- for (let i = 0; i < n; i++) {
633- const k = get(r, depth + 1);
634- if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");
635- o[k] = get(r, depth + 1);
636+ writeValue(w, "t");
637+ writeValue(w, node.t);
638+ const out = w.bytes();
639+ if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
640+ return out;
641+ };
642+ const decodeManifest = (bytes, options = {}) => {
643+ const b = asBytes(bytes);
644+ if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
645+ const r = new MessagePackReader(b),
646+ node = readValue(r);
647+ if (r.offset !== b.length) throw Error("trailing MessagePack data");
648+ validateNode(node);
649+ if (node.t === 3)
650+ for (const x of node.l) {
651+ if (x.m.count > Number(options.maxEntries || 1e9))
652+ throw Error("fanout count limit exceeded");
653 }
654- return o;
655- };
656- const get = (r, depth = 0) => {
657- if (depth > MAX_DEPTH) throw Error("MessagePack recursion limit exceeded");
658- const t = r.u8();
659- if (t <= 127) return t;
660- if (t >= 224) return t - 256;
661- if ((t & 224) === 160) return td.decode(r.n(t & 31));
662- if ((t & 240) === 144) return Array.from({ length: t & 15 }, () => get(r, depth + 1));
663- if ((t & 240) === 128) return readMap(r, t & 15, depth);
664- if (t === 192) return null;
665- if (t === 194) return false;
666- if (t === 195) return true;
667- let n;
668- if (t === 204) n = r.u8();
669- else if (t === 205) n = r.u8() << 8 | r.u8();
670- else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
671- else if (t === 207) {
672- let x = 0n;
673- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());
674- if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");
675- n = Number(x);
676- } else if (t === 208) n = r.u8() << 24 >> 24;
677- else if (t === 209) {
678- n = r.u8() << 8 | r.u8();
679- if (n & 32768) n -= 65536;
680- } else if (t === 210) {
681- n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
682- if (n >= 2147483648) n -= 4294967296;
683- } else if (t === 211) {
684- let x = 0n;
685- for (let i = 0; i < 8; i++) x = x << 8n | BigInt(r.u8());
686- if (x & 0x8000000000000000n) x -= 0x10000000000000000n;
687- if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER)) throw Error("integer exceeds safe range");
688- n = Number(x);
689- } else if (t === 202) {
690- const b = new ArrayBuffer(4), d = new DataView(b);
691- for (let i = 0; i < 4; i++) d.setUint8(i, r.u8());
692- n = d.getFloat32(0);
693- } else if (t === 203) {
694- const b = new ArrayBuffer(8), d = new DataView(b);
695- for (let i = 0; i < 8; i++) d.setUint8(i, r.u8());
696- n = d.getFloat64(0);
697- } else if (t === 217) n = td.decode(r.n(r.u8()));
698- else if (t === 218) n = td.decode(r.n(r.u8() << 8 | r.u8()));
699- else if (t === 196) n = r.n(r.u8());
700- else if (t === 197) n = r.n(r.u8() << 8 | r.u8());
701- else if (t === 220) {
702- n = r.u8() << 8 | r.u8();
703- return Array.from({ length: n }, () => get(r, depth + 1));
704- } else if (t === 222) {
705- n = r.u8() << 8 | r.u8();
706- return readMap(r, n, depth);
707- } else throw Error("unsupported MessagePack type");
708- return n;
709- };
710- const validateMeta = (m, depth = 0) => {
711- if (m === void 0) return;
712- if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array) throw Error("metadata must be a JSON map");
713- const keys = Object.keys(m);
714- if (keys.length > 64) throw Error("too many metadata keys");
715- for (const [k, v] of Object.entries(m)) {
716- if (typeof k !== "string" || te.encode(k).length > 256) throw Error("invalid metadata key");
717- 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");
718- if (v && typeof v === "object") {
719- if (Array.isArray(v)) {
720- if (v.length > 256) throw Error("metadata array too large");
721- v.forEach((x) => validateMeta({ x }, depth + 1));
722- } else validateMeta(v, depth + 1);
723- }
724+ return node;
725+ };
726+ const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));
727+ const leaf = (hash, size, key) => {
728+ const x = { h: hashBytes(hash), s: size, t: 0 };
729+ if (key !== undefined) x.k = keyBytes(key);
730+ return x;
731+ };
732+ const storeNode = async (node, options, manifests) => {
733+ const bytes = encodeManifest(node),
734+ computed = hex(await sha256(bytes));
735+ let result = { hash: computed };
736+ if (options.store) {
737+ const stored = await options.store({ hash: computed, bytes, node });
738+ if (stored) {
739+ if (typeof stored === "string") result.hash = stored;
740+ else result = { ...result, ...stored };
741 }
742- };
743- const validateLink = (x, nodeType) => {
744- if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");
745- if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k))) throw Error("unknown manifest link field");
746- if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");
747- if (x.k !== void 0) keyBytes(x.k);
748- if (x.m !== void 0) validateMeta(x.m);
749- if (!int(x.s)) throw Error("invalid link size");
750- if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");
751- if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");
752- if (nodeType === 1 && x.n !== void 0) throw Error("file links cannot have names");
753- if (nodeType === 3 && x.n !== void 0) throw Error("fanout links cannot have names");
754- if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");
755- if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");
756- };
757- const validateNode = (node, depth = 0) => {
758- if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");
759- 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");
760- if (node.l.length > MAX_LINKS) throw Error("too many manifest links");
761- const seen = /* @__PURE__ */ new Set(), links = node.l;
762- for (const x of links) {
763- validateLink(x, node.t);
764- if (node.t === 2) {
765- if (seen.has(x.n)) throw Error("duplicate directory entry");
766- seen.add(x.n);
767- }
768- if (node.t === 3) {
769- 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");
770- }
771+ }
772+ if (!/^[0-9a-f]{64}$/.test(result.hash))
773+ throw Error("store returned invalid manifest hash");
774+ if (result.key !== undefined) keyBytes(result.key);
775+ manifests.push({ hash: result.hash, bytes, node, key: result.key });
776+ return result;
777+ };
778+ const buildFile = async (chunks, options = {}) => {
779+ const max = options.maxLinks ?? MAX_LINKS;
780+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)
781+ throw Error("invalid maxLinks");
782+ const leaves = chunks.map((c) =>
783+ typeof c === "string"
784+ ? leaf(c, options.chunkSize || 0)
785+ : leaf(c.hash, c.size, c.key),
786+ );
787+ if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE))
788+ throw Error("invalid chunk size");
789+ const manifests = [];
790+ let level = leaves;
791+ while (level.length > max) {
792+ const next = [];
793+ for (let i = 0; i < level.length; i += max) {
794+ const group = level.slice(i, i + max),
795+ child = { l: group, t: 1 },
796+ stored = await storeNode(child, options, manifests);
797+ next.push({
798+ h: hashBytes(stored.hash),
799+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),
800+ s: group.reduce((n, x) => n + x.s, 0),
801+ t: 1,
802+ });
803 }
804- if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)) throw Error("directory links are not canonical");
805- return node;
806- };
807- const encodeManifest = (node) => {
808- validateNode(node);
809- const w = new Writer();
810- w.u8(130);
811- put(w, "l");
812- if (node.l.length < 16) w.u8(144 | node.l.length);
813- else w.push(220, node.l.length >> 8, node.l.length);
814- for (const x of node.l) {
815- const fields = ["h", ...x.k !== void 0 ? ["k"] : [], ...x.m !== void 0 ? ["m"] : [], ...x.n !== void 0 ? ["n"] : [], "s", "t"];
816- if (fields.length < 16) w.u8(128 | fields.length);
817- else w.push(222, fields.length >> 8, fields.length);
818- for (const k of fields) {
819- put(w, k);
820- put(w, k === "h" || k === "k" ? asBytes(x[k]) : x[k]);
821- }
822+ level = next;
823+ }
824+ const root = { l: level, t: 1 };
825+ await storeNode(root, options, manifests);
826+ return options.returnDetails ? { root, manifests } : root;
827+ };
828+ const buildDirectory = async (entries, options = {}) => {
829+ if (!Array.isArray(entries)) throw Error("entries must be an array");
830+ const max = options.maxLinks ?? MAX_LINKS;
831+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)
832+ throw Error("invalid maxLinks");
833+ const sorted = entries
834+ .map((e) => {
835+ const x = {
836+ h: hashBytes(e.hash),
837+ n: e.name,
838+ s: e.size || 0,
839+ t: e.type || 0,
840+ };
841+ if (e.key !== undefined) x.k = keyBytes(e.key);
842+ if (e.metadata !== undefined) x.m = e.metadata;
843+ return x;
844+ })
845+ .sort((a, b) => utf8cmp(a.n, b.n));
846+ for (let i = 1; i < sorted.length; i++)
847+ if (sorted[i - 1].n === sorted[i].n)
848+ throw Error("duplicate directory entry");
849+ const manifests = [];
850+ if (sorted.length <= max) {
851+ const directory = { l: sorted, t: 2 };
852+ await storeNode(directory, options, manifests);
853+ return options.returnDetails ? { root: directory, manifests } : directory;
854+ }
855+ let level = [];
856+ for (let i = 0; i < sorted.length; i += max) {
857+ const l = sorted.slice(i, i + max),
858+ child = { l, t: 2 },
859+ stored = await storeNode(child, options, manifests);
860+ level.push({
861+ h: hashBytes(stored.hash),
862+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),
863+ m: { count: l.length, first: l[0].n, last: l[l.length - 1].n },
864+ s: l.reduce((n, x) => n + x.s, 0),
865+ t: 2,
866+ });
867+ }
868+ while (level.length > max) {
869+ const next = [];
870+ for (let i = 0; i < level.length; i += max) {
871+ const l = level.slice(i, i + max),
872+ child = { l, t: 3 },
873+ stored = await storeNode(child, options, manifests);
874+ next.push({
875+ h: hashBytes(stored.hash),
876+ ...(stored.key ? { k: keyBytes(stored.key) } : {}),
877+ m: {
878+ count: l.reduce((n, x) => n + x.m.count, 0),
879+ first: l[0].m.first,
880+ last: l[l.length - 1].m.last,
881+ },
882+ s: l.reduce((n, x) => n + x.s, 0),
883+ t: 3,
884+ });
885 }
886- put(w, "t");
887- put(w, node.t);
888- const out = w.bytes();
889- if (out.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
890+ level = next;
891+ }
892+ const root = { l: level, t: 3 };
893+ await storeNode(root, options, manifests);
894+ return options.returnDetails ? { root, manifests } : root;
895+ };
896+ const flattenDirectory = (node, out = []) => {
897+ validateNode(node);
898+ if (node.t === 2) {
899+ out.push(...node.l);
900 return out;
901- };
902- const decodeManifest = (bytes, options = {}) => {
903- const b = asBytes(bytes);
904- if (b.length > MAX_MANIFEST_BYTES) throw Error("manifest too large");
905- const r = new Reader(b), node = get(r);
906- if (r.i !== b.length) throw Error("trailing MessagePack data");
907+ }
908+ if (node.t !== 3) throw Error("not a directory");
909+ for (const x of node.l) if (x.child) flattenDirectory(x.child, out);
910+ return out;
911+ };
912+ const resolveDirectory = async (root, fetchNode, options = {}) => {
913+ if (typeof fetchNode !== "function") throw Error("fetchNode is required");
914+ const maxDepth = options.maxDepth ?? MAX_DEPTH,
915+ maxEntries = options.maxEntries ?? 1e5,
916+ maxManifests = options.maxManifests ?? 1e4,
917+ maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
918+ if (
919+ [maxDepth, maxEntries, maxManifests, maxBytes].some(
920+ (value) => !Number.isSafeInteger(value) || value < 0,
921+ )
922+ )
923+ throw Error("invalid directory limits");
924+ let count = 0,
925+ manifestCount = 0,
926+ fetched = 0;
927+ const active = new Set();
928+ const walk = async (node, depth) => {
929+ if (depth > maxDepth) throw Error("directory recursion limit exceeded");
930+ if (++manifestCount > maxManifests)
931+ throw Error("manifest count limit exceeded");
932 validateNode(node);
933- if (node.t === 3) for (const x of node.l) {
934- if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");
935+ if (node.t === 2) {
936+ count += node.l.length;
937+ if (count > maxEntries) throw Error("directory entry limit exceeded");
938+ return node.l.slice();
939 }
940- return node;
941- };
942- const sha256 = async (b) => new Uint8Array(await crypto.subtle.digest("SHA-256", asBytes(b)));
943- const hex = (b) => Array.from(asBytes(b), (x) => x.toString(16).padStart(2, "0")).join("");
944- const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));
945- const leaf = (hash, size, key) => {
946- const x = { h: hashBytes(hash), s: size, t: 0 };
947- if (key !== void 0) x.k = keyBytes(key);
948- return x;
949- };
950- const storeNode = async (node, options, manifests) => {
951- const bytes = encodeManifest(node), computed = hex(await sha256(bytes));
952- let result = { hash: computed };
953- if (options.store) {
954- const stored = await options.store({ hash: computed, bytes, node });
955- if (stored) {
956- if (typeof stored === "string") result.hash = stored;
957- else result = { ...result, ...stored };
958+ if (node.t !== 3) throw Error("not a directory");
959+ const result = [];
960+ let previous = "";
961+ for (const link of node.l) {
962+ if (previous && utf8cmp(previous, link.m.first) >= 0)
963+ throw Error("fanout bounds are not ordered");
964+ previous = link.m.last;
965+ const linkHash = hex(link.h);
966+ if (active.has(linkHash)) throw Error("manifest cycle detected");
967+ active.add(linkHash);
968+ let raw = asBytes(await fetchNode(linkHash, link));
969+ fetched += raw.length;
970+ if (fetched > maxBytes) throw Error("directory byte limit exceeded");
971+ if (hex(await sha256(raw)) !== linkHash)
972+ throw Error("manifest hash mismatch");
973+ if (link.k) {
974+ if (typeof options.decrypt !== "function")
975+ throw Error("encrypted manifest requires decrypt callback");
976+ raw = asBytes(await options.decrypt(raw, link));
977 }
978+ const child = decodeManifest(raw);
979+ if (child.t !== link.t) throw Error("fanout child type mismatch");
980+ const links = await walk(child, depth + 1);
981+ active.delete(linkHash);
982+ if (
983+ links.length !== link.m.count ||
984+ links[0]?.n !== link.m.first ||
985+ links.at(-1)?.n !== link.m.last
986+ )
987+ throw Error("fanout bounds mismatch");
988+ result.push(...links);
989 }
990- if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");
991- if (result.key !== void 0) keyBytes(result.key);
992- manifests.push({ hash: result.hash, bytes, node, key: result.key });
993+ for (let i = 1; i < result.length; i++)
994+ if (utf8cmp(result[i - 1].n, result[i].n) >= 0)
995+ throw Error("duplicate or unordered directory entry");
996 return result;
997 };
998- const buildFile = async (chunks, options = {}) => {
999- const max = options.maxLinks ?? MAX_LINKS;
1000- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
1001- const leaves = chunks.map((c) => typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key));
1002- if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");
1003- const manifests = [];
1004- let level = leaves;
1005- while (level.length > max) {
1006- const next = [];
1007- for (let i = 0; i < level.length; i += max) {
1008- const group = level.slice(i, i + max), child = { l: group, t: 1 }, stored = await storeNode(child, options, manifests);
1009- next.push({ h: hashBytes(stored.hash), ...stored.key ? { k: keyBytes(stored.key) } : {}, s: group.reduce((n, x) => n + x.s, 0), t: 1 });
1010- }
1011- level = next;
1012- }
1013- const root = { l: level, t: 1 };
1014- await storeNode(root, options, manifests);
1015- return options.returnDetails ? { root, manifests } : root;
1016- };
1017- const buildDirectory = async (entries, options = {}) => {
1018- if (!Array.isArray(entries)) throw Error("entries must be an array");
1019- const max = options.maxLinks ?? MAX_LINKS;
1020- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
1021- const sorted = entries.map((e) => {
1022- const x = { h: hashBytes(e.hash), n: e.name, s: e.size || 0, t: e.type || 0 };
1023- if (e.key !== void 0) x.k = keyBytes(e.key);
1024- if (e.metadata !== void 0) x.m = e.metadata;
1025- return x;
1026- }).sort((a, b) => utf8cmp(a.n, b.n));
1027- for (let i = 1; i < sorted.length; i++) if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");
1028- const manifests = [];
1029- if (sorted.length <= max) {
1030- const root2 = { l: sorted, t: 2 };
1031- await storeNode(root2, options, manifests);
1032- return options.returnDetails ? { root: root2, manifests } : root2;
1033- }
1034- let level = [];
1035- for (let i = 0; i < sorted.length; i += max) {
1036- const l = sorted.slice(i, i + max), child = { l, t: 2 }, stored = await storeNode(child, options, manifests);
1037- 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 });
1038- }
1039- while (level.length > max) {
1040- const next = [];
1041- for (let i = 0; i < level.length; i += max) {
1042- const l = level.slice(i, i + max), child = { l, t: 3 }, stored = await storeNode(child, options, manifests);
1043- 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 });
1044- }
1045- level = next;
1046- }
1047- const root = { l: level, t: 3 };
1048- await storeNode(root, options, manifests);
1049- return options.returnDetails ? { root, manifests } : root;
1050- };
1051- const flattenDirectory = (node, out = []) => {
1052+ return walk(root, 0);
1053+ };
1054+ const readFile = async (root, fetchBlob, options = {}) => {
1055+ if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");
1056+ const maxDepth = options.maxDepth ?? MAX_DEPTH,
1057+ maxBytes = options.maxBytes ?? 512 * 1024 * 1024,
1058+ maxManifests = options.maxManifests ?? 1e4;
1059+ const maxFetchedBytes =
1060+ options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;
1061+ if (
1062+ [maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(
1063+ (value) => !Number.isSafeInteger(value) || value < 0,
1064+ )
1065+ )
1066+ throw Error("invalid file limits");
1067+ let used = 0,
1068+ fetched = 0,
1069+ manifests = 0;
1070+ const active = new Set(),
1071+ parts = [];
1072+ const readNode = async (node, depth) => {
1073+ if (depth > maxDepth) throw Error("file recursion limit exceeded");
1074 validateNode(node);
1075- if (node.t === 2) {
1076- out.push(...node.l);
1077- return out;
1078- }
1079- if (node.t !== 3) throw Error("not a directory");
1080- for (const x of node.l) if (x.child) flattenDirectory(x.child, out);
1081- return out;
1082- };
1083- const resolveDirectory = async (root, fetchNode, options = {}) => {
1084- if (typeof fetchNode !== "function") throw Error("fetchNode is required");
1085- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxEntries = options.maxEntries ?? 1e5, maxManifests = options.maxManifests ?? 1e4, maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
1086- if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid directory limits");
1087- let count = 0, manifestCount = 0, fetched = 0;
1088- const active = /* @__PURE__ */ new Set();
1089- const walk = async (node, depth) => {
1090- if (depth > maxDepth) throw Error("directory recursion limit exceeded");
1091- if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");
1092- validateNode(node);
1093- if (node.t === 2) {
1094- count += node.l.length;
1095- if (count > maxEntries) throw Error("directory entry limit exceeded");
1096- return node.l.slice();
1097- }
1098- if (node.t !== 3) throw Error("not a directory");
1099- const result = [];
1100- let previous = "";
1101- for (const link of node.l) {
1102- if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");
1103- previous = link.m.last;
1104- const linkHash = hex(link.h);
1105- if (active.has(linkHash)) throw Error("manifest cycle detected");
1106- active.add(linkHash);
1107- let raw = asBytes(await fetchNode(linkHash, link));
1108- fetched += raw.length;
1109- if (fetched > maxBytes) throw Error("directory byte limit exceeded");
1110- if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");
1111- if (link.k) {
1112- if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");
1113- raw = asBytes(await options.decrypt(raw, link));
1114- }
1115+ if (node.t !== 1) throw Error("not a file manifest");
1116+ if (++manifests > maxManifests)
1117+ throw Error("manifest count limit exceeded");
1118+ for (const link of node.l) {
1119+ const hash = hex(link.h);
1120+ if (active.has(hash)) throw Error("manifest cycle detected");
1121+ active.add(hash);
1122+ let raw = asBytes(await fetchBlob(hash, link));
1123+ fetched += raw.length;
1124+ if (fetched > maxFetchedBytes)
1125+ throw Error("file fetched byte limit exceeded");
1126+ if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");
1127+ if (link.t === 1) {
1128+ if (typeof options.decrypt !== "function" && link.k)
1129+ throw Error("encrypted manifest requires decrypt callback");
1130+ if (link.k) raw = asBytes(await options.decrypt(raw, link));
1131 const child = decodeManifest(raw);
1132- if (child.t !== link.t) throw Error("fanout child type mismatch");
1133- const links = await walk(child, depth + 1);
1134- active.delete(linkHash);
1135- if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last) throw Error("fanout bounds mismatch");
1136- result.push(...links);
1137- }
1138- 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");
1139- return result;
1140- };
1141- return walk(root, 0);
1142- };
1143- const readFile = async (root, fetchBlob, options = {}) => {
1144- if (typeof fetchBlob !== "function") throw Error("fetchBlob is required");
1145- const maxDepth = options.maxDepth ?? MAX_DEPTH, maxBytes = options.maxBytes ?? 512 * 1024 * 1024, maxManifests = options.maxManifests ?? 1e4;
1146- const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;
1147- if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0)) throw Error("invalid file limits");
1148- let used = 0, fetched = 0, manifests = 0;
1149- const active = /* @__PURE__ */ new Set(), parts = [];
1150- const readNode = async (node, depth) => {
1151- if (depth > maxDepth) throw Error("file recursion limit exceeded");
1152- validateNode(node);
1153- if (node.t !== 1) throw Error("not a file manifest");
1154- if (++manifests > maxManifests) throw Error("manifest count limit exceeded");
1155- for (const link of node.l) {
1156- const hash = hex(link.h);
1157- if (active.has(hash)) throw Error("manifest cycle detected");
1158- active.add(hash);
1159- let raw = asBytes(await fetchBlob(hash, link));
1160- fetched += raw.length;
1161- if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");
1162- if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");
1163- if (link.t === 1) {
1164- if (typeof options.decrypt !== "function" && link.k) throw Error("encrypted manifest requires decrypt callback");
1165- if (link.k) raw = asBytes(await options.decrypt(raw, link));
1166- const child = decodeManifest(raw);
1167- if (child.t !== 1) throw Error("file child type mismatch");
1168- const before = used;
1169- await readNode(child, depth + 1);
1170- if (used - before !== link.s) throw Error("file manifest size mismatch");
1171- } else {
1172- if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");
1173- const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;
1174- if (data.length !== link.s) throw Error("file chunk size mismatch");
1175- used += data.length;
1176- if (used > maxBytes) throw Error("file byte limit exceeded");
1177- parts.push(data);
1178- }
1179- active.delete(hash);
1180+ if (child.t !== 1) throw Error("file child type mismatch");
1181+ const before = used;
1182+ await readNode(child, depth + 1);
1183+ if (used - before !== link.s)
1184+ throw Error("file manifest size mismatch");
1185+ } else {
1186+ if (link.k && typeof options.decrypt !== "function")
1187+ throw Error("encrypted chunk requires decrypt callback");
1188+ const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;
1189+ if (data.length !== link.s) throw Error("file chunk size mismatch");
1190+ used += data.length;
1191+ if (used > maxBytes) throw Error("file byte limit exceeded");
1192+ parts.push(data);
1193 }
1194- };
1195- await readNode(root, 0);
1196- const out = new Uint8Array(used);
1197- let at = 0;
1198- for (const p of parts) {
1199- out.set(p, at);
1200- at += p.length;
1201+ active.delete(hash);
1202 }
1203- return out;
1204 };
1205- globalThis.TinyBlossomManifests = Object.freeze({ CHUNK_SIZE, MAX_LINKS, encodeManifest, decodeManifest, encode: encodeManifest, decode: decodeManifest, manifestHash, buildFile, buildDirectory, flattenDirectory, resolveDirectory, readFile, sha256, hex });
1206- })();
1207+ await readNode(root, 0);
1208+ const out = new Uint8Array(used);
1209+ let at = 0;
1210+ for (const p of parts) {
1211+ out.set(p, at);
1212+ at += p.length;
1213+ }
1214+ return out;
1215+ };
1216+ const api = Object.freeze({
1217+ CHUNK_SIZE,
1218+ MAX_LINKS,
1219+ encodeManifest,
1220+ decodeManifest,
1221+ encode: encodeManifest,
1222+ decode: decodeManifest,
1223+ manifestHash,
1224+ buildFile,
1225+ buildDirectory,
1226+ flattenDirectory,
1227+ resolveDirectory,
1228+ readFile,
1229+ sha256,
1230+ hex,
1231+ });
1232+ globalThis.tiny = globalThis.tiny || {};
1233+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, manifests: api };
1234 })();
1235diff --git a/internal/webui/blossom-upload.js b/internal/webui/blossom-upload.js
1236index a5b8acb..42bbea1 100644
1237--- a/internal/webui/blossom-upload.js
1238+++ b/internal/webui/blossom-upload.js
1239@@ -1,80 +1,159 @@
1240 // BUD-14 draft multipart uploads.
1241-// https://raw.githubusercontent.com/hzrd149/blossom/multi-part-uploads-2/buds/14.md
1242+// https://github.com/hzrd149/blossom/pull/102
1243 // Upload state is deliberately held by the caller. It does not survive a page
1244 // navigation because BUD-14 has no offset discovery protocol.
1245 (() => {
1246 "use strict";
1247
1248- const sha256 = async bytes => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
1249- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");
1250- const asBytes = async value => {
1251+ const {sha256, hex} = globalThis.tiny.util;
1252+ const asBytes = async (value) => {
1253 if (value instanceof Uint8Array) return value;
1254- if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1255+ if (ArrayBuffer.isView(value))
1256+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
1257 if (value instanceof ArrayBuffer) return new Uint8Array(value);
1258 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());
1259 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");
1260 };
1261- const responseBody = async response => {
1262- try { return await response.json(); } catch { return null; }
1263+ const responseBody = async (response) => {
1264+ try {
1265+ return await response.json();
1266+ } catch {
1267+ return null;
1268+ }
1269+ };
1270+ const abortError = () => {
1271+ const error = new Error("upload canceled");
1272+ error.name = "AbortError";
1273+ return error;
1274 };
1275- const abortError = () => { const error = new Error("upload canceled"); error.name = "AbortError"; return error; };
1276 const verifyDescriptor = (descriptor, hash, size) => {
1277- if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size) throw new Error("invalid Blossom upload descriptor");
1278+ if (
1279+ !descriptor ||
1280+ typeof descriptor !== "object" ||
1281+ descriptor.sha256 !== hash ||
1282+ descriptor.size !== size
1283+ )
1284+ throw new Error("invalid Blossom upload descriptor");
1285 return descriptor;
1286 };
1287
1288 const uploadTask = async (source, options = {}, controller) => {
1289 const bytes = await asBytes(source);
1290 const hash = options.hash || hex(await sha256(bytes));
1291- if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");
1292- if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");
1293+ if (!/^[0-9a-f]{64}$/.test(hash))
1294+ throw new Error("expected a lowercase SHA-256 hash");
1295+ if (options.hash && hash !== hex(await sha256(bytes)))
1296+ throw new Error("upload hash mismatch");
1297 const type = options.type || source.type || "application/octet-stream";
1298 const base = options.url || options.endpoint;
1299- if (typeof base !== "string" || base === "") throw new Error("upload URL is required");
1300+ if (typeof base !== "string" || base === "")
1301+ throw new Error("upload URL is required");
1302 let parsed;
1303- try { parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid"); } catch { throw new Error("invalid upload URL"); }
1304- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("upload URL must use HTTP or HTTPS");
1305- if (parsed.username || parsed.password || parsed.hash || parsed.search) throw new Error("upload URL must not contain credentials, query, or fragment");
1306- const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";
1307- if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");
1308+ try {
1309+ parsed = new URL(
1310+ base,
1311+ globalThis.location?.href || "https://blossom.invalid",
1312+ );
1313+ } catch {
1314+ throw new Error("invalid upload URL");
1315+ }
1316+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
1317+ throw new Error("upload URL must use HTTP or HTTPS");
1318+ if (parsed.username || parsed.password || parsed.hash || parsed.search)
1319+ throw new Error(
1320+ "upload URL must not contain credentials, query, or fragment",
1321+ );
1322+ const extension = options.extension
1323+ ? String(options.extension).replace(/^\.+/, "")
1324+ : "";
1325+ if (extension && !/^[a-z0-9]{1,8}$/.test(extension))
1326+ throw new Error("invalid file extension");
1327 const path = parsed.pathname;
1328- const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path) ? base : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");
1329+ const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(
1330+ path,
1331+ )
1332+ ? base
1333+ : base.replace(/\/$/, "") +
1334+ "/" +
1335+ hash +
1336+ (extension ? "." + extension : "");
1337 const url = target;
1338 const fetcher = options.fetch || globalThis.fetch;
1339 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");
1340- const chunkSize = Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;
1341- const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;
1342- const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;
1343+ const chunkSize =
1344+ Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0
1345+ ? options.chunkSize
1346+ : 5 * 1024 * 1024;
1347+ const timeoutMs =
1348+ Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0
1349+ ? options.timeoutMs
1350+ : 30000;
1351+ const retries =
1352+ Number.isSafeInteger(options.retries) && options.retries >= 0
1353+ ? options.retries
1354+ : 0;
1355 const state = options.state instanceof Map ? options.state : new Map();
1356- const notify = (offset, length, status) => options.onState?.({offset, length, status, state});
1357- const progress = sent => options.onProgress?.({sent, total: bytes.byteLength, fraction: bytes.byteLength ? sent / bytes.byteLength : 1});
1358+ const notify = (offset, length, status) =>
1359+ options.onState?.({ offset, length, status, state });
1360+ const progress = (sent) =>
1361+ options.onProgress?.({
1362+ sent,
1363+ total: bytes.byteLength,
1364+ fraction: bytes.byteLength ? sent / bytes.byteLength : 1,
1365+ });
1366 const request = async (method, body, headers, authBody = body) => {
1367 if (controller.signal.aborted) throw abortError();
1368 const requestController = new AbortController();
1369 let timer, rejectCanceled;
1370- const cancel = () => { requestController.abort(); rejectCanceled?.(abortError()); };
1371- controller.signal.addEventListener("abort", cancel, {once: true});
1372+ const cancel = () => {
1373+ requestController.abort();
1374+ rejectCanceled?.(abortError());
1375+ };
1376+ controller.signal.addEventListener("abort", cancel, { once: true });
1377 try {
1378 return await Promise.race([
1379 (async () => {
1380- const requestHeaders = {...headers};
1381+ const requestHeaders = { ...headers };
1382 if (options.authorize && method !== "OPTIONS") {
1383- const value = await options.authorize(url, method, authBody, {hash, type});
1384- if (typeof value === "string") requestHeaders.authorization = value;
1385- else if (value && typeof value === "object") Object.assign(requestHeaders, value);
1386+ const value = await options.authorize(url, method, authBody, {
1387+ hash,
1388+ type,
1389+ });
1390+ if (typeof value === "string")
1391+ requestHeaders.authorization = value;
1392+ else if (value && typeof value === "object")
1393+ Object.assign(requestHeaders, value);
1394 }
1395 if (requestController.signal.aborted) throw abortError();
1396- const response = await fetcher(url, {method, headers: requestHeaders, body: method === "OPTIONS" ? undefined : body, signal: requestController.signal});
1397+ const response = await fetcher(url, {
1398+ method,
1399+ headers: requestHeaders,
1400+ body: method === "OPTIONS" ? undefined : body,
1401+ signal: requestController.signal,
1402+ });
1403 // Keep the request deadline and cancellation active until the
1404 // descriptor body has arrived, not only until headers arrive.
1405- const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;
1406+ const descriptor =
1407+ response.status === 200 || response.status === 201
1408+ ? await responseBody(response)
1409+ : null;
1410 if (!descriptor) await response.body?.cancel?.();
1411- return {status: response.status, ok: response.ok, headers: response.headers, json: async () => descriptor};
1412+ return {
1413+ status: response.status,
1414+ ok: response.ok,
1415+ headers: response.headers,
1416+ json: async () => descriptor,
1417+ };
1418 })(),
1419 new Promise((_, reject) => {
1420- timer = setTimeout(() => { requestController.abort(); reject(Error("upload request timed out")); }, timeoutMs);
1421+ timer = setTimeout(() => {
1422+ requestController.abort();
1423+ reject(Error("upload request timed out"));
1424+ }, timeoutMs);
1425+ }),
1426+ new Promise((_, reject) => {
1427+ rejectCanceled = reject;
1428 }),
1429- new Promise((_, reject) => { rejectCanceled = reject; })
1430 ]);
1431 } finally {
1432 clearTimeout(timer);
1433@@ -88,7 +167,10 @@
1434 if (controller.signal.aborted) throw abortError();
1435 try {
1436 const result = await action();
1437- if (!result.ok && result.status >= 500 && attempt++ < retries) { notify(offset, length, "retrying"); continue; }
1438+ if (!result.ok && result.status >= 500 && attempt++ < retries) {
1439+ notify(offset, length, "retrying");
1440+ continue;
1441+ }
1442 return result;
1443 } catch (error) {
1444 if (controller.signal.aborted) throw abortError();
1445@@ -102,45 +184,112 @@
1446 if (options.probe !== false) {
1447 try {
1448 const response = await request("OPTIONS", null, {});
1449- supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");
1450- } catch { supportsPatch = false; }
1451+ supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(
1452+ response.headers?.get?.("allow") || "",
1453+ );
1454+ } catch {
1455+ supportsPatch = false;
1456+ }
1457 }
1458 if (!supportsPatch) {
1459- const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);
1460- if (!response.ok) throw new Error("Blossom upload failed: " + response.status);
1461- return {hash, descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength), state};
1462+ const response = await retry(
1463+ () => request("PUT", bytes, { "content-type": type }),
1464+ 0,
1465+ bytes.byteLength,
1466+ );
1467+ if (!response.ok)
1468+ throw new Error("Blossom upload failed: " + response.status);
1469+ return {
1470+ hash,
1471+ descriptor: verifyDescriptor(
1472+ await responseBody(response),
1473+ hash,
1474+ bytes.byteLength,
1475+ ),
1476+ state,
1477+ };
1478 }
1479
1480 const metadata = state.meta;
1481- if (!metadata || metadata.hash !== hash || metadata.size !== bytes.byteLength || metadata.chunkSize !== chunkSize || metadata.url !== url || metadata.type !== type || Date.now() - (metadata.lastAcceptedAt || 0) >= 60000) {
1482+ if (
1483+ !metadata ||
1484+ metadata.hash !== hash ||
1485+ metadata.size !== bytes.byteLength ||
1486+ metadata.chunkSize !== chunkSize ||
1487+ metadata.url !== url ||
1488+ metadata.type !== type ||
1489+ Date.now() - (metadata.lastAcceptedAt || 0) >= 60000
1490+ ) {
1491 state.clear();
1492- state.meta = {hash, size: bytes.byteLength, chunkSize, url, type, lastAcceptedAt: 0};
1493+ state.meta = {
1494+ hash,
1495+ size: bytes.byteLength,
1496+ chunkSize,
1497+ url,
1498+ type,
1499+ lastAcceptedAt: 0,
1500+ };
1501 }
1502 let sent = 0;
1503 let replayed = false;
1504 while (true) {
1505- for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {
1506- const end = Math.min(offset + chunkSize, bytes.byteLength);
1507- const chunk = bytes.slice(offset, end);
1508- const length = chunk.byteLength;
1509- const prior = state.get(offset);
1510- if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) { sent = end; progress(sent); continue; }
1511- const result = await retry(() => request("PATCH", chunk, {
1512- "upload-type": type,
1513- "upload-length": String(bytes.byteLength),
1514- "upload-offset": String(offset),
1515- "content-type": "application/octet-stream"
1516- }, options.authorizationMode === "final-hash" ? bytes : chunk), offset, length);
1517- if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);
1518- state.set(offset, {offset, length, status: result.status});
1519- state.meta.lastAcceptedAt = Date.now();
1520- sent = end;
1521- notify(offset, length, result.status === 204 ? "uploaded" : "complete");
1522- progress(sent);
1523- if (result.status !== 204) return {hash, descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength), state};
1524- if (bytes.byteLength === 0) break;
1525+ for (
1526+ let offset = 0;
1527+ offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0);
1528+ offset += chunkSize
1529+ ) {
1530+ const end = Math.min(offset + chunkSize, bytes.byteLength);
1531+ const chunk = bytes.slice(offset, end);
1532+ const length = chunk.byteLength;
1533+ const prior = state.get(offset);
1534+ if (
1535+ !replayed &&
1536+ prior?.status === 204 &&
1537+ Date.now() - (state.meta.lastAcceptedAt || 0) < 60000
1538+ ) {
1539+ sent = end;
1540+ progress(sent);
1541+ continue;
1542+ }
1543+ const result = await retry(
1544+ () =>
1545+ request(
1546+ "PATCH",
1547+ chunk,
1548+ {
1549+ "upload-type": type,
1550+ "upload-length": String(bytes.byteLength),
1551+ "upload-offset": String(offset),
1552+ "content-type": "application/octet-stream",
1553+ },
1554+ options.authorizationMode === "final-hash" ? bytes : chunk,
1555+ ),
1556+ offset,
1557+ length,
1558+ );
1559+ if (![200, 201, 204].includes(result.status))
1560+ throw new Error("Blossom chunk upload failed: " + result.status);
1561+ state.set(offset, { offset, length, status: result.status });
1562+ state.meta.lastAcceptedAt = Date.now();
1563+ sent = end;
1564+ notify(offset, length, result.status === 204 ? "uploaded" : "complete");
1565+ progress(sent);
1566+ if (result.status !== 204)
1567+ return {
1568+ hash,
1569+ descriptor: verifyDescriptor(
1570+ await responseBody(result),
1571+ hash,
1572+ bytes.byteLength,
1573+ ),
1574+ state,
1575+ };
1576+ if (bytes.byteLength === 0) break;
1577+ }
1578+ if (!replayed) {
1579+ replayed = true;
1580+ continue;
1581 }
1582- if (!replayed) { replayed = true; continue; }
1583 throw new Error("multipart upload ended without completion");
1584 }
1585 };
1586@@ -149,12 +298,17 @@
1587 const controller = new AbortController();
1588 if (options.signal) {
1589 if (options.signal.aborted) controller.abort();
1590- else options.signal.addEventListener("abort", () => controller.abort(), {once: true});
1591+ else
1592+ options.signal.addEventListener("abort", () => controller.abort(), {
1593+ once: true,
1594+ });
1595 }
1596 const task = uploadTask(source, options, controller);
1597 task.cancel = () => controller.abort();
1598 return task;
1599 };
1600
1601- globalThis.TinyBlossomUpload = Object.freeze({upload});
1602+ const api = Object.freeze({ upload });
1603+ globalThis.tiny = globalThis.tiny || {};
1604+ globalThis.tiny.blossom = { ...globalThis.tiny.blossom, upload: api };
1605 })();
1606diff --git a/internal/webui/bridge.js b/internal/webui/bridge.js
1607index c193ea9..3ff56fa 100644
1608--- a/internal/webui/bridge.js
1609+++ b/internal/webui/bridge.js
1610@@ -18,8 +18,8 @@
1611 if (status) status.textContent = text;
1612 };
1613 const errorText = err => err instanceof Error ? err.message : String(err);
1614- const hex = bytes => Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
1615- const sha256hex = async bytes => hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
1616+ const {hex, sha256} = window.tiny.util;
1617+ const sha256hex = async bytes => hex(await sha256(bytes));
1618 const randomHex = () => { const b = new Uint8Array(32); crypto.getRandomValues(b); return hex(b); };
1619 const toBytes = body => body instanceof ArrayBuffer ? new Uint8Array(body) : body instanceof Uint8Array ? body : new TextEncoder().encode(body || "");
1620 const validRelay = value => {
1621@@ -102,7 +102,7 @@
1622 document.getElementById("menu")?.focus();
1623 });
1624 if ("serviceWorker" in navigator) navigator.serviceWorker.register(localPath("/sw.js")).catch(() => {});
1625- window.tiny = {root, localPath, sha256hex, authorization, signedFetch};
1626+ Object.assign(window.tiny, {root, localPath, sha256hex, authorization, signedFetch});
1627 window.tinySignedFetch = signedFetch;
1628
1629 const signIn = async () => {
1630@@ -127,6 +127,7 @@
1631 const setSigner = signer => {
1632 window.tinySigner = signer;
1633 window.nostr = {signEvent: event => window.tinySigner.signEvent(event)};
1634+ document.dispatchEvent(new CustomEvent("tiny:signer"));
1635 if (document.getElementById("signer-status")) {
1636 say(document.getElementById("session-logout") ? "Signer connected." : "Signer connected. Choose Sign in with connected signer to continue.");
1637 }
1638diff --git a/internal/webui/components.js b/internal/webui/components.js
1639index 7cef85d..0197e5b 100644
1640--- a/internal/webui/components.js
1641+++ b/internal/webui/components.js
1642@@ -244,7 +244,7 @@
1643 blossomURI() {
1644 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));
1645 const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin";
1646- const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? TinyBlossomEncryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();
1647+ 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();
1648 return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host);
1649 }
1650 shareLink() {
1651@@ -280,8 +280,8 @@
1652 const plaintextHash = await tiny.sha256hex(plaintext);
1653 let ciphertext, fragment;
1654 if (scheme === "chk") {
1655- if (!globalThis.TinyBlossomEncryption) throw Error("CHK encryption is unavailable. Reload this page.");
1656- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);
1657+ if (!tiny.blossom.encryption) throw Error("CHK encryption is unavailable. Reload this page.");
1658+ const encrypted = await tiny.blossom.encryption.encryptCHK(plaintext);
1659 ciphertext = encrypted.ciphertext;
1660 fragment = "enc=chk-v1&key=" + encrypted.key;
1661 } else {
1662@@ -313,8 +313,8 @@
1663 const actualHash = await tiny.sha256hex(ciphertext);
1664 this.uploadController = new AbortController();
1665 let descriptor;
1666- if (globalThis.TinyBlossomUpload?.upload) {
1667- const task = TinyBlossomUpload.upload(ciphertext, {
1668+ if (tiny.blossom.upload?.upload) {
1669+ const task = tiny.blossom.upload.upload(ciphertext, {
1670 url: new URL(tiny.localPath("/"), location.href).href, hash: actualHash,
1671 type: "application/octet-stream", state, signal: this.uploadController.signal,
1672 authorize: (url, verb, body) => tiny.authorization(url, verb, body),
1673@@ -362,7 +362,7 @@
1674 const encrypted = new Uint8Array(await response.arrayBuffer());
1675 if (await tiny.sha256hex(encrypted) !== this.hash()) throw Error("ciphertext hash mismatch");
1676 let plain;
1677- if (params.get("enc") === "chk-v1") plain = await TinyBlossomEncryption.decryptCHK(encrypted, params.get("key"), this.hash());
1678+ if (params.get("enc") === "chk-v1") plain = await tiny.blossom.encryption.decryptCHK(encrypted, params.get("key"), this.hash());
1679 else {
1680 const key = await crypto.subtle.importKey("raw", fromB64url(params.get("key")), "AES-GCM", false, ["decrypt"]);
1681 plain = await crypto.subtle.decrypt({name: "AES-GCM", iv: fromB64url(params.get("iv"))}, key, encrypted);
1682@@ -379,15 +379,15 @@
1683 if (!recipient) { this.say("Enter a public key or npub.", true); return; }
1684 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));
1685 const signer = globalThis.tinySigner || globalThis.nostr;
1686- 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; }
1687+ 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; }
1688 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }
1689 this.sharing = true;
1690 try {
1691 const key = fromB64url(params.get("key")), nonce = fromB64url(params.get("iv"));
1692 if (key.length !== 32 || nonce.length !== 12) throw Error("Invalid file key or nonce.");
1693- const hex = bytes => Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");
1694+ const {hex} = tiny.util;
1695 this.say("Preparing sealed NIP-17 delivery…");
1696- 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});
1697+ 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});
1698 this.say("Encrypted message delivered; sender copy saved.");
1699 } catch (error) { this.say("NIP-17 delivery failed: " + error.message, true); } finally { this.sharing = false; }
1700 }
1701@@ -467,8 +467,11 @@
1702 const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
1703 const result = await response.json();
1704 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
1705- this.report("Published. Reload to see the update.");
1706+ this.report("Published.");
1707 form.reset();
1708+ // Refresh through the shared navigation so the thread shows the new
1709+ // event without a full reload.
1710+ await tiny.navigate?.(globalThis.location?.href);
1711 }
1712 }
1713
1714@@ -708,11 +711,12 @@
1715 this.whenSigned(() => this.load().catch(err => this.say("error: " + err.message, true)));
1716 }
1717
1718- // whenSigned waits briefly for a signer, since a remote one resumes async.
1719- whenSigned(fn, tries = 20) {
1720+ // whenSigned runs once a signer is available; the bridge announces a
1721+ // connected or resumed remote signer with the tiny:signer event.
1722+ whenSigned(fn) {
1723 if (window.nostr?.signEvent) return fn();
1724- if (tries <= 0) { this.say("connect a signer, then reload this page"); return; }
1725- setTimeout(() => this.whenSigned(fn, tries - 1), 500);
1726+ this.say("connect a signer to load this list");
1727+ document.addEventListener("tiny:signer", () => fn(), {once: true});
1728 }
1729
1730 say(text, error) {
1731@@ -929,6 +933,8 @@
1732 throw error;
1733 } finally { if (serial === navigationSerial) setBusy(false); }
1734 };
1735+ // navigate re-requests a page in place; components use it after a publish.
1736+ tiny.navigate = href => load(new URL(href, location.href), false);
1737 document.addEventListener("fx:config", event => {
1738 const cfg = event.detail.cfg, elt = cfg.trigger?.target?.closest?.("a[data-fixi-nav], form[data-fixi-nav]");
1739 if (!elt?.matches?.("a[data-fixi-nav], form[data-fixi-nav]")) return;
1740diff --git a/internal/webui/file-messages.js b/internal/webui/file-messages.js
1741index c6d9ba2..aa3e34c 100644
1742--- a/internal/webui/file-messages.js
1743+++ b/internal/webui/file-messages.js
1744@@ -7,7 +7,12 @@
1745
1746 function api() {
1747 const signer = root.NostrSigner;
1748- if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {
1749+ if (
1750+ !signer ||
1751+ !signer.nip44 ||
1752+ !signer.finalizeEvent ||
1753+ !signer.generateSecretKey
1754+ ) {
1755 throw new Error("NIP-44 support is unavailable");
1756 }
1757 return signer;
1758@@ -16,7 +21,11 @@
1759 function relayURL(value) {
1760 if (typeof value !== "string" || value.length > 2048) return null;
1761 let url;
1762- try { url = new URL(value); } catch { return null; }
1763+ try {
1764+ url = new URL(value);
1765+ } catch {
1766+ return null;
1767+ }
1768 if (url.protocol !== "wss:" && url.protocol !== "ws:") return null;
1769 if (url.username || url.password || url.hash) return null;
1770 return url.toString().replace(/\/$/, "");
1771@@ -24,15 +33,27 @@
1772
1773 function relayList(event, pubkey) {
1774 if (Array.isArray(event)) {
1775- event = event.filter((candidate) => candidate?.kind === 10050 && candidate.pubkey === pubkey)
1776+ event = event
1777+ .filter(
1778+ (candidate) =>
1779+ candidate?.kind === 10050 && candidate.pubkey === pubkey,
1780+ )
1781 .sort((a, b) => b.created_at - a.created_at)[0];
1782 }
1783 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {
1784 throw new Error("recipient has no valid kind 10050 relay list");
1785 }
1786 const signer = api();
1787- if (!signer.verifyEvent(event)) throw new Error("invalid kind 10050 relay list signature");
1788- const relays = [...new Set(event.tags.filter((tag) => tag[0] === "relay").map((tag) => relayURL(tag[1])).filter(Boolean))];
1789+ if (!signer.verifyEvent(event))
1790+ throw new Error("invalid kind 10050 relay list signature");
1791+ const relays = [
1792+ ...new Set(
1793+ event.tags
1794+ .filter((tag) => tag[0] === "relay")
1795+ .map((tag) => relayURL(tag[1]))
1796+ .filter(Boolean),
1797+ ),
1798+ ];
1799 if (!relays.length) throw new Error("kind 10050 relay list is empty");
1800 return relays.slice(0, MAX_RELAYS);
1801 }
1802@@ -41,7 +62,8 @@
1803 const text = String(value || "").trim();
1804 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();
1805 const decoded = api().decodeNpub?.(text);
1806- if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();
1807+ if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded))
1808+ return decoded.toLowerCase();
1809 throw new Error("public key must be a 64 character hex key or npub");
1810 }
1811
1812@@ -49,10 +71,15 @@
1813 const ms = Number.isFinite(timeout) && timeout > 0 ? timeout : 10000;
1814 let timer;
1815 try {
1816- return await Promise.race([Promise.resolve(value), new Promise((_, reject) => {
1817- timer = setTimeout(() => reject(new Error(label + " timed out")), ms);
1818- })]);
1819- } finally { clearTimeout(timer); }
1820+ return await Promise.race([
1821+ Promise.resolve(value),
1822+ new Promise((_, reject) => {
1823+ timer = setTimeout(() => reject(new Error(label + " timed out")), ms);
1824+ }),
1825+ ]);
1826+ } finally {
1827+ clearTimeout(timer);
1828+ }
1829 }
1830
1831 function sent(result) {
1832@@ -63,70 +90,166 @@
1833 }
1834
1835 function past(now) {
1836- return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));
1837+ return Math.max(
1838+ 0,
1839+ Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)),
1840+ );
1841 }
1842
1843 async function publicKey(signer, getPublicKey) {
1844- const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();
1845- if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw new Error("invalid signer public key");
1846+ const key = getPublicKey
1847+ ? await getPublicKey()
1848+ : await signer.getPublicKey();
1849+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key))
1850+ throw new Error("invalid signer public key");
1851 return key.toLowerCase();
1852 }
1853
1854 async function queryRelayList(pubkey, options) {
1855- if (!root.tiny?.signedFetch) throw new Error("signed local query is unavailable");
1856- const response = await root.tiny.signedFetch("/query", "POST", JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]), { contentType: "application/json" });
1857+ if (!root.tiny?.signedFetch)
1858+ throw new Error("signed local query is unavailable");
1859+ const response = await root.tiny.signedFetch(
1860+ "/query",
1861+ "POST",
1862+ JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]),
1863+ { contentType: "application/json" },
1864+ );
1865 return response.json();
1866 }
1867
1868 function publishEvent(event, relays, options) {
1869 const signer = options.signer || root.tinySigner || root.nostr;
1870 const WebSocketImpl = options.WebSocket || root.WebSocket;
1871- if (typeof WebSocketImpl !== "function") throw new Error("WebSocket support is unavailable");
1872- const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;
1873- return Promise.all(relays.map((url) => new Promise((resolve) => {
1874- let socket;
1875- let timer;
1876- let settled = false;
1877- let authID = "";
1878- let authAttempts = 0;
1879- let eventSent = false;
1880- const finish = (ok) => { if (settled) return; settled = true; clearTimeout(timer); try { socket?.close(); } catch {} resolve(ok); };
1881- const sendEvent = () => { if (!settled && !eventSent) { eventSent = true; socket.send(JSON.stringify(["EVENT", event])); } };
1882- try {
1883- socket = new WebSocketImpl(url);
1884- timer = setTimeout(() => finish(false), timeout);
1885- socket.onopen = sendEvent;
1886- socket.onmessage = async (message) => {
1887- let frame;
1888- try { frame = JSON.parse(typeof message.data === "string" ? message.data : ""); } catch { return; }
1889- if (settled || !Array.isArray(frame)) return;
1890- if (frame[0] === "OK" && frame[1] === authID) {
1891- if (frame[2] === true) { eventSent = false; sendEvent(); } else finish(false);
1892- return;
1893- }
1894- if (frame[0] === "OK" && frame[1] === event.id) {
1895- if (frame[2] !== true && String(frame[3] || "").toLowerCase().startsWith("auth-required")) { eventSent = false; return; }
1896- finish(frame[2] === true); return;
1897- }
1898- if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;
1899- if (typeof frame[1] !== "string" || frame[1].length > 4096) { finish(false); return; }
1900- if (authAttempts++ >= 1) { finish(false); return; }
1901- try {
1902- const auth = await signer.signEvent({ kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [["relay", url], ["challenge", frame[1]], ["method", "AUTH"]], content: "" });
1903- 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])); }
1904- else finish(false);
1905- } catch { finish(false); }
1906- };
1907- socket.onerror = () => finish(false);
1908- socket.onclose = () => finish(false);
1909- } catch { finish(false); }
1910- })));
1911+ if (typeof WebSocketImpl !== "function")
1912+ throw new Error("WebSocket support is unavailable");
1913+ const timeout =
1914+ Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
1915+ ? options.timeoutMs
1916+ : 10000;
1917+ return Promise.all(
1918+ relays.map(
1919+ (url) =>
1920+ new Promise((resolve) => {
1921+ let socket;
1922+ let timer;
1923+ let settled = false;
1924+ let authID = "";
1925+ let authAttempts = 0;
1926+ let eventSent = false;
1927+ const finish = (ok) => {
1928+ if (settled) return;
1929+ settled = true;
1930+ clearTimeout(timer);
1931+ try {
1932+ socket?.close();
1933+ } catch {}
1934+ resolve(ok);
1935+ };
1936+ const sendEvent = () => {
1937+ if (!settled && !eventSent) {
1938+ eventSent = true;
1939+ socket.send(JSON.stringify(["EVENT", event]));
1940+ }
1941+ };
1942+ try {
1943+ socket = new WebSocketImpl(url);
1944+ timer = setTimeout(() => finish(false), timeout);
1945+ socket.onopen = sendEvent;
1946+ socket.onmessage = async (message) => {
1947+ let frame;
1948+ try {
1949+ frame = JSON.parse(
1950+ typeof message.data === "string" ? message.data : "",
1951+ );
1952+ } catch {
1953+ return;
1954+ }
1955+ if (settled || !Array.isArray(frame)) return;
1956+ if (frame[0] === "OK" && frame[1] === authID) {
1957+ if (frame[2] === true) {
1958+ eventSent = false;
1959+ sendEvent();
1960+ } else finish(false);
1961+ return;
1962+ }
1963+ if (frame[0] === "OK" && frame[1] === event.id) {
1964+ if (
1965+ frame[2] !== true &&
1966+ String(frame[3] || "")
1967+ .toLowerCase()
1968+ .startsWith("auth-required")
1969+ ) {
1970+ eventSent = false;
1971+ return;
1972+ }
1973+ finish(frame[2] === true);
1974+ return;
1975+ }
1976+ if (
1977+ frame[0] !== "AUTH" ||
1978+ typeof signer?.signEvent !== "function"
1979+ )
1980+ return;
1981+ if (typeof frame[1] !== "string" || frame[1].length > 4096) {
1982+ finish(false);
1983+ return;
1984+ }
1985+ if (authAttempts++ >= 1) {
1986+ finish(false);
1987+ return;
1988+ }
1989+ try {
1990+ const auth = await signer.signEvent({
1991+ kind: 22242,
1992+ created_at: Math.floor(Date.now() / 1000),
1993+ tags: [
1994+ ["relay", url],
1995+ ["challenge", frame[1]],
1996+ ["method", "AUTH"],
1997+ ],
1998+ content: "",
1999+ });
2000+ if (
2001+ auth &&
2002+ !settled &&
2003+ auth.kind === 22242 &&
2004+ auth.content === "" &&
2005+ auth.tags.length === 3 &&
2006+ auth.tags[0][0] === "relay" &&
2007+ auth.tags[0][1] === url &&
2008+ auth.tags[1][0] === "challenge" &&
2009+ auth.tags[1][1] === frame[1] &&
2010+ auth.tags[2][0] === "method" &&
2011+ auth.tags[2][1] === "AUTH" &&
2012+ api().verifyEvent(auth) &&
2013+ auth.pubkey === (await publicKey(signer))
2014+ ) {
2015+ authID = auth.id;
2016+ socket.send(JSON.stringify(["AUTH", auth]));
2017+ } else finish(false);
2018+ } catch {
2019+ finish(false);
2020+ }
2021+ };
2022+ socket.onerror = () => finish(false);
2023+ socket.onclose = () => finish(false);
2024+ } catch {
2025+ finish(false);
2026+ }
2027+ }),
2028+ ),
2029+ );
2030 }
2031
2032 function unsignedRumor(input, sender, now) {
2033- if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw new Error("fileURL must be an HTTP(S) URL");
2034- if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw new Error("ciphertextHash must be SHA-256 hex");
2035- if (!input.key || !input.nonce) throw new Error("encrypted file messages require a decryption key and nonce");
2036+ if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL))
2037+ throw new Error("fileURL must be an HTTP(S) URL");
2038+ if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || ""))
2039+ throw new Error("ciphertextHash must be SHA-256 hex");
2040+ if (!input.key || !input.nonce)
2041+ throw new Error(
2042+ "encrypted file messages require a decryption key and nonce",
2043+ );
2044 const tags = [["p", input.recipient]];
2045 if (input.mimeType) tags.push(["file-type", input.mimeType]);
2046 tags.push(["encryption-algorithm", "aes-gcm"]);
2047@@ -134,27 +257,51 @@
2048 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);
2049 tags.push(["x", input.ciphertextHash.toLowerCase()]);
2050 if (input.plaintextHash) {
2051- if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw new Error("plaintextHash must be SHA-256 hex");
2052+ if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash))
2053+ throw new Error("plaintextHash must be SHA-256 hex");
2054 tags.push(["ox", input.plaintextHash.toLowerCase()]);
2055 }
2056 if (input.size != null) tags.push(["size", String(input.size)]);
2057- const rumor = { pubkey: sender, created_at: Math.floor(now), kind: 15, tags, content: input.fileURL };
2058+ const rumor = {
2059+ pubkey: sender,
2060+ created_at: Math.floor(now),
2061+ kind: 15,
2062+ tags,
2063+ content: input.fileURL,
2064+ };
2065 rumor.id = api().getEventHash(rumor);
2066 return rumor;
2067 }
2068
2069 async function encryptFor(signer, recipient, plaintext) {
2070- if (typeof signer.nip44Encrypt === "function") return signer.nip44Encrypt(recipient, plaintext);
2071- if (signer.nip44?.encrypt) return signer.nip44.encrypt(recipient, plaintext);
2072+ if (typeof signer.nip44Encrypt === "function")
2073+ return signer.nip44Encrypt(recipient, plaintext);
2074+ if (signer.nip44?.encrypt)
2075+ return signer.nip44.encrypt(recipient, plaintext);
2076 throw new Error("signer has no NIP-44 encrypt method");
2077 }
2078
2079 async function seal(rumor, recipient, signer, now) {
2080 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));
2081- const unsigned = { kind: 13, tags: [], content, created_at: past(now), pubkey: rumor.pubkey };
2082+ const unsigned = {
2083+ kind: 13,
2084+ tags: [],
2085+ content,
2086+ created_at: past(now),
2087+ pubkey: rumor.pubkey,
2088+ };
2089 const event = await signer.signEvent(unsigned);
2090- 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");
2091- if (!api().verifyEvent(event)) throw new Error("signer returned an invalid seal signature");
2092+ if (
2093+ !event ||
2094+ event.kind !== 13 ||
2095+ event.pubkey !== rumor.pubkey ||
2096+ event.tags.length ||
2097+ event.content !== unsigned.content ||
2098+ event.created_at !== unsigned.created_at
2099+ )
2100+ throw new Error("signer returned an invalid NIP-17 seal");
2101+ if (!api().verifyEvent(event))
2102+ throw new Error("signer returned an invalid seal signature");
2103 return event;
2104 }
2105
2106@@ -164,46 +311,89 @@
2107 const pubkey = signer.getPublicKey(secret);
2108 const key = signer.nip44.getConversationKey(secret, recipient);
2109 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);
2110- const event = signer.finalizeEvent({ kind: 1059, tags: [["p", recipient]], content, created_at: past(now) }, secret);
2111- if (!signer.verifyEvent(event)) throw new Error("failed to verify gift wrap");
2112+ const event = signer.finalizeEvent(
2113+ { kind: 1059, tags: [["p", recipient]], content, created_at: past(now) },
2114+ secret,
2115+ );
2116+ if (!signer.verifyEvent(event))
2117+ throw new Error("failed to verify gift wrap");
2118 return { event, secret, pubkey };
2119 }
2120
2121 async function build(input, options = {}) {
2122 const signer = options.signer || root.tinySigner || root.nostr;
2123- if (!signer || typeof signer.signEvent !== "function" || (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)) throw new Error("a NIP-44 signer is required");
2124+ if (
2125+ !signer ||
2126+ typeof signer.signEvent !== "function" ||
2127+ (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)
2128+ )
2129+ throw new Error("a NIP-44 signer is required");
2130 const recipient = normalizePubkey(input.recipient);
2131 const sender = await publicKey(signer, options.getPublicKey);
2132- const now = (options.now || Date.now() / 1000);
2133+ const now = options.now || Date.now() / 1000;
2134 const rumor = unsignedRumor({ ...input, recipient }, sender, now);
2135 const receiverSeal = await seal(rumor, recipient, signer, now);
2136 const senderSeal = await seal(rumor, sender, signer, now);
2137 const receiver = wrap(receiverSeal, recipient, now).event;
2138 const own = wrap(senderSeal, sender, now).event;
2139- return { rumor, events: [{ recipient, event: receiver }, { recipient: sender, event: own }] };
2140+ return {
2141+ rumor,
2142+ events: [
2143+ { recipient, event: receiver },
2144+ { recipient: sender, event: own },
2145+ ],
2146+ };
2147 }
2148
2149 async function share(input, options = {}) {
2150 const signer = options.signer || root.tinySigner || root.nostr;
2151- const query = options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));
2152- const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));
2153- if (typeof query !== "function" || typeof publish !== "function") throw new Error("private NIP-17 delivery is unavailable");
2154+ const query =
2155+ options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));
2156+ const publish =
2157+ options.publish ||
2158+ ((event, relays) => publishEvent(event, relays, options));
2159+ if (typeof query !== "function" || typeof publish !== "function")
2160+ throw new Error("private NIP-17 delivery is unavailable");
2161 const sender = await publicKey(signer, options.getPublicKey);
2162 const recipient = normalizePubkey(input.recipient);
2163 const timeout = options.timeoutMs;
2164- const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);
2165- const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);
2166+ const receiverList = relayList(
2167+ await bounded(query(recipient), timeout, "recipient relay lookup"),
2168+ recipient,
2169+ );
2170+ const senderList = relayList(
2171+ await bounded(query(sender), timeout, "sender relay lookup"),
2172+ sender,
2173+ );
2174 const built = await build(input, options);
2175- if (built.rumor.pubkey !== sender) throw new Error("signer changed during file sharing; try again");
2176+ if (built.rumor.pubkey !== sender)
2177+ throw new Error("signer changed during file sharing; try again");
2178 const deliveries = [];
2179 for (const target of built.events) {
2180 const relays = target.recipient === recipient ? receiverList : senderList;
2181- const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");
2182- if (!sent(delivery)) throw new Error("NIP-17 delivery failed on every inbox relay");
2183+ const delivery = await bounded(
2184+ publish(target.event, relays),
2185+ timeout,
2186+ "NIP-17 delivery",
2187+ );
2188+ if (!sent(delivery))
2189+ throw new Error("NIP-17 delivery failed on every inbox relay");
2190 deliveries.push(delivery);
2191 }
2192- return { ...built, relays: { recipient: receiverList, sender: senderList }, deliveries };
2193+ return {
2194+ ...built,
2195+ relays: { recipient: receiverList, sender: senderList },
2196+ deliveries,
2197+ };
2198 }
2199
2200- root.TinyFileMessages = { build, share, relayList, queryRelayList, publishEvent };
2201+ const apiObject = Object.freeze({
2202+ build,
2203+ share,
2204+ relayList,
2205+ queryRelayList,
2206+ publishEvent,
2207+ });
2208+ root.tiny = root.tiny || {};
2209+ root.tiny.files = { ...root.tiny.files, messages: apiObject };
2210 })(globalThis);
2211diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
2212index 17b9c1c..611cfb1 100644
2213--- a/internal/webui/file-workspace.js
2214+++ b/internal/webui/file-workspace.js
2215@@ -8,100 +8,184 @@
2216 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;
2217 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;
2218 const directoryType = "application/vnd.blossom.directory+msgpack";
2219- const codec = () => globalThis.TinyBlossomManifests;
2220- const hex = value => codec().hex(value);
2221- const fromHex = value => {
2222- if (!/^[0-9a-f]{64}$/.test(value || "")) throw Error("Invalid hash or encryption key.");
2223- return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));
2224+ const codec = () =>
2225+ globalThis.tiny?.blossom?.manifests;
2226+ const encryption = () =>
2227+ globalThis.tiny?.blossom?.encryption;
2228+ const upload = () =>
2229+ globalThis.tiny?.blossom?.upload;
2230+ const hex = (value) => globalThis.tiny.util.hex(value);
2231+ const fromHex = (value) => {
2232+ if (!/^[0-9a-f]{64}$/.test(value || ""))
2233+ throw Error("Invalid hash or encryption key.");
2234+ return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));
2235 };
2236 const element = (tag, text) => {
2237 const node = document.createElement(tag);
2238 if (text !== undefined) node.textContent = text;
2239 return node;
2240 };
2241- const checkAbort = signal => {
2242- if (signal?.aborted) throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});
2243+ const checkAbort = (signal) => {
2244+ if (signal?.aborted)
2245+ throw Object.assign(
2246+ Error("Upload canceled. Retry while this page stays open."),
2247+ { name: "AbortError" },
2248+ );
2249 };
2250- const validName = name => typeof name === "string" && name.length > 0 && name.isWellFormed() && ![".", ".."].includes(name) && !/[\/\u0000]/.test(name) && new TextEncoder().encode(name).length <= 1024;
2251- const folderNode = () => ({directories: new Map(), files: new Map()});
2252+ const validName = (name) =>
2253+ typeof name === "string" &&
2254+ name.length > 0 &&
2255+ name.isWellFormed() &&
2256+ ![".", ".."].includes(name) &&
2257+ !/[\/\u0000]/.test(name) &&
2258+ new TextEncoder().encode(name).length <= 1024;
2259+ const folderNode = () => ({ directories: new Map(), files: new Map() });
2260
2261 function selectionTree(files) {
2262- if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");
2263+ if (!files.length || files.length > MAX_FILES)
2264+ throw Error("Choose a folder with 1 to 10,000 files.");
2265 const root = folderNode();
2266 let total = 0;
2267- const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));
2268- const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);
2269+ const paths = files.map((file) =>
2270+ (file.webkitRelativePath || file.name).split("/"),
2271+ );
2272+ const commonRoot = paths.every(
2273+ (path) => path.length > 1 && path[0] === paths[0][0],
2274+ );
2275 for (let index = 0; index < files.length; index++) {
2276 const file = files[index];
2277 const path = commonRoot ? paths[index].slice(1) : paths[index];
2278- if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");
2279- 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.");
2280+ if (path.length > MAX_DEPTH || path.some((name) => !validName(name)))
2281+ throw Error("Invalid folder path.");
2282+ if (
2283+ !Number.isSafeInteger(file.size) ||
2284+ file.size < 0 ||
2285+ file.size > MAX_FILE_BYTES
2286+ )
2287+ throw Error("Files must be at most 256 MiB in the browser workspace.");
2288 total += file.size;
2289- if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");
2290+ if (total > MAX_FOLDER_BYTES)
2291+ throw Error("The selected folder exceeds 1 GiB.");
2292 let directory = root;
2293 for (const name of path.slice(0, -1)) {
2294- if (directory.files.has(name)) throw Error("A file and folder use the same name.");
2295- if (!directory.directories.has(name)) directory.directories.set(name, folderNode());
2296+ if (directory.files.has(name))
2297+ throw Error("A file and folder use the same name.");
2298+ if (!directory.directories.has(name))
2299+ directory.directories.set(name, folderNode());
2300 directory = directory.directories.get(name);
2301 }
2302 const name = path.at(-1);
2303- if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");
2304+ if (directory.files.has(name) || directory.directories.has(name))
2305+ throw Error("Duplicate folder entry.");
2306 directory.files.set(name, file);
2307 }
2308- return {root, name: commonRoot ? paths[0][0] : "folder"};
2309+ return { root, name: commonRoot ? paths[0][0] : "folder" };
2310 }
2311
2312 async function storeEncrypted(plaintext, type, signal) {
2313 checkAbort(signal);
2314- const encrypted = await TinyBlossomEncryption.encryptCHK(plaintext);
2315+ const encrypted = await encryption().encryptCHK(plaintext);
2316 checkAbort(signal);
2317 const url = new URL(tiny.localPath("/"), location.href).href;
2318- const result = await TinyBlossomUpload.upload(encrypted.ciphertext, {
2319- url, hash: encrypted.hash, type, signal,
2320- authorize: (target, method, body) => tiny.authorization(target, method, body)
2321+ const result = await upload().upload(encrypted.ciphertext, {
2322+ url,
2323+ hash: encrypted.hash,
2324+ type,
2325+ signal,
2326+ authorize: (target, method, body) =>
2327+ tiny.authorization(target, method, body),
2328 });
2329- if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length) throw Error("Relay returned an unexpected blob descriptor.");
2330- return {hash: encrypted.hash, key: fromHex(encrypted.key)};
2331+ if (
2332+ result.descriptor.sha256 !== encrypted.hash ||
2333+ result.descriptor.size !== encrypted.ciphertext.length
2334+ )
2335+ throw Error("Relay returned an unexpected blob descriptor.");
2336+ return { hash: encrypted.hash, key: fromHex(encrypted.key) };
2337 }
2338
2339- const manifestStore = signal => async ({bytes, node}) => storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);
2340- const rootReference = details => {
2341+ const manifestStore =
2342+ (signal) =>
2343+ async ({ bytes, node }) =>
2344+ storeEncrypted(
2345+ bytes,
2346+ node.t === 2 || node.t === 3
2347+ ? directoryType
2348+ : "application/octet-stream",
2349+ signal,
2350+ );
2351+ const rootReference = (details) => {
2352 const stored = details.manifests.at(-1);
2353- return {hash: stored.hash, key: stored.key, type: details.root.t, size: details.root.l.reduce((sum, link) => sum + link.s, 0)};
2354+ return {
2355+ hash: stored.hash,
2356+ key: stored.key,
2357+ type: details.root.t,
2358+ size: details.root.l.reduce((sum, link) => sum + link.s, 0),
2359+ };
2360 };
2361
2362 async function storeFile(file, signal, progress) {
2363- if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");
2364+ if (file.size > MAX_FILE_BYTES)
2365+ throw Error("Files must be at most 256 MiB in the browser workspace.");
2366 const chunks = [];
2367- for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {
2368+ for (
2369+ let offset = 0;
2370+ offset < file.size || offset === 0;
2371+ offset += codec().CHUNK_SIZE
2372+ ) {
2373 checkAbort(signal);
2374- const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());
2375- const stored = await storeEncrypted(plain, "application/octet-stream", signal);
2376- chunks.push({...stored, size: plain.length});
2377+ const plain = new Uint8Array(
2378+ await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer(),
2379+ );
2380+ const stored = await storeEncrypted(
2381+ plain,
2382+ "application/octet-stream",
2383+ signal,
2384+ );
2385+ chunks.push({ ...stored, size: plain.length });
2386 progress?.(plain.length);
2387 if (file.size === 0) break;
2388 }
2389- if (chunks.length === 1) return {...chunks[0], type: 0};
2390- return rootReference(await codec().buildFile(chunks, {store: manifestStore(signal), returnDetails: true}));
2391+ if (chunks.length === 1) return { ...chunks[0], type: 0 };
2392+ return rootReference(
2393+ await codec().buildFile(chunks, {
2394+ store: manifestStore(signal),
2395+ returnDetails: true,
2396+ }),
2397+ );
2398 }
2399
2400 async function storeDirectory(directory, signal, progress) {
2401 const entries = [];
2402 for (const [name, file] of directory.files) {
2403 const stored = await storeFile(file, signal, progress);
2404- entries.push({...stored, name, metadata: {type: file.type || "application/octet-stream"}});
2405+ entries.push({
2406+ ...stored,
2407+ name,
2408+ metadata: { type: file.type || "application/octet-stream" },
2409+ });
2410 }
2411 for (const [name, child] of directory.directories) {
2412 const stored = await storeDirectory(child, signal, progress);
2413- entries.push({...stored, name});
2414+ entries.push({ ...stored, name });
2415 }
2416- return rootReference(await codec().buildDirectory(entries, {store: manifestStore(signal), returnDetails: true}));
2417+ return rootReference(
2418+ await codec().buildDirectory(entries, {
2419+ store: manifestStore(signal),
2420+ returnDetails: true,
2421+ }),
2422+ );
2423 }
2424
2425 const shareURL = (reference, name, type) => {
2426 const url = new URL(tiny.localPath("/file"), location.href);
2427 url.searchParams.set("hash", reference.hash);
2428- url.hash = new URLSearchParams({manifest: reference.hash, key: hex(reference.key), node: String(reference.type), name, type: type || "application/octet-stream"}).toString();
2429+ url.hash = new URLSearchParams({
2430+ manifest: reference.hash,
2431+ key: hex(reference.key),
2432+ node: String(reference.type),
2433+ name,
2434+ type: type || "application/octet-stream",
2435+ }).toString();
2436 return url.href;
2437 };
2438
2439@@ -109,16 +193,24 @@
2440 connectedCallback() {
2441 if (this.bound) return;
2442 this.bound = true;
2443- 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>';
2444+ this.innerHTML =
2445+ '<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>';
2446 this.out = this.querySelector("output");
2447- for (const form of this.querySelectorAll("form")) form.addEventListener("submit", event => {
2448- event.preventDefault();
2449- this.run(form).catch(error => this.say(error.message, true));
2450- });
2451- this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());
2452- this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(error => this.say(error.message, true)));
2453+ for (const form of this.querySelectorAll("form"))
2454+ form.addEventListener("submit", (event) => {
2455+ event.preventDefault();
2456+ this.run(form).catch((error) => this.say(error.message, true));
2457+ });
2458+ this.querySelector("[data-cancel]").addEventListener("click", () =>
2459+ this.controller?.abort(),
2460+ );
2461+ this.querySelector("[data-retry]").addEventListener("click", () =>
2462+ this.run().catch((error) => this.say(error.message, true)),
2463+ );
2464+ }
2465+ disconnectedCallback() {
2466+ this.controller?.abort();
2467 }
2468- disconnectedCallback() { this.controller?.abort(); }
2469 say(message, error = false) {
2470 this.out.textContent = message;
2471 if (error) this.out.dataset.error = "";
2472@@ -129,9 +221,10 @@
2473 if (form) {
2474 const folder = form.elements.namedItem("folder");
2475 const input = folder || form.elements.namedItem("file");
2476- this.selection = {files: [...input.files], folder: Boolean(folder)};
2477+ this.selection = { files: [...input.files], folder: Boolean(folder) };
2478 }
2479- if (!this.selection?.files.length) throw Error("Choose a file or folder first.");
2480+ if (!this.selection?.files.length)
2481+ throw Error("Choose a file or folder first.");
2482 this.busy = true;
2483 this.controller = new AbortController();
2484 this.querySelector("[data-cancel]").disabled = false;
2485@@ -140,23 +233,38 @@
2486 try {
2487 let reference, name, type;
2488 let sent = 0;
2489- const progress = size => { sent += size; this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…"); };
2490+ const progress = (size) => {
2491+ sent += size;
2492+ this.say(
2493+ "Stored " +
2494+ sent.toLocaleString() +
2495+ " bytes. Encrypting the remaining content…",
2496+ );
2497+ };
2498 this.say("Encrypting selected content…");
2499 if (this.selection.folder) {
2500 const selected = selectionTree(this.selection.files);
2501 name = selected.name;
2502- reference = await storeDirectory(selected.root, this.controller.signal, progress);
2503+ reference = await storeDirectory(
2504+ selected.root,
2505+ this.controller.signal,
2506+ progress,
2507+ );
2508 } else {
2509 const file = this.selection.files[0];
2510- name = file.name; type = file.type;
2511+ name = file.name;
2512+ type = file.type;
2513 reference = await storeFile(file, this.controller.signal, progress);
2514 }
2515 checkAbort(this.controller.signal);
2516 const url = shareURL(reference, name, type);
2517 this.querySelector("[data-tree-share]").value = url;
2518 const open = this.querySelector("[data-open]");
2519- open.href = url; open.hidden = false;
2520- this.say("Stored. Keep the complete share link to decrypt and browse this content.");
2521+ open.href = url;
2522+ open.hidden = false;
2523+ this.say(
2524+ "Stored. Keep the complete share link to decrypt and browse this content.",
2525+ );
2526 completed = true;
2527 return url;
2528 } finally {
2529@@ -170,79 +278,134 @@
2530
2531 async function fetchBlob(hash, signal) {
2532 fromHex(hash);
2533- const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {signal});
2534+ const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {
2535+ signal,
2536+ });
2537 if (!response.ok) throw Error("Download failed (" + response.status + ").");
2538- if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");
2539+ if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)
2540+ throw Error("Linked blob exceeds the browser fetch limit.");
2541 const reader = response.body?.getReader();
2542 if (!reader) {
2543 const bytes = new Uint8Array(await response.arrayBuffer());
2544- if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");
2545+ if (bytes.length > MAX_FETCH_BYTES)
2546+ throw Error("Linked blob exceeds the browser fetch limit.");
2547 return bytes;
2548 }
2549 const parts = [];
2550 let size = 0;
2551 try {
2552 for (;;) {
2553- const {value, done} = await reader.read();
2554+ const { value, done } = await reader.read();
2555 if (done) break;
2556 size += value.length;
2557- if (size > MAX_FETCH_BYTES) { await reader.cancel(); throw Error("Linked blob exceeds the browser fetch limit."); }
2558+ if (size > MAX_FETCH_BYTES) {
2559+ await reader.cancel();
2560+ throw Error("Linked blob exceeds the browser fetch limit.");
2561+ }
2562 parts.push(value);
2563 }
2564- } finally { reader.releaseLock(); }
2565+ } finally {
2566+ reader.releaseLock();
2567+ }
2568 const bytes = new Uint8Array(size);
2569 let offset = 0;
2570- for (const part of parts) { bytes.set(part, offset); offset += part.length; }
2571+ for (const part of parts) {
2572+ bytes.set(part, offset);
2573+ offset += part.length;
2574+ }
2575 return bytes;
2576 }
2577
2578 async function plaintext(reference, signal) {
2579 const hash = hex(reference.h);
2580 const raw = await fetchBlob(hash, signal);
2581- if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");
2582- return reference.k ? TinyBlossomEncryption.decryptCHK(raw, hex(reference.k), hash) : raw;
2583+ if (hex(await codec().sha256(raw)) !== hash)
2584+ throw Error("Blob hash mismatch.");
2585+ return reference.k
2586+ ? encryption().decryptCHK(raw, hex(reference.k), hash)
2587+ : raw;
2588 }
2589- const decrypt = (raw, link) => TinyBlossomEncryption.decryptCHK(raw, hex(link.k), hex(link.h));
2590+ const decrypt = (raw, link) =>
2591+ encryption().decryptCHK(raw, hex(link.k), hex(link.h));
2592
2593 class Root extends HTMLElement {
2594 connectedCallback() {
2595 if (this.bound) return;
2596 this.bound = true;
2597 const params = new URLSearchParams(location.hash.slice(1));
2598- if (!params.has("manifest") && this.getAttribute("type") !== directoryType) { this.hidden = true; return; }
2599+ if (
2600+ !params.has("manifest") &&
2601+ this.getAttribute("type") !== directoryType
2602+ ) {
2603+ this.hidden = true;
2604+ return;
2605+ }
2606 this.hidden = false;
2607- this.load().catch(error => this.fail(error.message));
2608+ this.load().catch((error) => this.fail(error.message));
2609 }
2610 disconnectedCallback() {
2611 this.controller?.abort();
2612 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
2613 }
2614+ // fail reports an error without discarding a listing that has already
2615+ // rendered, so one failed download leaves the folder browsable.
2616 fail(message) {
2617 const error = element("p", "Content unavailable: " + message);
2618 error.setAttribute("role", "alert");
2619- this.replaceChildren(error);
2620+ this.querySelector("p[role=alert]")?.remove();
2621+ if (this.querySelector("h2")) this.querySelector("h2").after(error);
2622+ else this.replaceChildren(error);
2623 }
2624 async load() {
2625 this.controller?.abort();
2626 this.controller = new AbortController();
2627 const params = new URLSearchParams(location.hash.slice(1));
2628- const reference = {h: fromHex(params.get("manifest") || this.getAttribute("hash"))};
2629+ const reference = {
2630+ h: fromHex(params.get("manifest") || this.getAttribute("hash")),
2631+ };
2632 if (params.has("key")) reference.k = fromHex(params.get("key"));
2633 const type = params.has("node") ? Number(params.get("node")) : 2;
2634- if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");
2635+ if (![0, 1, 2, 3].includes(type))
2636+ throw Error("Unsupported manifest type.");
2637 const data = await plaintext(reference, this.controller.signal);
2638 const heading = element("h2", params.get("name") || "Stored content");
2639 this.replaceChildren(heading);
2640- if (type === 0) { this.offerDownload(data, params.get("name") || "file", params.get("type")); return; }
2641+ if (type === 0) {
2642+ this.offerDownload(
2643+ data,
2644+ params.get("name") || "file",
2645+ params.get("type"),
2646+ );
2647+ return;
2648+ }
2649 const root = codec().decodeManifest(data);
2650- if (root.t !== type) throw Error("Manifest type does not match its reference.");
2651+ if (root.t !== type)
2652+ throw Error("Manifest type does not match its reference.");
2653 if (type === 1) {
2654 const button = element("button", "Download decrypted file");
2655 button.type = "button";
2656- 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)));
2657- this.append(button); return;
2658+ button.addEventListener("click", () =>
2659+ this.download({
2660+ ...reference,
2661+ t: 1,
2662+ s: root.l.reduce((sum, link) => sum + link.s, 0),
2663+ n: params.get("name") || "file",
2664+ m: { type: params.get("type") },
2665+ }).catch((error) => this.fail(error.message)),
2666+ );
2667+ this.append(button);
2668+ return;
2669 }
2670- const entries = await codec().resolveDirectory(root, (hash) => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxEntries: MAX_FILES, maxBytes: 64 * 1024 * 1024});
2671+ const entries = await codec().resolveDirectory(
2672+ root,
2673+ (hash) => fetchBlob(hash, this.controller.signal),
2674+ {
2675+ decrypt,
2676+ maxDepth: MAX_DEPTH,
2677+ maxEntries: MAX_FILES,
2678+ maxBytes: 64 * 1024 * 1024,
2679+ },
2680+ );
2681 const list = element("ul");
2682 if (!entries.length) this.append(element("p", "This folder is empty."));
2683 for (const link of entries) {
2684@@ -251,14 +414,24 @@
2685 const open = element("a", link.n + "/");
2686 const url = new URL(tiny.localPath("/file"), location.href);
2687 url.searchParams.set("hash", hex(link.h));
2688- const fragment = new URLSearchParams({manifest: hex(link.h), node: String(link.t), name: link.n});
2689+ const fragment = new URLSearchParams({
2690+ manifest: hex(link.h),
2691+ node: String(link.t),
2692+ name: link.n,
2693+ });
2694 if (link.k) fragment.set("key", hex(link.k));
2695- url.hash = fragment.toString(); open.href = url.href;
2696+ url.hash = fragment.toString();
2697+ open.href = url.href;
2698 item.append(open);
2699 } else {
2700- const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");
2701+ const button = element(
2702+ "button",
2703+ link.n + " (" + link.s.toLocaleString() + " bytes)",
2704+ );
2705 button.type = "button";
2706- button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));
2707+ button.addEventListener("click", () =>
2708+ this.download(link).catch((error) => this.fail(error.message)),
2709+ );
2710 item.append(button);
2711 }
2712 list.append(item);
2713@@ -270,18 +443,28 @@
2714 let data;
2715 if (link.t === 0) data = await plaintext(link, this.controller.signal);
2716 else if (link.t === 1) {
2717- const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));
2718- data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES});
2719+ const manifest = codec().decodeManifest(
2720+ await plaintext(link, this.controller.signal),
2721+ );
2722+ data = await codec().readFile(
2723+ manifest,
2724+ (hash) => fetchBlob(hash, this.controller.signal),
2725+ { decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES },
2726+ );
2727 } else throw Error("Unsupported file link.");
2728- if (data.length !== link.s) throw Error("File size does not match its directory entry.");
2729+ if (data.length !== link.s)
2730+ throw Error("File size does not match its directory entry.");
2731 this.offerDownload(data, link.n, link.m?.type);
2732 return data;
2733 }
2734 offerDownload(data, name, type) {
2735 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
2736- this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));
2737+ this.downloadURL = URL.createObjectURL(
2738+ new Blob([data], { type: type || "application/octet-stream" }),
2739+ );
2740 const link = element("a", "Save " + name);
2741- link.href = this.downloadURL; link.download = name;
2742+ link.href = this.downloadURL;
2743+ link.download = name;
2744 this.append(link);
2745 link.click();
2746 }
2747diff --git a/internal/webui/page.html b/internal/webui/page.html
2748index 53f950d..1e9ba60 100644
2749--- a/internal/webui/page.html
2750+++ b/internal/webui/page.html
2751@@ -56,6 +56,7 @@
2752 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
2753 </tr></tbody></table>
2754 <footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; {{prompt .Path .Query}}</span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrKey" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="/signin">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span><button id="theme" type="button" aria-label="Switch theme">theme</button></td></tr></table></footer>
2755+<script>{{script "tiny.js"}}</script>
2756 <script>{{script "bridge.js"}}</script>
2757 <script>{{script "blossom-encryption.js"}}</script>
2758 <script>{{script "blossom-manifests.js"}}</script>
2759diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js
2760index 78733f4..c931348 100644
2761--- a/internal/webui/private-services.js
2762+++ b/internal/webui/private-services.js
2763@@ -3,21 +3,32 @@
2764 (() => {
2765 "use strict";
2766
2767- const validURL = value => {
2768+ const validURL = (value) => {
2769 try {
2770 const parsed = new URL(String(value).trim());
2771- return (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&
2772- parsed.hostname !== "" && !parsed.username && !parsed.password &&
2773- parsed.search === "" && parsed.hash === "";
2774- } catch { return false; }
2775+ return (
2776+ (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&
2777+ parsed.hostname !== "" &&
2778+ !parsed.username &&
2779+ !parsed.password &&
2780+ parsed.search === "" &&
2781+ parsed.hash === ""
2782+ );
2783+ } catch {
2784+ return false;
2785+ }
2786 };
2787- const relay = value => {
2788+ const relay = (value) => {
2789 const parsed = new URL(String(value || "").trim());
2790 parsed.hostname = parsed.hostname.toLowerCase();
2791- if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/+$/, "");
2792+ if (parsed.pathname.length > 1)
2793+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
2794 return parsed.toString().replace(/\/$/, "");
2795 };
2796- const mergeRows = (rows, urls) => rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));
2797+ const mergeRows = (rows, urls) =>
2798+ rows
2799+ .filter((row) => !(Array.isArray(row) && row[0] === "g"))
2800+ .concat(urls.map((value) => ["g", value]));
2801 const signer = () => window.tinySigner || window.nostr;
2802 const publicKey = async () => {
2803 const active = signer();
2804@@ -29,39 +40,76 @@
2805 const active = signer();
2806 if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);
2807 if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);
2808- if (window.tiny?.nip44Encrypt) return window.tiny.nip44Encrypt(pubkey, text);
2809+ if (window.tiny?.nip44Encrypt)
2810+ return window.tiny.nip44Encrypt(pubkey, text);
2811 throw Error("The connected signer does not support NIP-44.");
2812 };
2813 const decrypt = async (pubkey, text) => {
2814 const active = signer();
2815 if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);
2816 if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);
2817- if (window.tiny?.nip44Decrypt) return window.tiny.nip44Decrypt(pubkey, text);
2818+ if (window.tiny?.nip44Decrypt)
2819+ return window.tiny.nip44Decrypt(pubkey, text);
2820 throw Error("The connected signer does not support NIP-44.");
2821 };
2822- const readList = async pubkey => {
2823- const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);
2824- const response = await window.tiny.signedFetch("/query", "POST", body, {contentType: "application/json"});
2825- if (!response || response.ok === false) throw Error("Could not read the private relay list.");
2826+ const readList = async (pubkey) => {
2827+ const body = JSON.stringify([
2828+ { kinds: [10318], authors: [pubkey], limit: 1 },
2829+ ]);
2830+ const response = await window.tiny.signedFetch("/query", "POST", body, {
2831+ contentType: "application/json",
2832+ });
2833+ if (!response || response.ok === false)
2834+ throw Error("Could not read the private relay list.");
2835 const rows = await response.json();
2836- if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");
2837+ if (!Array.isArray(rows) || rows.length > 1)
2838+ throw Error("The private relay list response is malformed.");
2839 const event = rows[0];
2840- if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");
2841+ if (rows.length && !acceptEvent(event, pubkey))
2842+ throw Error("The private relay list event is invalid.");
2843 return event;
2844 };
2845 const acceptEvent = (event, expectedPubkey) => {
2846- if (!event || event.kind !== 10318 || event.pubkey !== expectedPubkey || !Array.isArray(event.tags) || typeof event.content !== "string") return false;
2847+ if (
2848+ !event ||
2849+ event.kind !== 10318 ||
2850+ event.pubkey !== expectedPubkey ||
2851+ !Array.isArray(event.tags) ||
2852+ typeof event.content !== "string"
2853+ )
2854+ return false;
2855 if (typeof window.NostrSigner?.verifyEvent !== "function") return false;
2856- try { return window.NostrSigner.verifyEvent(event) === true; } catch { return false; }
2857+ try {
2858+ return window.NostrSigner.verifyEvent(event) === true;
2859+ } catch {
2860+ return false;
2861+ }
2862 };
2863- const publish = async event => {
2864- const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
2865+ const publish = async (event) => {
2866+ const response = await window.tiny.signedFetch(
2867+ "/events",
2868+ "POST",
2869+ JSON.stringify(event),
2870+ { contentType: "application/json" },
2871+ );
2872 const text = await response.text();
2873 let value;
2874- try { value = JSON.parse(text); } catch { value = undefined; }
2875- if (value?.error || value?.accepted === false) throw Error(value.error || value.message || "The relay rejected the event.");
2876+ try {
2877+ value = JSON.parse(text);
2878+ } catch {
2879+ value = undefined;
2880+ }
2881+ if (value?.error || value?.accepted === false)
2882+ throw Error(
2883+ value.error || value.message || "The relay rejected the event.",
2884+ );
2885 };
2886- const wireEvent = content => ({kind: 10318, created_at: Math.floor(Date.now() / 1000), tags: [], content});
2887+ const wireEvent = (content) => ({
2888+ kind: 10318,
2889+ created_at: Math.floor(Date.now() / 1000),
2890+ tags: [],
2891+ content,
2892+ });
2893
2894 class PrivateServices extends HTMLElement {
2895 connectedCallback() {
2896@@ -70,23 +118,33 @@
2897 this.form = this.querySelector("form");
2898 this.lines = this.querySelector("textarea[name=lines]");
2899 this.output = this.querySelector("output");
2900- this.form?.addEventListener("submit", event => {
2901+ this.form?.addEventListener("submit", (event) => {
2902 event.preventDefault();
2903 if (this.busy) return;
2904 this.busy = true;
2905 this.form.querySelector("button").disabled = true;
2906- this.save().catch(error => this.say("Error: " + error.message, true)).finally(() => {
2907- this.busy = false;
2908- this.form.querySelector("button").disabled = false;
2909- });
2910+ this.save()
2911+ .catch((error) => this.say("Error: " + error.message, true))
2912+ .finally(() => {
2913+ this.busy = false;
2914+ this.form.querySelector("button").disabled = false;
2915+ });
2916 });
2917- this.whenSigned(() => this.load().then(() => { this.form.querySelector("button").disabled = false; }).catch(error => this.say("Error: " + error.message, true)));
2918+ this.whenSigned(() =>
2919+ this.load()
2920+ .then(() => {
2921+ this.form.querySelector("button").disabled = false;
2922+ })
2923+ .catch((error) => this.say("Error: " + error.message, true)),
2924+ );
2925 }
2926
2927- whenSigned(fn, tries = 20) {
2928+ // whenSigned runs once a signer is available. The bridge announces a
2929+ // connected or resumed remote signer with the tiny:signer event.
2930+ whenSigned(fn) {
2931 if (signer()?.signEvent || window.nostr?.signEvent) return fn();
2932- if (tries <= 0) { this.say("Connect a signer, then reload this page."); return; }
2933- setTimeout(() => this.whenSigned(fn, tries - 1), 500);
2934+ this.say("Connect a signer to edit this list.");
2935+ document.addEventListener("tiny:signer", () => fn(), { once: true });
2936 }
2937
2938 say(text, error) {
2939@@ -99,17 +157,37 @@
2940 this.say("Reading encrypted list…");
2941 const pubkey = await publicKey();
2942 const event = await readList(pubkey);
2943- if (!event) { this.say("No private relay list published yet."); return; }
2944+ if (!event) {
2945+ this.say("No private relay list published yet.");
2946+ return;
2947+ }
2948 const rows = JSON.parse(await decrypt(pubkey, event.content));
2949- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
2950- const urls = rows.filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1])).map(row => relay(row[1]));
2951+ if (!Array.isArray(rows))
2952+ throw Error("The encrypted list is not a JSON array.");
2953+ const urls = rows
2954+ .filter(
2955+ (row) => Array.isArray(row) && row[0] === "g" && validURL(row[1]),
2956+ )
2957+ .map((row) => relay(row[1]));
2958 this.lines.value = urls.join("\n");
2959- this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");
2960+ this.say(
2961+ "Loaded " +
2962+ urls.length +
2963+ " private relay" +
2964+ (urls.length === 1 ? "" : "s") +
2965+ ".",
2966+ );
2967 }
2968
2969 async save() {
2970- const rawURLs = this.lines.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean);
2971- if (rawURLs.some(value => !validURL(value))) throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");
2972+ const rawURLs = this.lines.value
2973+ .split(/\r?\n/)
2974+ .map((value) => value.trim())
2975+ .filter(Boolean);
2976+ if (rawURLs.some((value) => !validURL(value)))
2977+ throw Error(
2978+ "Use ws:// or wss:// URLs without credentials, queries or fragments.",
2979+ );
2980 const urls = rawURLs.map(relay);
2981 const unique = [...new Set(urls)];
2982 const pubkey = await publicKey();
2983@@ -117,19 +195,42 @@
2984 let rows = [];
2985 if (existing) {
2986 rows = JSON.parse(await decrypt(pubkey, existing.content));
2987- if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
2988+ if (!Array.isArray(rows))
2989+ throw Error("The encrypted list is not a JSON array.");
2990 }
2991 rows = mergeRows(rows, unique);
2992 const content = await encrypt(pubkey, JSON.stringify(rows));
2993 const active = signer();
2994 if (!active?.signEvent) throw Error("Connect a signer first.");
2995 const event = await active.signEvent(wireEvent(content));
2996- if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0) throw Error("The signer returned an invalid private relay list event.");
2997+ if (
2998+ !acceptEvent(event, pubkey) ||
2999+ event.content !== content ||
3000+ event.tags.length !== 0
3001+ )
3002+ throw Error("The signer returned an invalid private relay list event.");
3003 await publish(event);
3004- this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");
3005+ this.say(
3006+ "Published " +
3007+ unique.length +
3008+ " private relay" +
3009+ (unique.length === 1 ? "" : "s") +
3010+ ".",
3011+ );
3012 }
3013 }
3014
3015- window.PrivateServices = {validURL, canonicalURL: relay, mergeRows, encrypt, decrypt, wireEvent, acceptEvent, readList};
3016+ const api = Object.freeze({
3017+ validURL,
3018+ canonicalURL: relay,
3019+ mergeRows,
3020+ encrypt,
3021+ decrypt,
3022+ wireEvent,
3023+ acceptEvent,
3024+ readList,
3025+ });
3026+ window.tiny = window.tiny || {};
3027+ window.tiny.files = { ...window.tiny.files, privateServices: api };
3028 customElements.define("private-services", PrivateServices);
3029 })();
3030diff --git a/internal/webui/templates.go b/internal/webui/templates.go
3031index 55fe770..846a1f5 100644
3032--- a/internal/webui/templates.go
3033+++ b/internal/webui/templates.go
3034@@ -29,6 +29,9 @@ var styleCSS string
3035 //go:embed bridge.js
3036 var bridgeJS string
3037
3038+//go:embed tiny.js
3039+var tinyJS string
3040+
3041 //go:embed components.js
3042 var componentsJS string
3043
3044@@ -128,7 +131,7 @@ func repoView(query url.Values) string {
3045 }
3046
3047 func parseTemplates() (*template.Template, error) {
3048- scripts := map[string]string{"bridge.js": bridgeJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}
3049+ scripts := map[string]string{"bridge.js": bridgeJS, "tiny.js": tinyJS, "components.js": componentsJS, "blossom-encryption.js": blossomEncryptionJS, "blossom-manifests.js": blossomManifestsJS, "blossom-upload.js": blossomUploadJS, "file-messages.js": fileMessagesJS, "private-services.js": privateServicesJS, "file-workspace.js": fileWorkspaceJS}
3050 funcs := template.FuncMap{
3051 "stylesheet": func() template.CSS { return template.CSS(styleCSS) },
3052 "script": func(name string) (template.JS, error) {
3053diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
3054new file mode 100644
3055index 0000000..79d6c74
3056--- /dev/null
3057+++ b/internal/webui/tiny.js
3058@@ -0,0 +1,18 @@
3059+// Shared browser helpers. This file is loaded before feature modules.
3060+(() => {
3061+ "use strict";
3062+ const tiny = globalThis.tiny || {};
3063+ const bytes = value => {
3064+ if (value instanceof Uint8Array) return value;
3065+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
3066+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
3067+ throw new TypeError("expected bytes");
3068+ };
3069+ const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");
3070+ const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));
3071+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256});
3072+ // Feature modules add to these groups; guards can read them before load.
3073+ tiny.blossom = tiny.blossom || {};
3074+ tiny.files = tiny.files || {};
3075+ globalThis.tiny = tiny;
3076+})();
3077diff --git a/package.json b/package.json
3078index 1771e8b..e8ff5ef 100644
3079--- a/package.json
3080+++ b/package.json
3081@@ -7,7 +7,7 @@
3082 "test:conformance": "node scripts/conformance/run.mjs",
3083 "test:webmcp": "node --test scripts/webmcp.test.mjs",
3084 "test:signer": "node --test scripts/signer.test.mjs",
3085- "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs",
3086+ "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs scripts/tiny-namespace.test.mjs",
3087 "test:collaboration": "node --test scripts/collaboration-compose.test.mjs"
3088 },
3089 "devDependencies": {
3090diff --git a/scripts/blossom-encryption.test.mjs b/scripts/blossom-encryption.test.mjs
3091index 0e61726..40235c7 100644
3092--- a/scripts/blossom-encryption.test.mjs
3093+++ b/scripts/blossom-encryption.test.mjs
3094@@ -5,10 +5,11 @@ import test from "node:test";
3095 import vm from "node:vm";
3096
3097 const source = await readFile(new URL("../internal/webui/blossom-encryption.js", import.meta.url), "utf8");
3098+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
3099 const sandbox = {crypto: webcrypto, TextEncoder, URL, URLSearchParams, Uint8Array, ArrayBuffer};
3100 sandbox.globalThis = sandbox;
3101-vm.runInNewContext(source, sandbox);
3102-const api = sandbox.TinyBlossomEncryption;
3103+vm.runInNewContext(shared + source, sandbox);
3104+const api = sandbox.tiny.blossom.encryption;
3105 const utf8 = value => new TextEncoder().encode(value);
3106 const hex = bytes => Buffer.from(bytes).toString("hex");
3107
3108diff --git a/scripts/blossom-manifests-review.test.mjs b/scripts/blossom-manifests-review.test.mjs
3109index a216609..ac2dd3b 100644
3110--- a/scripts/blossom-manifests-review.test.mjs
3111+++ b/scripts/blossom-manifests-review.test.mjs
3112@@ -4,10 +4,11 @@ import test from "node:test";
3113 import vm from "node:vm";
3114
3115 const source = await readFile(new URL("../internal/webui/blossom-manifests.js", import.meta.url), "utf8");
3116+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
3117 const sandbox = {TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, crypto: (await import("node:crypto")).webcrypto};
3118 sandbox.globalThis = sandbox;
3119-vm.runInNewContext(source, sandbox);
3120-const api = sandbox.TinyBlossomManifests;
3121+vm.runInNewContext(shared + source, sandbox);
3122+const api = sandbox.tiny.blossom.manifests;
3123 const hash = value => value.repeat(64);
3124 const bytes = hex => Uint8Array.from(hex.match(/../g), value => parseInt(value, 16));
3125
3126diff --git a/scripts/blossom-manifests.test.mjs b/scripts/blossom-manifests.test.mjs
3127index fda7380..698244f 100644
3128--- a/scripts/blossom-manifests.test.mjs
3129+++ b/scripts/blossom-manifests.test.mjs
3130@@ -4,10 +4,11 @@ import test from "node:test";
3131 import vm from "node:vm";
3132
3133 const source = await readFile(new URL("../internal/webui/blossom-manifests.js", import.meta.url), "utf8");
3134+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
3135 const sandbox = {TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, crypto: (await import("node:crypto")).webcrypto};
3136 sandbox.globalThis = sandbox;
3137-vm.runInNewContext(source, sandbox);
3138-const api = sandbox.TinyBlossomManifests;
3139+vm.runInNewContext(shared + source, sandbox);
3140+const api = sandbox.tiny.blossom.manifests;
3141 const bytes = hex => Uint8Array.from(hex.match(/../g), x => parseInt(x, 16));
3142 const hash = c => c.repeat(64);
3143
3144diff --git a/scripts/blossom-upload.test.mjs b/scripts/blossom-upload.test.mjs
3145index 134f429..60054de 100644
3146--- a/scripts/blossom-upload.test.mjs
3147+++ b/scripts/blossom-upload.test.mjs
3148@@ -6,10 +6,11 @@ import test from "node:test";
3149 import vm from "node:vm";
3150
3151 const source = await readFile(new URL("../internal/webui/blossom-upload.js", import.meta.url), "utf8");
3152+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
3153 const sandbox = {crypto: webcrypto, Blob, ArrayBuffer, Uint8Array, AbortController, Map, setTimeout, clearTimeout, URL};
3154 sandbox.globalThis = sandbox;
3155-vm.runInNewContext(source, sandbox);
3156-const upload = sandbox.TinyBlossomUpload.upload;
3157+vm.runInNewContext(shared + source, sandbox);
3158+const upload = sandbox.tiny.blossom.upload.upload;
3159 const digest = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");
3160 const response = (status, headers = {}, body = null) => ({
3161 status, ok: status >= 200 && status < 300,
3162diff --git a/scripts/file-messages.test.mjs b/scripts/file-messages.test.mjs
3163index 8f6aa91..72cd9bd 100644
3164--- a/scripts/file-messages.test.mjs
3165+++ b/scripts/file-messages.test.mjs
3166@@ -5,7 +5,9 @@ import vm from "node:vm";
3167 import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, nip44, verifyEvent } from "nostr-tools";
3168
3169 globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data };
3170+globalThis.tiny = {files: {}};
3171 vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), { filename: "file-messages.js" });
3172+const fileMessages = globalThis.tiny.files.messages;
3173
3174 const alice = generateSecretKey();
3175 const bob = generateSecretKey();
3176@@ -19,7 +21,7 @@ const signer = {
3177 const list = (pubkey, relays = ["wss://inbox.example"]) => finalizeEvent({ kind: 10050, tags: relays.map((relay) => ["relay", relay]), content: "", created_at: 100 }, pubkey === alicePub ? alice : bob);
3178
3179 test("build creates independently decryptable NIP-17 file gift wraps", async () => {
3180- 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 });
3181+ 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 });
3182 assert.equal(result.rumor.pubkey, alicePub);
3183 assert.equal(result.rumor.id, getEventHash(result.rumor));
3184 assert.equal(result.rumor.kind, 15);
3185@@ -43,7 +45,7 @@ test("build creates independently decryptable NIP-17 file gift wraps", async ()
3186
3187 test("share requires valid inbox lists and publishes only to those lists", async () => {
3188 const published = [];
3189- const result = await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3190+ const result = await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "c".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3191 signer,
3192 queryRelayList: async (pubkey) => list(pubkey, pubkey === bobPub ? ["wss://bob.example"] : ["ws://127.0.0.1:7777"]),
3193 publish: async (event, relays) => { published.push({ event, relays }); return true; },
3194@@ -57,7 +59,7 @@ test("share requires valid inbox lists and publishes only to those lists", async
3195
3196 test("share refuses to send when either inbox list is absent", async () => {
3197 let sends = 0;
3198- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3199+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "d".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3200 signer,
3201 queryRelayList: async () => null,
3202 publish: async () => { sends++; },
3203@@ -67,18 +69,18 @@ test("share refuses to send when either inbox list is absent", async () => {
3204
3205 test("share accepts npub recipients and rejects signer identity mismatches", async () => {
3206 const published = [];
3207- await TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {
3208+ await fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "e".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: nip19.npubEncode(bobPub) }, {
3209 signer,
3210 queryRelayList: async (pubkey) => list(pubkey),
3211 publish: async (event) => { published.push(event); return true; },
3212 });
3213 assert.equal(published.length, 2);
3214 const mismatched = { ...signer, async signEvent(event) { return finalizeEvent(event, bob); } };
3215- 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/);
3216+ 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/);
3217 });
3218
3219 test("share rejects an all-failed publish result", async () => {
3220- await assert.rejects(() => TinyFileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3221+ await assert.rejects(() => fileMessages.share({ fileURL: "https://files.example/a", ciphertextHash: "1".repeat(64), key: "11".repeat(32), nonce: "22".repeat(12), recipient: bobPub }, {
3222 signer,
3223 queryRelayList: async (pubkey) => list(pubkey),
3224 publish: async () => [false, false],
3225@@ -100,7 +102,7 @@ test("default transport queries locally and publishes only inbox WebSockets", as
3226 send(data) { const event = JSON.parse(data)[1]; setTimeout(() => this.onmessage?.({ data: JSON.stringify(["OK", event.id, true, ""]) }), 0); }
3227 close() { this.closed = true; }
3228 };
3229- 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 });
3230+ 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 });
3231 assert.equal(queries.length, 2);
3232 assert.deepEqual(sockets.map((socket) => socket.url), ["wss://bob.example", "wss://alice.example"]);
3233 assert.deepEqual(result.deliveries, [[true], [true]]);
3234@@ -125,7 +127,7 @@ test("default transport answers one NIP-42 challenge and retries the event", asy
3235 }
3236 close() {}
3237 };
3238- const result = await TinyFileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });
3239+ const result = await fileMessages.publishEvent({ id: "1".repeat(64) }, ["wss://inbox.example"], { signer, timeoutMs: 1000 });
3240 assert.deepEqual(result, [true]);
3241 assert.equal(eventAttempts, 2);
3242 delete globalThis.WebSocket;
3243diff --git a/scripts/file-workspace.test.mjs b/scripts/file-workspace.test.mjs
3244index e9fd7bc..9f5a1c0 100644
3245--- a/scripts/file-workspace.test.mjs
3246+++ b/scripts/file-workspace.test.mjs
3247@@ -50,13 +50,14 @@ const parse = (html, parent) => {
3248 const load = (workspace = false) => {
3249 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 = () => {};
3250 const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }};
3251- const window = {document, customElements, tiny: {}, location: {href: "https://relay.test/files", origin: "https://relay.test", pathname: "/files", hash: ""}, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})};
3252+ 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: () => {}})};
3253 class HTMLElement extends Node {}
3254 Object.assign(globalThis, {window, document, customElements, HTMLElement, location: window.location, Blob, URL, CustomEvent: class {}});
3255 globalThis.tiny = window.tiny;
3256 Object.defineProperty(globalThis, "navigator", {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable"); }}}});
3257 globalThis.btoa = value => Buffer.from(value, "binary").toString("base64"); globalThis.atob = value => Buffer.from(value, "base64").toString("binary");
3258- 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; }
3259+ vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));
3260+ 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; }
3261 vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));
3262 return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"]};
3263 };
3264@@ -187,7 +188,7 @@ test("sharing a newly uploaded file uses its current key, hash and ciphertext si
3265 shareForm.querySelector("input").value = "b".repeat(64);
3266 globalThis.nostr = {signEvent: async value => value};
3267 let input;
3268- globalThis.TinyFileMessages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};
3269+ globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};
3270 await element.share(shareForm);
3271 assert.ok(input, element.output.textContent);
3272 assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded));
3273@@ -241,7 +242,7 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up
3274 window.tiny.localPath = path => path;
3275 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");
3276 const sent = [];
3277- globalThis.TinyBlossomUpload = {upload: async (bytes, options) => {
3278+ globalThis.tiny.blossom.upload = {upload: async (bytes, options) => {
3279 assert.equal(options.url, "https://relay.test/");
3280 sent.push(bytes.slice());
3281 if (sent.length === 1) throw Error("connection interrupted");
3282diff --git a/scripts/private-services.test.mjs b/scripts/private-services.test.mjs
3283index 55c80b5..e238296 100644
3284--- a/scripts/private-services.test.mjs
3285+++ b/scripts/private-services.test.mjs
3286@@ -18,8 +18,9 @@ const sandbox = {
3287 NostrSigner: {verifyEvent: () => true}
3288 };
3289 sandbox.window = sandbox;
3290+ sandbox.tiny = {files: {}};
3291 vm.runInNewContext(source, sandbox);
3292-const api = sandbox.PrivateServices;
3293+const api = sandbox.tiny.files.privateServices;
3294
3295 test("accepts tenant paths while rejecting unsafe relay URLs", () => {
3296 assert.equal(api.validURL("wss://Relay.Example/r/Private"), true);
3297diff --git a/scripts/signer.test.mjs b/scripts/signer.test.mjs
3298index 0c9bfc0..5690cd4 100644
3299--- a/scripts/signer.test.mjs
3300+++ b/scripts/signer.test.mjs
3301@@ -5,6 +5,7 @@ import test from "node:test";
3302 import vm from "node:vm";
3303
3304 const bridge = await readFile(new URL("../internal/webui/bridge.js", import.meta.url), "utf8");
3305+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
3306
3307 async function page({signedIn = false, bunkerResult = null} = {}) {
3308 const elements = new Map();
3309@@ -15,6 +16,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
3310 const requests = [];
3311 const signer = {bp: {pubkey: "a".repeat(64), relays: ["wss://tiny.example/r/work"]}, getPublicKey: async () => "a".repeat(64), signEvent: async event => ({...event, pubkey: "a".repeat(64), sig: "test"})};
3312 const sandbox = {
3313+ tiny: {},
3314 document: {getElementById: id => elements.get(id), querySelector: () => null, addEventListener() {}, documentElement: {dataset: {}}},
3315 crypto: webcrypto, TextEncoder, URL, Uint8Array, ArrayBuffer,
3316 btoa: value => Buffer.from(value).toString("base64"),
3317@@ -32,7 +34,7 @@ async function page({signedIn = false, bunkerResult = null} = {}) {
3318 fetch: async (url, options) => { requests.push({url, options}); return new Response("{}"); }
3319 };
3320 sandbox.window = sandbox;
3321- vm.runInNewContext(bridge, sandbox);
3322+ vm.runInNewContext(shared + bridge, sandbox);
3323 await new Promise(resolve => setTimeout(resolve, 0));
3324 return {elements, saved, requests, sandbox};
3325 }
3326diff --git a/scripts/tiny-namespace.test.mjs b/scripts/tiny-namespace.test.mjs
3327new file mode 100644
3328index 0000000..7b0346d
3329--- /dev/null
3330+++ b/scripts/tiny-namespace.test.mjs
3331@@ -0,0 +1,25 @@
3332+import assert from "node:assert/strict";
3333+import {webcrypto} from "node:crypto";
3334+import {readFile} from "node:fs/promises";
3335+import test from "node:test";
3336+import vm from "node:vm";
3337+
3338+const read = name => readFile(new URL(`../internal/webui/${name}`, import.meta.url), "utf8");
3339+const [tiny, encryption, manifests, upload] = await Promise.all([
3340+ read("tiny.js"), read("blossom-encryption.js"), read("blossom-manifests.js"), read("blossom-upload.js")
3341+]);
3342+
3343+test("feature modules bootstrap through the shared tiny namespace", () => {
3344+ const sandbox = {crypto: webcrypto, TextEncoder, TextDecoder, URL, URLSearchParams, Uint8Array, ArrayBuffer, Blob, Map, setTimeout, clearTimeout};
3345+ sandbox.globalThis = sandbox;
3346+ vm.runInNewContext(tiny + encryption + manifests + upload, sandbox);
3347+ assert.equal(typeof sandbox.tiny.util.hex, "function");
3348+ assert.equal(typeof sandbox.tiny.blossom.encryption.encryptCHK, "function");
3349+ assert.equal(typeof sandbox.tiny.blossom.manifests.encodeManifest, "function");
3350+ assert.equal(typeof sandbox.tiny.blossom.upload.upload, "function");
3351+ assert.equal("TinyBlossomEncryption" in sandbox, false);
3352+ assert.equal("TinyBlossomManifests" in sandbox, false);
3353+ assert.equal("TinyBlossomUpload" in sandbox, false);
3354+ vm.runInNewContext(tiny + encryption + manifests + upload, sandbox);
3355+ assert.equal(typeof sandbox.tiny.blossom.manifests.decodeManifest, "function");
3356+});

About

ownerc01503e8035f
visibilitypublic
headefc0c4439bc2

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs