tinyrelaycommit

Commit 3d07a13fd864

browse files at this commit | history

1diff --git a/internal/webui/blossom-encryption.js b/internal/webui/blossom-encryption.js
2index 8ca65cb..2f62933 100644
3--- a/internal/webui/blossom-encryption.js
4+++ b/internal/webui/blossom-encryption.js
5@@ -10,44 +10,29 @@
6 const info = encoder.encode("encryption-key");
7
8 const {bytes, hex, sha256: digest} = globalThis.tiny.util;
9- const fromHex = (value) => {
10- if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
11- throw new Error("expected a 64-character lowercase hex key");
12- const result = new Uint8Array(32);
13- for (let index = 0; index < result.length; index++)
14- result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);
15- return result;
16- };
17- const equal = (left, right) =>
18- left.length === right.length &&
19- left.every((value, index) => value === right[index]);
20- const aesKey = async (key) => {
21- const material = await crypto.subtle.importKey("raw", key, "HKDF", false, [
22- "deriveKey",
23- ]);
24+ const fromHex = value => globalThis.tiny.util.fromHex(value, "expected a 64-character lowercase hex key");
25+ const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
26+ const aesKey = async key => {
27+ const material = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);
28 return crypto.subtle.deriveKey(
29- { name: "HKDF", hash: "SHA-256", salt, info },
30+ {name: "HKDF", hash: "SHA-256", salt, info},
31 material,
32- { name: "AES-GCM", length: 256 },
33+ {name: "AES-GCM", length: 256},
34 false,
35- ["encrypt", "decrypt"],
36+ ["encrypt", "decrypt"]
37 );
38 };
39
40- const encryptCHK = async (plaintext) => {
41+ const encryptCHK = async plaintext => {
42 const input = bytes(plaintext);
43 const key = await digest(input);
44 const encrypted = new Uint8Array(
45- await crypto.subtle.encrypt(
46- { name: "AES-GCM", iv: zeroNonce },
47- await aesKey(key),
48- input,
49- ),
50+ await crypto.subtle.encrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(key), input)
51 );
52 return {
53 ciphertext: encrypted,
54 key: hex(key),
55- hash: hex(await digest(encrypted)),
56+ hash: hex(await digest(encrypted))
57 };
58 };
59
60@@ -56,55 +41,39 @@
61 const chkKey = fromHex(key);
62 if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
63 throw new Error("expected a 64-character lowercase blob hash");
64- if (hex(await digest(input)) !== hash)
65- throw new Error("ciphertext hash mismatch");
66+ if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch");
67 let plain;
68 try {
69 plain = new Uint8Array(
70- await crypto.subtle.decrypt(
71- { name: "AES-GCM", iv: zeroNonce },
72- await aesKey(chkKey),
73- input,
74- ),
75+ await crypto.subtle.decrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(chkKey), input)
76 );
77 } catch {
78 throw new Error("CHK decryption failed");
79 }
80- if (!equal(await digest(plain), chkKey))
81- throw new Error("plaintext hash mismatch");
82+ if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch");
83 return plain;
84 };
85
86 const createURI = (hash, extension, key) => {
87 if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))
88 throw new Error("expected a 64-character lowercase blob hash");
89- const path =
90- "blossom:" +
91- hash +
92- (extension ? "." + String(extension).replace(/^\.+/, "") : "");
93+ const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "") : "");
94 fromHex(key);
95- const params = new URLSearchParams({ enc: "chk-v1", k: key });
96+ const params = new URLSearchParams({enc: "chk-v1", k: key});
97 return path + "?" + params;
98 };
99
100- const parseURI = (value) => {
101- if (typeof value !== "string" || !value.startsWith("blossom:"))
102- throw new Error("expected a Blossom URI");
103- const parsed = new URL(
104- value.slice("blossom:".length),
105- "https://blossom.invalid",
106- );
107- const match = parsed.pathname.match(
108- /^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/,
109- );
110- if (!match || parsed.searchParams.get("enc") !== "chk-v1")
111- throw new Error("invalid CHK Blossom URI");
112+ const parseURI = value => {
113+ if (typeof value !== "string" || !value.startsWith("blossom:")) throw new Error("expected a Blossom URI");
114+ const parsed = new URL(value.slice("blossom:".length), "https://blossom.invalid");
115+ const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/);
116+ if (!match || parsed.searchParams.get("enc") !== "chk-v1") throw new Error("invalid CHK Blossom URI");
117 const key = parsed.searchParams.get("k");
118 fromHex(key);
119- return { hash: match[1], extension: match[2] || "", key };
120+ return {hash: match[1], extension: match[2] || "", key};
121 };
122
123- const api = Object.freeze({ encryptCHK, decryptCHK, createURI, parseURI });
124+ const api = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});
125 globalThis.tiny = globalThis.tiny || {};
126- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, encryption: api };
127+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, encryption: api};
128 })();
129diff --git a/internal/webui/blossom-manifests.js b/internal/webui/blossom-manifests.js
130index 3391d51..7496ce2 100644
131--- a/internal/webui/blossom-manifests.js
132+++ b/internal/webui/blossom-manifests.js
133@@ -7,34 +7,25 @@
134 const MAX_DEPTH = 64;
135 const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;
136 const textEncoder = new TextEncoder(),
137- textDecoder = new TextDecoder("utf-8", { fatal: true });
138+ textDecoder = new TextDecoder("utf-8", {fatal: true});
139 const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;
140- const hashBytes = (h) => {
141- if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h))
142- throw new Error("expected lowercase SHA-256 hash");
143- return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));
144- };
145- const keyBytes = (k) => {
146+ const hashBytes = h => globalThis.tiny.util.fromHex(h, "expected lowercase SHA-256 hash");
147+ const keyBytes = k => {
148 if (k === undefined) return;
149 const b = asBytes(k);
150 if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");
151 return b;
152 };
153- const wellFormed = (value) => {
154+ const wellFormed = value => {
155 for (let i = 0; i < value.length; i++) {
156 const c = value.charCodeAt(i);
157 if (c >= 55296 && c <= 56319) {
158- if (
159- ++i >= value.length ||
160- value.charCodeAt(i) < 56320 ||
161- value.charCodeAt(i) > 57343
162- )
163- return false;
164+ if (++i >= value.length || value.charCodeAt(i) < 56320 || value.charCodeAt(i) > 57343) return false;
165 } else if (c >= 56320 && c <= 57343) return false;
166 }
167 return true;
168 };
169- const nameOK = (n) =>
170+ const nameOK = n =>
171 typeof n === "string" &&
172 n.length > 0 &&
173 n !== "." &&
174@@ -43,12 +34,11 @@
175 !n.includes("/") &&
176 !n.includes("\0") &&
177 textEncoder.encode(n).length <= 1024;
178- const int = (n) => Number.isSafeInteger(n) && n >= 0;
179+ const int = n => Number.isSafeInteger(n) && n >= 0;
180 const utf8cmp = (a, b) => {
181 const x = textEncoder.encode(a),
182 y = textEncoder.encode(b);
183- for (let i = 0; i < Math.min(x.length, y.length); i++)
184- if (x[i] !== y[i]) return x[i] - y[i];
185+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];
186 return x.length - y.length;
187 };
188 class MessagePackWriter {
189@@ -66,8 +56,7 @@
190 }
191 }
192 const writeInteger = (w, n) => {
193- if (!Number.isSafeInteger(n))
194- throw new Error("manifest integers must be safe integers");
195+ if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");
196 if (n >= 0) {
197 if (n < 128) w.u8(n);
198 else if (n < 256) w.push(204, n);
199@@ -121,7 +110,7 @@
200 if (v.length < 16) w.u8(144 | v.length);
201 else if (v.length < 65536) w.push(220, v.length >> 8, v.length);
202 else throw new Error("array too large");
203- v.forEach((x) => writeValue(w, x));
204+ v.forEach(x => writeValue(w, x));
205 return;
206 }
207 if (v && typeof v === "object") {
208@@ -129,7 +118,7 @@
209 if (keys.length < 16) w.u8(128 | keys.length);
210 else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);
211 else throw new Error("map too large");
212- keys.forEach((k) => {
213+ keys.forEach(k => {
214 writeValue(w, k);
215 writeValue(w, v[k]);
216 });
217@@ -160,8 +149,7 @@
218 const o = Object.create(null);
219 for (let i = 0; i < n; i++) {
220 const k = readValue(r, depth + 1);
221- if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k))
222- throw Error("invalid map key");
223+ if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");
224 o[k] = readValue(r, depth + 1);
225 }
226 return o;
227@@ -172,8 +160,7 @@
228 if (t <= 127) return t;
229 if (t >= 224) return t - 256;
230 if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));
231- if ((t & 240) === 144)
232- return Array.from({ length: t & 15 }, () => readValue(r, depth + 1));
233+ if ((t & 240) === 144) return Array.from({length: t & 15}, () => readValue(r, depth + 1));
234 if ((t & 240) === 128) return readMap(r, t & 15, depth);
235 if (t === 192) return null;
236 if (t === 194) return false;
237@@ -181,13 +168,11 @@
238 let n;
239 if (t === 204) n = r.u8();
240 else if (t === 205) n = (r.u8() << 8) | r.u8();
241- else if (t === 206)
242- n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
243+ else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();
244 else if (t === 207) {
245 let x = 0n;
246 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
247- if (x > BigInt(Number.MAX_SAFE_INTEGER))
248- throw Error("integer exceeds safe range");
249+ if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");
250 n = Number(x);
251 } else if (t === 208) n = (r.u8() << 24) >> 24;
252 else if (t === 209) {
253@@ -200,10 +185,7 @@
254 let x = 0n;
255 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());
256 if (x & 0x8000000000000000n) x -= 0x10000000000000000n;
257- if (
258- x > BigInt(Number.MAX_SAFE_INTEGER) ||
259- x < BigInt(Number.MIN_SAFE_INTEGER)
260- )
261+ if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER))
262 throw Error("integer exceeds safe range");
263 n = Number(x);
264 } else if (t === 202) {
265@@ -222,7 +204,7 @@
266 else if (t === 197) n = r.n((r.u8() << 8) | r.u8());
267 else if (t === 220) {
268 n = (r.u8() << 8) | r.u8();
269- return Array.from({ length: n }, () => readValue(r, depth + 1));
270+ return Array.from({length: n}, () => readValue(r, depth + 1));
271 } else if (t === 222) {
272 n = (r.u8() << 8) | r.u8();
273 return readMap(r, n, depth);
274@@ -231,19 +213,12 @@
275 };
276 const validateMeta = (m, depth = 0) => {
277 if (m === undefined) return;
278- if (
279- depth > MAX_DEPTH ||
280- !m ||
281- Array.isArray(m) ||
282- typeof m !== "object" ||
283- m instanceof Uint8Array
284- )
285+ if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array)
286 throw Error("metadata must be a JSON map");
287 const keys = Object.keys(m);
288 if (keys.length > 64) throw Error("too many metadata keys");
289 for (const [k, v] of Object.entries(m)) {
290- if (typeof k !== "string" || textEncoder.encode(k).length > 256)
291- throw Error("invalid metadata key");
292+ if (typeof k !== "string" || textEncoder.encode(k).length > 256) throw Error("invalid metadata key");
293 if (
294 v === undefined ||
295 typeof v === "bigint" ||
296@@ -255,32 +230,25 @@
297 if (v && typeof v === "object") {
298 if (Array.isArray(v)) {
299 if (v.length > 256) throw Error("metadata array too large");
300- v.forEach((x) => validateMeta({ x }, depth + 1));
301+ v.forEach(x => validateMeta({x}, depth + 1));
302 } else validateMeta(v, depth + 1);
303 }
304 }
305 };
306 const validateLink = (x, nodeType) => {
307- if (!x || typeof x !== "object" || Array.isArray(x))
308- throw Error("invalid manifest link");
309- if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k)))
310+ if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");
311+ if (Object.keys(x).some(k => !["h", "k", "m", "n", "s", "t"].includes(k)))
312 throw Error("unknown manifest link field");
313- if (!(x.h instanceof Uint8Array) || x.h.length !== 32)
314- throw Error("link hash must be 32 bytes");
315+ if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");
316 if (x.k !== undefined) keyBytes(x.k);
317 if (x.m !== undefined) validateMeta(x.m);
318 if (!int(x.s)) throw Error("invalid link size");
319 if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");
320- if (nodeType === 1 && x.t !== 0 && x.t !== 1)
321- throw Error("invalid file link type");
322- if (nodeType === 1 && x.n !== undefined)
323- throw Error("file links cannot have names");
324- if (nodeType === 3 && x.n !== undefined)
325- throw Error("fanout links cannot have names");
326- if (nodeType === 3 && x.t !== 2 && x.t !== 3)
327- throw Error("invalid fanout child type");
328- if (nodeType === 2 && !nameOK(x.n))
329- throw Error("invalid directory entry name");
330+ if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");
331+ if (nodeType === 1 && x.n !== undefined) throw Error("file links cannot have names");
332+ if (nodeType === 3 && x.n !== undefined) throw Error("fanout links cannot have names");
333+ if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");
334+ if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");
335 };
336 const validateNode = (node, depth = 0) => {
337 if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");
338@@ -291,7 +259,7 @@
339 !Array.isArray(node.l) ||
340 !int(node.t) ||
341 ![1, 2, 3].includes(node.t) ||
342- Object.keys(node).some((k) => k !== "l" && k !== "t")
343+ Object.keys(node).some(k => k !== "l" && k !== "t")
344 )
345 throw Error("invalid manifest node");
346 if (node.l.length > MAX_LINKS) throw Error("too many manifest links");
347@@ -315,14 +283,11 @@
348 throw Error("invalid fanout bounds");
349 }
350 }
351- if (
352- node.t === 2 &&
353- links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)
354- )
355+ if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0))
356 throw Error("directory links are not canonical");
357 return node;
358 };
359- const encodeManifest = (node) => {
360+ const encodeManifest = node => {
361 validateNode(node);
362 const w = new MessagePackWriter();
363 w.u8(130);
364@@ -336,7 +301,7 @@
365 ...(x.m !== undefined ? ["m"] : []),
366 ...(x.n !== undefined ? ["n"] : []),
367 "s",
368- "t",
369+ "t"
370 ];
371 if (fields.length < 16) w.u8(128 | fields.length);
372 else w.push(222, fields.length >> 8, fields.length);
373@@ -360,78 +325,71 @@
374 validateNode(node);
375 if (node.t === 3)
376 for (const x of node.l) {
377- if (x.m.count > Number(options.maxEntries || 1e9))
378- throw Error("fanout count limit exceeded");
379+ if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");
380 }
381 return node;
382 };
383- const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));
384+ const manifestHash = async node => hex(await sha256(encodeManifest(node)));
385 const leaf = (hash, size, key) => {
386- const x = { h: hashBytes(hash), s: size, t: 0 };
387+ const x = {h: hashBytes(hash), s: size, t: 0};
388 if (key !== undefined) x.k = keyBytes(key);
389 return x;
390 };
391 const storeNode = async (node, options, manifests) => {
392 const bytes = encodeManifest(node),
393 computed = hex(await sha256(bytes));
394- let result = { hash: computed };
395+ let result = {hash: computed};
396 if (options.store) {
397- const stored = await options.store({ hash: computed, bytes, node });
398+ const stored = await options.store({hash: computed, bytes, node});
399 if (stored) {
400 if (typeof stored === "string") result.hash = stored;
401- else result = { ...result, ...stored };
402+ else result = {...result, ...stored};
403 }
404 }
405- if (!/^[0-9a-f]{64}$/.test(result.hash))
406- throw Error("store returned invalid manifest hash");
407+ if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");
408 if (result.key !== undefined) keyBytes(result.key);
409- manifests.push({ hash: result.hash, bytes, node, key: result.key });
410+ manifests.push({hash: result.hash, bytes, node, key: result.key});
411 return result;
412 };
413 const buildFile = async (chunks, options = {}) => {
414 const max = options.maxLinks ?? MAX_LINKS;
415- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)
416- throw Error("invalid maxLinks");
417- const leaves = chunks.map((c) =>
418- typeof c === "string"
419- ? leaf(c, options.chunkSize || 0)
420- : leaf(c.hash, c.size, c.key),
421+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
422+ const leaves = chunks.map(c =>
423+ typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key)
424 );
425- if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE))
426- throw Error("invalid chunk size");
427+ if (leaves.some(x => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");
428 const manifests = [];
429 let level = leaves;
430 while (level.length > max) {
431 const next = [];
432 for (let i = 0; i < level.length; i += max) {
433 const group = level.slice(i, i + max),
434- child = { l: group, t: 1 },
435+ child = {l: group, t: 1},
436 stored = await storeNode(child, options, manifests);
437 next.push({
438 h: hashBytes(stored.hash),
439- ...(stored.key ? { k: keyBytes(stored.key) } : {}),
440+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),
441 s: group.reduce((n, x) => n + x.s, 0),
442- t: 1,
443+ t: 1
444 });
445 }
446 level = next;
447 }
448- const root = { l: level, t: 1 };
449+ const root = {l: level, t: 1};
450 await storeNode(root, options, manifests);
451- return options.returnDetails ? { root, manifests } : root;
452+ return options.returnDetails ? {root, manifests} : root;
453 };
454 const buildDirectory = async (entries, options = {}) => {
455 if (!Array.isArray(entries)) throw Error("entries must be an array");
456 const max = options.maxLinks ?? MAX_LINKS;
457- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)
458- throw Error("invalid maxLinks");
459+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");
460 const sorted = entries
461- .map((e) => {
462+ .map(e => {
463 const x = {
464 h: hashBytes(e.hash),
465 n: e.name,
466 s: e.size || 0,
467- t: e.type || 0,
468+ t: e.type || 0
469 };
470 if (e.key !== undefined) x.k = keyBytes(e.key);
471 if (e.metadata !== undefined) x.m = e.metadata;
472@@ -439,50 +397,49 @@
473 })
474 .sort((a, b) => utf8cmp(a.n, b.n));
475 for (let i = 1; i < sorted.length; i++)
476- if (sorted[i - 1].n === sorted[i].n)
477- throw Error("duplicate directory entry");
478+ if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");
479 const manifests = [];
480 if (sorted.length <= max) {
481- const directory = { l: sorted, t: 2 };
482+ const directory = {l: sorted, t: 2};
483 await storeNode(directory, options, manifests);
484- return options.returnDetails ? { root: directory, manifests } : directory;
485+ return options.returnDetails ? {root: directory, manifests} : directory;
486 }
487 let level = [];
488 for (let i = 0; i < sorted.length; i += max) {
489 const l = sorted.slice(i, i + max),
490- child = { l, t: 2 },
491+ child = {l, t: 2},
492 stored = await storeNode(child, options, manifests);
493 level.push({
494 h: hashBytes(stored.hash),
495- ...(stored.key ? { k: keyBytes(stored.key) } : {}),
496- m: { count: l.length, first: l[0].n, last: l[l.length - 1].n },
497+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),
498+ m: {count: l.length, first: l[0].n, last: l[l.length - 1].n},
499 s: l.reduce((n, x) => n + x.s, 0),
500- t: 2,
501+ t: 2
502 });
503 }
504 while (level.length > max) {
505 const next = [];
506 for (let i = 0; i < level.length; i += max) {
507 const l = level.slice(i, i + max),
508- child = { l, t: 3 },
509+ child = {l, t: 3},
510 stored = await storeNode(child, options, manifests);
511 next.push({
512 h: hashBytes(stored.hash),
513- ...(stored.key ? { k: keyBytes(stored.key) } : {}),
514+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),
515 m: {
516 count: l.reduce((n, x) => n + x.m.count, 0),
517 first: l[0].m.first,
518- last: l[l.length - 1].m.last,
519+ last: l[l.length - 1].m.last
520 },
521 s: l.reduce((n, x) => n + x.s, 0),
522- t: 3,
523+ t: 3
524 });
525 }
526 level = next;
527 }
528- const root = { l: level, t: 3 };
529+ const root = {l: level, t: 3};
530 await storeNode(root, options, manifests);
531- return options.returnDetails ? { root, manifests } : root;
532+ return options.returnDetails ? {root, manifests} : root;
533 };
534 const flattenDirectory = (node, out = []) => {
535 validateNode(node);
536@@ -500,11 +457,7 @@
537 maxEntries = options.maxEntries ?? 1e5,
538 maxManifests = options.maxManifests ?? 1e4,
539 maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
540- if (
541- [maxDepth, maxEntries, maxManifests, maxBytes].some(
542- (value) => !Number.isSafeInteger(value) || value < 0,
543- )
544- )
545+ if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0))
546 throw Error("invalid directory limits");
547 let count = 0,
548 manifestCount = 0,
549@@ -512,8 +465,7 @@
550 const active = new Set();
551 const walk = async (node, depth) => {
552 if (depth > maxDepth) throw Error("directory recursion limit exceeded");
553- if (++manifestCount > maxManifests)
554- throw Error("manifest count limit exceeded");
555+ if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");
556 validateNode(node);
557 if (node.t === 2) {
558 count += node.l.length;
559@@ -524,8 +476,7 @@
560 const result = [];
561 let previous = "";
562 for (const link of node.l) {
563- if (previous && utf8cmp(previous, link.m.first) >= 0)
564- throw Error("fanout bounds are not ordered");
565+ if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");
566 previous = link.m.last;
567 const linkHash = hex(link.h);
568 if (active.has(linkHash)) throw Error("manifest cycle detected");
569@@ -533,28 +484,21 @@
570 let raw = asBytes(await fetchNode(linkHash, link));
571 fetched += raw.length;
572 if (fetched > maxBytes) throw Error("directory byte limit exceeded");
573- if (hex(await sha256(raw)) !== linkHash)
574- throw Error("manifest hash mismatch");
575+ if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");
576 if (link.k) {
577- if (typeof options.decrypt !== "function")
578- throw Error("encrypted manifest requires decrypt callback");
579+ if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");
580 raw = asBytes(await options.decrypt(raw, link));
581 }
582 const child = decodeManifest(raw);
583 if (child.t !== link.t) throw Error("fanout child type mismatch");
584 const links = await walk(child, depth + 1);
585 active.delete(linkHash);
586- if (
587- links.length !== link.m.count ||
588- links[0]?.n !== link.m.first ||
589- links.at(-1)?.n !== link.m.last
590- )
591+ if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last)
592 throw Error("fanout bounds mismatch");
593 result.push(...links);
594 }
595 for (let i = 1; i < result.length; i++)
596- if (utf8cmp(result[i - 1].n, result[i].n) >= 0)
597- throw Error("duplicate or unordered directory entry");
598+ if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");
599 return result;
600 };
601 return walk(root, 0);
602@@ -564,13 +508,8 @@
603 const maxDepth = options.maxDepth ?? MAX_DEPTH,
604 maxBytes = options.maxBytes ?? 512 * 1024 * 1024,
605 maxManifests = options.maxManifests ?? 1e4;
606- const maxFetchedBytes =
607- options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;
608- if (
609- [maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(
610- (value) => !Number.isSafeInteger(value) || value < 0,
611- )
612- )
613+ const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;
614+ if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0))
615 throw Error("invalid file limits");
616 let used = 0,
617 fetched = 0,
618@@ -581,16 +520,14 @@
619 if (depth > maxDepth) throw Error("file recursion limit exceeded");
620 validateNode(node);
621 if (node.t !== 1) throw Error("not a file manifest");
622- if (++manifests > maxManifests)
623- throw Error("manifest count limit exceeded");
624+ if (++manifests > maxManifests) throw Error("manifest count limit exceeded");
625 for (const link of node.l) {
626 const hash = hex(link.h);
627 if (active.has(hash)) throw Error("manifest cycle detected");
628 active.add(hash);
629 let raw = asBytes(await fetchBlob(hash, link));
630 fetched += raw.length;
631- if (fetched > maxFetchedBytes)
632- throw Error("file fetched byte limit exceeded");
633+ if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");
634 if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");
635 if (link.t === 1) {
636 if (typeof options.decrypt !== "function" && link.k)
637@@ -600,11 +537,9 @@
638 if (child.t !== 1) throw Error("file child type mismatch");
639 const before = used;
640 await readNode(child, depth + 1);
641- if (used - before !== link.s)
642- throw Error("file manifest size mismatch");
643+ if (used - before !== link.s) throw Error("file manifest size mismatch");
644 } else {
645- if (link.k && typeof options.decrypt !== "function")
646- throw Error("encrypted chunk requires decrypt callback");
647+ if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");
648 const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;
649 if (data.length !== link.s) throw Error("file chunk size mismatch");
650 used += data.length;
651@@ -637,8 +572,8 @@
652 resolveDirectory,
653 readFile,
654 sha256,
655- hex,
656+ hex
657 });
658 globalThis.tiny = globalThis.tiny || {};
659- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, manifests: api };
660+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, manifests: api};
661 })();
662diff --git a/internal/webui/blossom-upload.js b/internal/webui/blossom-upload.js
663index 42bbea1..9c91678 100644
664--- a/internal/webui/blossom-upload.js
665+++ b/internal/webui/blossom-upload.js
666@@ -6,15 +6,14 @@
667 "use strict";
668
669 const {sha256, hex} = globalThis.tiny.util;
670- const asBytes = async (value) => {
671+ const asBytes = async value => {
672 if (value instanceof Uint8Array) return value;
673- if (ArrayBuffer.isView(value))
674- return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
675+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
676 if (value instanceof ArrayBuffer) return new Uint8Array(value);
677 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());
678 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");
679 };
680- const responseBody = async (response) => {
681+ const responseBody = async response => {
682 try {
683 return await response.json();
684 } catch {
685@@ -27,12 +26,7 @@
686 return error;
687 };
688 const verifyDescriptor = (descriptor, hash, size) => {
689- if (
690- !descriptor ||
691- typeof descriptor !== "object" ||
692- descriptor.sha256 !== hash ||
693- descriptor.size !== size
694- )
695+ if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size)
696 throw new Error("invalid Blossom upload descriptor");
697 return descriptor;
698 };
699@@ -40,66 +34,41 @@
700 const uploadTask = async (source, options = {}, controller) => {
701 const bytes = await asBytes(source);
702 const hash = options.hash || hex(await sha256(bytes));
703- if (!/^[0-9a-f]{64}$/.test(hash))
704- throw new Error("expected a lowercase SHA-256 hash");
705- if (options.hash && hash !== hex(await sha256(bytes)))
706- throw new Error("upload hash mismatch");
707+ if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");
708+ if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");
709 const type = options.type || source.type || "application/octet-stream";
710 const base = options.url || options.endpoint;
711- if (typeof base !== "string" || base === "")
712- throw new Error("upload URL is required");
713+ if (typeof base !== "string" || base === "") throw new Error("upload URL is required");
714 let parsed;
715 try {
716- parsed = new URL(
717- base,
718- globalThis.location?.href || "https://blossom.invalid",
719- );
720+ parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid");
721 } catch {
722 throw new Error("invalid upload URL");
723 }
724 if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
725 throw new Error("upload URL must use HTTP or HTTPS");
726 if (parsed.username || parsed.password || parsed.hash || parsed.search)
727- throw new Error(
728- "upload URL must not contain credentials, query, or fragment",
729- );
730- const extension = options.extension
731- ? String(options.extension).replace(/^\.+/, "")
732- : "";
733- if (extension && !/^[a-z0-9]{1,8}$/.test(extension))
734- throw new Error("invalid file extension");
735+ throw new Error("upload URL must not contain credentials, query, or fragment");
736+ const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";
737+ if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");
738 const path = parsed.pathname;
739- const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(
740- path,
741- )
742+ const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path)
743 ? base
744- : base.replace(/\/$/, "") +
745- "/" +
746- hash +
747- (extension ? "." + extension : "");
748+ : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");
749 const url = target;
750 const fetcher = options.fetch || globalThis.fetch;
751 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");
752 const chunkSize =
753- Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0
754- ? options.chunkSize
755- : 5 * 1024 * 1024;
756- const timeoutMs =
757- Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0
758- ? options.timeoutMs
759- : 30000;
760- const retries =
761- Number.isSafeInteger(options.retries) && options.retries >= 0
762- ? options.retries
763- : 0;
764+ Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;
765+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;
766+ const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;
767 const state = options.state instanceof Map ? options.state : new Map();
768- const notify = (offset, length, status) =>
769- options.onState?.({ offset, length, status, state });
770- const progress = (sent) =>
771+ const notify = (offset, length, status) => options.onState?.({offset, length, status, state});
772+ const progress = sent =>
773 options.onProgress?.({
774 sent,
775 total: bytes.byteLength,
776- fraction: bytes.byteLength ? sent / bytes.byteLength : 1,
777+ fraction: bytes.byteLength ? sent / bytes.byteLength : 1
778 });
779 const request = async (method, body, headers, authBody = body) => {
780 if (controller.signal.aborted) throw abortError();
781@@ -109,40 +78,35 @@
782 requestController.abort();
783 rejectCanceled?.(abortError());
784 };
785- controller.signal.addEventListener("abort", cancel, { once: true });
786+ controller.signal.addEventListener("abort", cancel, {once: true});
787 try {
788 return await Promise.race([
789 (async () => {
790- const requestHeaders = { ...headers };
791+ const requestHeaders = {...headers};
792 if (options.authorize && method !== "OPTIONS") {
793 const value = await options.authorize(url, method, authBody, {
794 hash,
795- type,
796+ type
797 });
798- if (typeof value === "string")
799- requestHeaders.authorization = value;
800- else if (value && typeof value === "object")
801- Object.assign(requestHeaders, value);
802+ if (typeof value === "string") requestHeaders.authorization = value;
803+ else if (value && typeof value === "object") Object.assign(requestHeaders, value);
804 }
805 if (requestController.signal.aborted) throw abortError();
806 const response = await fetcher(url, {
807 method,
808 headers: requestHeaders,
809 body: method === "OPTIONS" ? undefined : body,
810- signal: requestController.signal,
811+ signal: requestController.signal
812 });
813 // Keep the request deadline and cancellation active until the
814 // descriptor body has arrived, not only until headers arrive.
815- const descriptor =
816- response.status === 200 || response.status === 201
817- ? await responseBody(response)
818- : null;
819+ const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;
820 if (!descriptor) await response.body?.cancel?.();
821 return {
822 status: response.status,
823 ok: response.ok,
824 headers: response.headers,
825- json: async () => descriptor,
826+ json: async () => descriptor
827 };
828 })(),
829 new Promise((_, reject) => {
830@@ -153,7 +117,7 @@
831 }),
832 new Promise((_, reject) => {
833 rejectCanceled = reject;
834- }),
835+ })
836 ]);
837 } finally {
838 clearTimeout(timer);
839@@ -184,29 +148,18 @@
840 if (options.probe !== false) {
841 try {
842 const response = await request("OPTIONS", null, {});
843- supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(
844- response.headers?.get?.("allow") || "",
845- );
846+ supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");
847 } catch {
848 supportsPatch = false;
849 }
850 }
851 if (!supportsPatch) {
852- const response = await retry(
853- () => request("PUT", bytes, { "content-type": type }),
854- 0,
855- bytes.byteLength,
856- );
857- if (!response.ok)
858- throw new Error("Blossom upload failed: " + response.status);
859+ const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);
860+ if (!response.ok) throw new Error("Blossom upload failed: " + response.status);
861 return {
862 hash,
863- descriptor: verifyDescriptor(
864- await responseBody(response),
865- hash,
866- bytes.byteLength,
867- ),
868- state,
869+ descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength),
870+ state
871 };
872 }
873
874@@ -227,26 +180,18 @@
875 chunkSize,
876 url,
877 type,
878- lastAcceptedAt: 0,
879+ lastAcceptedAt: 0
880 };
881 }
882 let sent = 0;
883 let replayed = false;
884 while (true) {
885- for (
886- let offset = 0;
887- offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0);
888- offset += chunkSize
889- ) {
890+ for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {
891 const end = Math.min(offset + chunkSize, bytes.byteLength);
892 const chunk = bytes.slice(offset, end);
893 const length = chunk.byteLength;
894 const prior = state.get(offset);
895- if (
896- !replayed &&
897- prior?.status === 204 &&
898- Date.now() - (state.meta.lastAcceptedAt || 0) < 60000
899- ) {
900+ if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) {
901 sent = end;
902 progress(sent);
903 continue;
904@@ -260,16 +205,15 @@
905 "upload-type": type,
906 "upload-length": String(bytes.byteLength),
907 "upload-offset": String(offset),
908- "content-type": "application/octet-stream",
909+ "content-type": "application/octet-stream"
910 },
911- options.authorizationMode === "final-hash" ? bytes : chunk,
912+ options.authorizationMode === "final-hash" ? bytes : chunk
913 ),
914 offset,
915- length,
916+ length
917 );
918- if (![200, 201, 204].includes(result.status))
919- throw new Error("Blossom chunk upload failed: " + result.status);
920- state.set(offset, { offset, length, status: result.status });
921+ if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);
922+ state.set(offset, {offset, length, status: result.status});
923 state.meta.lastAcceptedAt = Date.now();
924 sent = end;
925 notify(offset, length, result.status === 204 ? "uploaded" : "complete");
926@@ -277,12 +221,8 @@
927 if (result.status !== 204)
928 return {
929 hash,
930- descriptor: verifyDescriptor(
931- await responseBody(result),
932- hash,
933- bytes.byteLength,
934- ),
935- state,
936+ descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength),
937+ state
938 };
939 if (bytes.byteLength === 0) break;
940 }
941@@ -300,7 +240,7 @@
942 if (options.signal.aborted) controller.abort();
943 else
944 options.signal.addEventListener("abort", () => controller.abort(), {
945- once: true,
946+ once: true
947 });
948 }
949 const task = uploadTask(source, options, controller);
950@@ -308,7 +248,7 @@
951 return task;
952 };
953
954- const api = Object.freeze({ upload });
955+ const api = Object.freeze({upload});
956 globalThis.tiny = globalThis.tiny || {};
957- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, upload: api };
958+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, upload: api};
959 })();
960diff --git a/internal/webui/components.js b/internal/webui/components.js
961index 0197e5b..b9e8a0f 100644
962--- a/internal/webui/components.js
963+++ b/internal/webui/components.js
964@@ -378,7 +378,7 @@
965 const recipient = form.elements.namedItem("recipient").value.trim();
966 if (!recipient) { this.say("Enter a public key or npub.", true); return; }
967 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));
968- const signer = globalThis.tinySigner || globalThis.nostr;
969+ const signer = tiny.signer();
970 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; }
971 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }
972 this.sharing = true;
973diff --git a/internal/webui/file-messages.js b/internal/webui/file-messages.js
974index aa3e34c..d9b33e1 100644
975--- a/internal/webui/file-messages.js
976+++ b/internal/webui/file-messages.js
977@@ -1,60 +1,40 @@
978 /* NIP-17 file messages. This module never publishes to ordinary relays. */
979-(function (root) {
980+(() => {
981 "use strict";
982+ const {relayURL} = globalThis.tiny.util;
983+ const {signer: activeSigner, nip44Encrypt} = globalThis.tiny;
984
985 const MAX_RELAYS = 8;
986 const DAY = 86400;
987
988 function api() {
989- const signer = root.NostrSigner;
990- if (
991- !signer ||
992- !signer.nip44 ||
993- !signer.finalizeEvent ||
994- !signer.generateSecretKey
995- ) {
996- throw new Error("NIP-44 support is unavailable");
997+ const signer = globalThis.NostrSigner;
998+ if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {
999+ throw Error("NIP-44 support is unavailable");
1000 }
1001 return signer;
1002 }
1003
1004- function relayURL(value) {
1005- if (typeof value !== "string" || value.length > 2048) return null;
1006- let url;
1007- try {
1008- url = new URL(value);
1009- } catch {
1010- return null;
1011- }
1012- if (url.protocol !== "wss:" && url.protocol !== "ws:") return null;
1013- if (url.username || url.password || url.hash) return null;
1014- return url.toString().replace(/\/$/, "");
1015- }
1016-
1017 function relayList(event, pubkey) {
1018 if (Array.isArray(event)) {
1019 event = event
1020- .filter(
1021- (candidate) =>
1022- candidate?.kind === 10050 && candidate.pubkey === pubkey,
1023- )
1024+ .filter(candidate => candidate?.kind === 10050 && candidate.pubkey === pubkey)
1025 .sort((a, b) => b.created_at - a.created_at)[0];
1026 }
1027 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {
1028- throw new Error("recipient has no valid kind 10050 relay list");
1029+ throw Error("recipient has no valid kind 10050 relay list");
1030 }
1031 const signer = api();
1032- if (!signer.verifyEvent(event))
1033- throw new Error("invalid kind 10050 relay list signature");
1034+ if (!signer.verifyEvent(event)) throw Error("invalid kind 10050 relay list signature");
1035 const relays = [
1036 ...new Set(
1037 event.tags
1038- .filter((tag) => tag[0] === "relay")
1039- .map((tag) => relayURL(tag[1]))
1040- .filter(Boolean),
1041- ),
1042+ .filter(tag => tag[0] === "relay")
1043+ .map(tag => relayURL(tag[1]))
1044+ .filter(Boolean)
1045+ )
1046 ];
1047- if (!relays.length) throw new Error("kind 10050 relay list is empty");
1048+ if (!relays.length) throw Error("kind 10050 relay list is empty");
1049 return relays.slice(0, MAX_RELAYS);
1050 }
1051
1052@@ -62,9 +42,8 @@
1053 const text = String(value || "").trim();
1054 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();
1055 const decoded = api().decodeNpub?.(text);
1056- if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded))
1057- return decoded.toLowerCase();
1058- throw new Error("public key must be a 64 character hex key or npub");
1059+ if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();
1060+ throw Error("public key must be a 64 character hex key or npub");
1061 }
1062
1063 async function bounded(value, timeout, label) {
1064@@ -74,8 +53,8 @@
1065 return await Promise.race([
1066 Promise.resolve(value),
1067 new Promise((_, reject) => {
1068- timer = setTimeout(() => reject(new Error(label + " timed out")), ms);
1069- }),
1070+ timer = setTimeout(() => reject(Error(label + " timed out")), ms);
1071+ })
1072 ]);
1073 } finally {
1074 clearTimeout(timer);
1075@@ -90,53 +69,42 @@
1076 }
1077
1078 function past(now) {
1079- return Math.max(
1080- 0,
1081- Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)),
1082- );
1083+ return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));
1084 }
1085
1086 async function publicKey(signer, getPublicKey) {
1087- const key = getPublicKey
1088- ? await getPublicKey()
1089- : await signer.getPublicKey();
1090- if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key))
1091- throw new Error("invalid signer public key");
1092+ const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();
1093+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw Error("invalid signer public key");
1094 return key.toLowerCase();
1095 }
1096
1097 async function queryRelayList(pubkey, options) {
1098- if (!root.tiny?.signedFetch)
1099- throw new Error("signed local query is unavailable");
1100- const response = await root.tiny.signedFetch(
1101+ if (!globalThis.tiny?.signedFetch) throw Error("signed local query is unavailable");
1102+ const response = await globalThis.tiny.signedFetch(
1103 "/query",
1104 "POST",
1105- JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]),
1106- { contentType: "application/json" },
1107+ JSON.stringify([{kinds: [10050], authors: [pubkey], limit: 1}]),
1108+ {contentType: "application/json"}
1109 );
1110 return response.json();
1111 }
1112
1113 function publishEvent(event, relays, options) {
1114- const signer = options.signer || root.tinySigner || root.nostr;
1115- const WebSocketImpl = options.WebSocket || root.WebSocket;
1116- if (typeof WebSocketImpl !== "function")
1117- throw new Error("WebSocket support is unavailable");
1118- const timeout =
1119- Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
1120- ? options.timeoutMs
1121- : 10000;
1122+ const signer = options.signer || activeSigner();
1123+ const WebSocketImpl = options.WebSocket || globalThis.WebSocket;
1124+ if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");
1125+ const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;
1126 return Promise.all(
1127 relays.map(
1128- (url) =>
1129- new Promise((resolve) => {
1130+ url =>
1131+ new Promise(resolve => {
1132 let socket;
1133 let timer;
1134 let settled = false;
1135 let authID = "";
1136 let authAttempts = 0;
1137 let eventSent = false;
1138- const finish = (ok) => {
1139+ const finish = ok => {
1140 if (settled) return;
1141 settled = true;
1142 clearTimeout(timer);
1143@@ -155,12 +123,10 @@
1144 socket = new WebSocketImpl(url);
1145 timer = setTimeout(() => finish(false), timeout);
1146 socket.onopen = sendEvent;
1147- socket.onmessage = async (message) => {
1148+ socket.onmessage = async message => {
1149 let frame;
1150 try {
1151- frame = JSON.parse(
1152- typeof message.data === "string" ? message.data : "",
1153- );
1154+ frame = JSON.parse(typeof message.data === "string" ? message.data : "");
1155 } catch {
1156 return;
1157 }
1158@@ -185,11 +151,7 @@
1159 finish(frame[2] === true);
1160 return;
1161 }
1162- if (
1163- frame[0] !== "AUTH" ||
1164- typeof signer?.signEvent !== "function"
1165- )
1166- return;
1167+ if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;
1168 if (typeof frame[1] !== "string" || frame[1].length > 4096) {
1169 finish(false);
1170 return;
1171@@ -205,9 +167,9 @@
1172 tags: [
1173 ["relay", url],
1174 ["challenge", frame[1]],
1175- ["method", "AUTH"],
1176+ ["method", "AUTH"]
1177 ],
1178- content: "",
1179+ content: ""
1180 });
1181 if (
1182 auth &&
1183@@ -236,20 +198,15 @@
1184 } catch {
1185 finish(false);
1186 }
1187- }),
1188- ),
1189+ })
1190+ )
1191 );
1192 }
1193
1194 function unsignedRumor(input, sender, now) {
1195- if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL))
1196- throw new Error("fileURL must be an HTTP(S) URL");
1197- if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || ""))
1198- throw new Error("ciphertextHash must be SHA-256 hex");
1199- if (!input.key || !input.nonce)
1200- throw new Error(
1201- "encrypted file messages require a decryption key and nonce",
1202- );
1203+ if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw Error("fileURL must be an HTTP(S) URL");
1204+ if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw Error("ciphertextHash must be SHA-256 hex");
1205+ if (!input.key || !input.nonce) throw Error("encrypted file messages require a decryption key and nonce");
1206 const tags = [["p", input.recipient]];
1207 if (input.mimeType) tags.push(["file-type", input.mimeType]);
1208 tags.push(["encryption-algorithm", "aes-gcm"]);
1209@@ -257,8 +214,7 @@
1210 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);
1211 tags.push(["x", input.ciphertextHash.toLowerCase()]);
1212 if (input.plaintextHash) {
1213- if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash))
1214- throw new Error("plaintextHash must be SHA-256 hex");
1215+ if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw Error("plaintextHash must be SHA-256 hex");
1216 tags.push(["ox", input.plaintextHash.toLowerCase()]);
1217 }
1218 if (input.size != null) tags.push(["size", String(input.size)]);
1219@@ -267,19 +223,13 @@
1220 created_at: Math.floor(now),
1221 kind: 15,
1222 tags,
1223- content: input.fileURL,
1224+ content: input.fileURL
1225 };
1226 rumor.id = api().getEventHash(rumor);
1227 return rumor;
1228 }
1229
1230- async function encryptFor(signer, recipient, plaintext) {
1231- if (typeof signer.nip44Encrypt === "function")
1232- return signer.nip44Encrypt(recipient, plaintext);
1233- if (signer.nip44?.encrypt)
1234- return signer.nip44.encrypt(recipient, plaintext);
1235- throw new Error("signer has no NIP-44 encrypt method");
1236- }
1237+ const encryptFor = (signer, recipient, plaintext) => nip44Encrypt(recipient, plaintext, signer);
1238
1239 async function seal(rumor, recipient, signer, now) {
1240 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));
1241@@ -288,7 +238,7 @@
1242 tags: [],
1243 content,
1244 created_at: past(now),
1245- pubkey: rumor.pubkey,
1246+ pubkey: rumor.pubkey
1247 };
1248 const event = await signer.signEvent(unsigned);
1249 if (
1250@@ -299,9 +249,8 @@
1251 event.content !== unsigned.content ||
1252 event.created_at !== unsigned.created_at
1253 )
1254- throw new Error("signer returned an invalid NIP-17 seal");
1255- if (!api().verifyEvent(event))
1256- throw new Error("signer returned an invalid seal signature");
1257+ throw Error("signer returned an invalid NIP-17 seal");
1258+ if (!api().verifyEvent(event)) throw Error("signer returned an invalid seal signature");
1259 return event;
1260 }
1261
1262@@ -311,27 +260,23 @@
1263 const pubkey = signer.getPublicKey(secret);
1264 const key = signer.nip44.getConversationKey(secret, recipient);
1265 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);
1266- const event = signer.finalizeEvent(
1267- { kind: 1059, tags: [["p", recipient]], content, created_at: past(now) },
1268- secret,
1269- );
1270- if (!signer.verifyEvent(event))
1271- throw new Error("failed to verify gift wrap");
1272- return { event, secret, pubkey };
1273+ const event = signer.finalizeEvent({kind: 1059, tags: [["p", recipient]], content, created_at: past(now)}, secret);
1274+ if (!signer.verifyEvent(event)) throw Error("failed to verify gift wrap");
1275+ return {event, secret, pubkey};
1276 }
1277
1278 async function build(input, options = {}) {
1279- const signer = options.signer || root.tinySigner || root.nostr;
1280+ const signer = options.signer || activeSigner();
1281 if (
1282 !signer ||
1283 typeof signer.signEvent !== "function" ||
1284 (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)
1285 )
1286- throw new Error("a NIP-44 signer is required");
1287+ throw Error("a NIP-44 signer is required");
1288 const recipient = normalizePubkey(input.recipient);
1289 const sender = await publicKey(signer, options.getPublicKey);
1290 const now = options.now || Date.now() / 1000;
1291- const rumor = unsignedRumor({ ...input, recipient }, sender, now);
1292+ const rumor = unsignedRumor({...input, recipient}, sender, now);
1293 const receiverSeal = await seal(rumor, recipient, signer, now);
1294 const senderSeal = await seal(rumor, sender, signer, now);
1295 const receiver = wrap(receiverSeal, recipient, now).event;
1296@@ -339,51 +284,36 @@
1297 return {
1298 rumor,
1299 events: [
1300- { recipient, event: receiver },
1301- { recipient: sender, event: own },
1302- ],
1303+ {recipient, event: receiver},
1304+ {recipient: sender, event: own}
1305+ ]
1306 };
1307 }
1308
1309 async function share(input, options = {}) {
1310- const signer = options.signer || root.tinySigner || root.nostr;
1311- const query =
1312- options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));
1313- const publish =
1314- options.publish ||
1315- ((event, relays) => publishEvent(event, relays, options));
1316+ const signer = options.signer || activeSigner();
1317+ const query = options.queryRelayList || (pubkey => queryRelayList(pubkey, options));
1318+ const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));
1319 if (typeof query !== "function" || typeof publish !== "function")
1320- throw new Error("private NIP-17 delivery is unavailable");
1321+ throw Error("private NIP-17 delivery is unavailable");
1322 const sender = await publicKey(signer, options.getPublicKey);
1323 const recipient = normalizePubkey(input.recipient);
1324 const timeout = options.timeoutMs;
1325- const receiverList = relayList(
1326- await bounded(query(recipient), timeout, "recipient relay lookup"),
1327- recipient,
1328- );
1329- const senderList = relayList(
1330- await bounded(query(sender), timeout, "sender relay lookup"),
1331- sender,
1332- );
1333+ const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);
1334+ const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);
1335 const built = await build(input, options);
1336- if (built.rumor.pubkey !== sender)
1337- throw new Error("signer changed during file sharing; try again");
1338+ if (built.rumor.pubkey !== sender) throw Error("signer changed during file sharing; try again");
1339 const deliveries = [];
1340 for (const target of built.events) {
1341 const relays = target.recipient === recipient ? receiverList : senderList;
1342- const delivery = await bounded(
1343- publish(target.event, relays),
1344- timeout,
1345- "NIP-17 delivery",
1346- );
1347- if (!sent(delivery))
1348- throw new Error("NIP-17 delivery failed on every inbox relay");
1349+ const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");
1350+ if (!sent(delivery)) throw Error("NIP-17 delivery failed on every inbox relay");
1351 deliveries.push(delivery);
1352 }
1353 return {
1354 ...built,
1355- relays: { recipient: receiverList, sender: senderList },
1356- deliveries,
1357+ relays: {recipient: receiverList, sender: senderList},
1358+ deliveries
1359 };
1360 }
1361
1362@@ -392,8 +322,8 @@
1363 share,
1364 relayList,
1365 queryRelayList,
1366- publishEvent,
1367+ publishEvent
1368 });
1369- root.tiny = root.tiny || {};
1370- root.tiny.files = { ...root.tiny.files, messages: apiObject };
1371-})(globalThis);
1372+ globalThis.tiny = globalThis.tiny || {};
1373+ globalThis.tiny.files = {...globalThis.tiny.files, messages: apiObject};
1374+})();
1375diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
1376index 611cfb1..8c10803 100644
1377--- a/internal/webui/file-workspace.js
1378+++ b/internal/webui/file-workspace.js
1379@@ -8,78 +8,50 @@
1380 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;
1381 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;
1382 const directoryType = "application/vnd.blossom.directory+msgpack";
1383- const codec = () =>
1384- globalThis.tiny?.blossom?.manifests;
1385- const encryption = () =>
1386- globalThis.tiny?.blossom?.encryption;
1387- const upload = () =>
1388- globalThis.tiny?.blossom?.upload;
1389- const hex = (value) => globalThis.tiny.util.hex(value);
1390- const fromHex = (value) => {
1391- if (!/^[0-9a-f]{64}$/.test(value || ""))
1392- throw Error("Invalid hash or encryption key.");
1393- return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));
1394- };
1395- const element = (tag, text) => {
1396- const node = document.createElement(tag);
1397- if (text !== undefined) node.textContent = text;
1398- return node;
1399- };
1400- const checkAbort = (signal) => {
1401+ const codec = () => globalThis.tiny?.blossom?.manifests;
1402+ const encryption = () => globalThis.tiny?.blossom?.encryption;
1403+ const upload = () => globalThis.tiny?.blossom?.upload;
1404+ const hex = value => globalThis.tiny.util.hex(value);
1405+ const fromHex = value => globalThis.tiny.util.fromHex(value || "", "Invalid hash or encryption key.");
1406+ const element = globalThis.tiny.util.element;
1407+ const checkAbort = signal => {
1408 if (signal?.aborted)
1409- throw Object.assign(
1410- Error("Upload canceled. Retry while this page stays open."),
1411- { name: "AbortError" },
1412- );
1413+ throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});
1414 };
1415- const validName = (name) =>
1416+ const validName = name =>
1417 typeof name === "string" &&
1418 name.length > 0 &&
1419 name.isWellFormed() &&
1420 ![".", ".."].includes(name) &&
1421 !/[\/\u0000]/.test(name) &&
1422 new TextEncoder().encode(name).length <= 1024;
1423- const folderNode = () => ({ directories: new Map(), files: new Map() });
1424+ const folderNode = () => ({directories: new Map(), files: new Map()});
1425
1426 function selectionTree(files) {
1427- if (!files.length || files.length > MAX_FILES)
1428- throw Error("Choose a folder with 1 to 10,000 files.");
1429+ if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");
1430 const root = folderNode();
1431 let total = 0;
1432- const paths = files.map((file) =>
1433- (file.webkitRelativePath || file.name).split("/"),
1434- );
1435- const commonRoot = paths.every(
1436- (path) => path.length > 1 && path[0] === paths[0][0],
1437- );
1438+ const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));
1439+ const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);
1440 for (let index = 0; index < files.length; index++) {
1441 const file = files[index];
1442 const path = commonRoot ? paths[index].slice(1) : paths[index];
1443- if (path.length > MAX_DEPTH || path.some((name) => !validName(name)))
1444- throw Error("Invalid folder path.");
1445- if (
1446- !Number.isSafeInteger(file.size) ||
1447- file.size < 0 ||
1448- file.size > MAX_FILE_BYTES
1449- )
1450+ if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");
1451+ if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_FILE_BYTES)
1452 throw Error("Files must be at most 256 MiB in the browser workspace.");
1453 total += file.size;
1454- if (total > MAX_FOLDER_BYTES)
1455- throw Error("The selected folder exceeds 1 GiB.");
1456+ if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");
1457 let directory = root;
1458 for (const name of path.slice(0, -1)) {
1459- if (directory.files.has(name))
1460- throw Error("A file and folder use the same name.");
1461- if (!directory.directories.has(name))
1462- directory.directories.set(name, folderNode());
1463+ if (directory.files.has(name)) throw Error("A file and folder use the same name.");
1464+ if (!directory.directories.has(name)) directory.directories.set(name, folderNode());
1465 directory = directory.directories.get(name);
1466 }
1467 const name = path.at(-1);
1468- if (directory.files.has(name) || directory.directories.has(name))
1469- throw Error("Duplicate folder entry.");
1470+ if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");
1471 directory.files.set(name, file);
1472 }
1473- return { root, name: commonRoot ? paths[0][0] : "folder" };
1474+ return {root, name: commonRoot ? paths[0][0] : "folder"};
1475 }
1476
1477 async function storeEncrypted(plaintext, type, signal) {
1478@@ -92,65 +64,44 @@
1479 hash: encrypted.hash,
1480 type,
1481 signal,
1482- authorize: (target, method, body) =>
1483- tiny.authorization(target, method, body),
1484+ authorize: (target, method, body) => tiny.authorization(target, method, body)
1485 });
1486- if (
1487- result.descriptor.sha256 !== encrypted.hash ||
1488- result.descriptor.size !== encrypted.ciphertext.length
1489- )
1490+ if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length)
1491 throw Error("Relay returned an unexpected blob descriptor.");
1492- return { hash: encrypted.hash, key: fromHex(encrypted.key) };
1493+ return {hash: encrypted.hash, key: fromHex(encrypted.key)};
1494 }
1495
1496 const manifestStore =
1497- (signal) =>
1498- async ({ bytes, node }) =>
1499- storeEncrypted(
1500- bytes,
1501- node.t === 2 || node.t === 3
1502- ? directoryType
1503- : "application/octet-stream",
1504- signal,
1505- );
1506- const rootReference = (details) => {
1507+ signal =>
1508+ async ({bytes, node}) =>
1509+ storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);
1510+ const rootReference = details => {
1511 const stored = details.manifests.at(-1);
1512 return {
1513 hash: stored.hash,
1514 key: stored.key,
1515 type: details.root.t,
1516- size: details.root.l.reduce((sum, link) => sum + link.s, 0),
1517+ size: details.root.l.reduce((sum, link) => sum + link.s, 0)
1518 };
1519 };
1520
1521 async function storeFile(file, signal, progress) {
1522- if (file.size > MAX_FILE_BYTES)
1523- throw Error("Files must be at most 256 MiB in the browser workspace.");
1524+ if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");
1525 const chunks = [];
1526- for (
1527- let offset = 0;
1528- offset < file.size || offset === 0;
1529- offset += codec().CHUNK_SIZE
1530- ) {
1531+ for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {
1532 checkAbort(signal);
1533- const plain = new Uint8Array(
1534- await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer(),
1535- );
1536- const stored = await storeEncrypted(
1537- plain,
1538- "application/octet-stream",
1539- signal,
1540- );
1541- chunks.push({ ...stored, size: plain.length });
1542+ const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());
1543+ const stored = await storeEncrypted(plain, "application/octet-stream", signal);
1544+ chunks.push({...stored, size: plain.length});
1545 progress?.(plain.length);
1546 if (file.size === 0) break;
1547 }
1548- if (chunks.length === 1) return { ...chunks[0], type: 0 };
1549+ if (chunks.length === 1) return {...chunks[0], type: 0};
1550 return rootReference(
1551 await codec().buildFile(chunks, {
1552 store: manifestStore(signal),
1553- returnDetails: true,
1554- }),
1555+ returnDetails: true
1556+ })
1557 );
1558 }
1559
1560@@ -161,18 +112,18 @@
1561 entries.push({
1562 ...stored,
1563 name,
1564- metadata: { type: file.type || "application/octet-stream" },
1565+ metadata: {type: file.type || "application/octet-stream"}
1566 });
1567 }
1568 for (const [name, child] of directory.directories) {
1569 const stored = await storeDirectory(child, signal, progress);
1570- entries.push({ ...stored, name });
1571+ entries.push({...stored, name});
1572 }
1573 return rootReference(
1574 await codec().buildDirectory(entries, {
1575 store: manifestStore(signal),
1576- returnDetails: true,
1577- }),
1578+ returnDetails: true
1579+ })
1580 );
1581 }
1582
1583@@ -184,7 +135,7 @@
1584 key: hex(reference.key),
1585 node: String(reference.type),
1586 name,
1587- type: type || "application/octet-stream",
1588+ type: type || "application/octet-stream"
1589 }).toString();
1590 return url.href;
1591 };
1592@@ -197,15 +148,13 @@
1593 '<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>';
1594 this.out = this.querySelector("output");
1595 for (const form of this.querySelectorAll("form"))
1596- form.addEventListener("submit", (event) => {
1597+ form.addEventListener("submit", event => {
1598 event.preventDefault();
1599- this.run(form).catch((error) => this.say(error.message, true));
1600+ this.run(form).catch(error => this.say(error.message, true));
1601 });
1602- this.querySelector("[data-cancel]").addEventListener("click", () =>
1603- this.controller?.abort(),
1604- );
1605+ this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());
1606 this.querySelector("[data-retry]").addEventListener("click", () =>
1607- this.run().catch((error) => this.say(error.message, true)),
1608+ this.run().catch(error => this.say(error.message, true))
1609 );
1610 }
1611 disconnectedCallback() {
1612@@ -221,10 +170,9 @@
1613 if (form) {
1614 const folder = form.elements.namedItem("folder");
1615 const input = folder || form.elements.namedItem("file");
1616- this.selection = { files: [...input.files], folder: Boolean(folder) };
1617+ this.selection = {files: [...input.files], folder: Boolean(folder)};
1618 }
1619- if (!this.selection?.files.length)
1620- throw Error("Choose a file or folder first.");
1621+ if (!this.selection?.files.length) throw Error("Choose a file or folder first.");
1622 this.busy = true;
1623 this.controller = new AbortController();
1624 this.querySelector("[data-cancel]").disabled = false;
1625@@ -233,23 +181,15 @@
1626 try {
1627 let reference, name, type;
1628 let sent = 0;
1629- const progress = (size) => {
1630+ const progress = size => {
1631 sent += size;
1632- this.say(
1633- "Stored " +
1634- sent.toLocaleString() +
1635- " bytes. Encrypting the remaining content…",
1636- );
1637+ this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…");
1638 };
1639 this.say("Encrypting selected content…");
1640 if (this.selection.folder) {
1641 const selected = selectionTree(this.selection.files);
1642 name = selected.name;
1643- reference = await storeDirectory(
1644- selected.root,
1645- this.controller.signal,
1646- progress,
1647- );
1648+ reference = await storeDirectory(selected.root, this.controller.signal, progress);
1649 } else {
1650 const file = this.selection.files[0];
1651 name = file.name;
1652@@ -262,9 +202,7 @@
1653 const open = this.querySelector("[data-open]");
1654 open.href = url;
1655 open.hidden = false;
1656- this.say(
1657- "Stored. Keep the complete share link to decrypt and browse this content.",
1658- );
1659+ this.say("Stored. Keep the complete share link to decrypt and browse this content.");
1660 completed = true;
1661 return url;
1662 } finally {
1663@@ -279,7 +217,7 @@
1664 async function fetchBlob(hash, signal) {
1665 fromHex(hash);
1666 const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {
1667- signal,
1668+ signal
1669 });
1670 if (!response.ok) throw Error("Download failed (" + response.status + ").");
1671 if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)
1672@@ -287,15 +225,14 @@
1673 const reader = response.body?.getReader();
1674 if (!reader) {
1675 const bytes = new Uint8Array(await response.arrayBuffer());
1676- if (bytes.length > MAX_FETCH_BYTES)
1677- throw Error("Linked blob exceeds the browser fetch limit.");
1678+ if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");
1679 return bytes;
1680 }
1681 const parts = [];
1682 let size = 0;
1683 try {
1684 for (;;) {
1685- const { value, done } = await reader.read();
1686+ const {value, done} = await reader.read();
1687 if (done) break;
1688 size += value.length;
1689 if (size > MAX_FETCH_BYTES) {
1690@@ -319,29 +256,22 @@
1691 async function plaintext(reference, signal) {
1692 const hash = hex(reference.h);
1693 const raw = await fetchBlob(hash, signal);
1694- if (hex(await codec().sha256(raw)) !== hash)
1695- throw Error("Blob hash mismatch.");
1696- return reference.k
1697- ? encryption().decryptCHK(raw, hex(reference.k), hash)
1698- : raw;
1699+ if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");
1700+ return reference.k ? encryption().decryptCHK(raw, hex(reference.k), hash) : raw;
1701 }
1702- const decrypt = (raw, link) =>
1703- encryption().decryptCHK(raw, hex(link.k), hex(link.h));
1704+ const decrypt = (raw, link) => encryption().decryptCHK(raw, hex(link.k), hex(link.h));
1705
1706 class Root extends HTMLElement {
1707 connectedCallback() {
1708 if (this.bound) return;
1709 this.bound = true;
1710 const params = new URLSearchParams(location.hash.slice(1));
1711- if (
1712- !params.has("manifest") &&
1713- this.getAttribute("type") !== directoryType
1714- ) {
1715+ if (!params.has("manifest") && this.getAttribute("type") !== directoryType) {
1716 this.hidden = true;
1717 return;
1718 }
1719 this.hidden = false;
1720- this.load().catch((error) => this.fail(error.message));
1721+ this.load().catch(error => this.fail(error.message));
1722 }
1723 disconnectedCallback() {
1724 this.controller?.abort();
1725@@ -361,26 +291,20 @@
1726 this.controller = new AbortController();
1727 const params = new URLSearchParams(location.hash.slice(1));
1728 const reference = {
1729- h: fromHex(params.get("manifest") || this.getAttribute("hash")),
1730+ h: fromHex(params.get("manifest") || this.getAttribute("hash"))
1731 };
1732 if (params.has("key")) reference.k = fromHex(params.get("key"));
1733 const type = params.has("node") ? Number(params.get("node")) : 2;
1734- if (![0, 1, 2, 3].includes(type))
1735- throw Error("Unsupported manifest type.");
1736+ if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");
1737 const data = await plaintext(reference, this.controller.signal);
1738 const heading = element("h2", params.get("name") || "Stored content");
1739 this.replaceChildren(heading);
1740 if (type === 0) {
1741- this.offerDownload(
1742- data,
1743- params.get("name") || "file",
1744- params.get("type"),
1745- );
1746+ this.offerDownload(data, params.get("name") || "file", params.get("type"));
1747 return;
1748 }
1749 const root = codec().decodeManifest(data);
1750- if (root.t !== type)
1751- throw Error("Manifest type does not match its reference.");
1752+ if (root.t !== type) throw Error("Manifest type does not match its reference.");
1753 if (type === 1) {
1754 const button = element("button", "Download decrypted file");
1755 button.type = "button";
1756@@ -390,22 +314,18 @@
1757 t: 1,
1758 s: root.l.reduce((sum, link) => sum + link.s, 0),
1759 n: params.get("name") || "file",
1760- m: { type: params.get("type") },
1761- }).catch((error) => this.fail(error.message)),
1762+ m: {type: params.get("type")}
1763+ }).catch(error => this.fail(error.message))
1764 );
1765 this.append(button);
1766 return;
1767 }
1768- const entries = await codec().resolveDirectory(
1769- root,
1770- (hash) => fetchBlob(hash, this.controller.signal),
1771- {
1772- decrypt,
1773- maxDepth: MAX_DEPTH,
1774- maxEntries: MAX_FILES,
1775- maxBytes: 64 * 1024 * 1024,
1776- },
1777- );
1778+ const entries = await codec().resolveDirectory(root, hash => fetchBlob(hash, this.controller.signal), {
1779+ decrypt,
1780+ maxDepth: MAX_DEPTH,
1781+ maxEntries: MAX_FILES,
1782+ maxBytes: 64 * 1024 * 1024
1783+ });
1784 const list = element("ul");
1785 if (!entries.length) this.append(element("p", "This folder is empty."));
1786 for (const link of entries) {
1787@@ -417,21 +337,16 @@
1788 const fragment = new URLSearchParams({
1789 manifest: hex(link.h),
1790 node: String(link.t),
1791- name: link.n,
1792+ name: link.n
1793 });
1794 if (link.k) fragment.set("key", hex(link.k));
1795 url.hash = fragment.toString();
1796 open.href = url.href;
1797 item.append(open);
1798 } else {
1799- const button = element(
1800- "button",
1801- link.n + " (" + link.s.toLocaleString() + " bytes)",
1802- );
1803+ const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");
1804 button.type = "button";
1805- button.addEventListener("click", () =>
1806- this.download(link).catch((error) => this.fail(error.message)),
1807- );
1808+ button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));
1809 item.append(button);
1810 }
1811 list.append(item);
1812@@ -443,25 +358,20 @@
1813 let data;
1814 if (link.t === 0) data = await plaintext(link, this.controller.signal);
1815 else if (link.t === 1) {
1816- const manifest = codec().decodeManifest(
1817- await plaintext(link, this.controller.signal),
1818- );
1819- data = await codec().readFile(
1820- manifest,
1821- (hash) => fetchBlob(hash, this.controller.signal),
1822- { decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES },
1823- );
1824+ const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));
1825+ data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {
1826+ decrypt,
1827+ maxDepth: MAX_DEPTH,
1828+ maxBytes: MAX_FILE_BYTES
1829+ });
1830 } else throw Error("Unsupported file link.");
1831- if (data.length !== link.s)
1832- throw Error("File size does not match its directory entry.");
1833+ if (data.length !== link.s) throw Error("File size does not match its directory entry.");
1834 this.offerDownload(data, link.n, link.m?.type);
1835 return data;
1836 }
1837 offerDownload(data, name, type) {
1838 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
1839- this.downloadURL = URL.createObjectURL(
1840- new Blob([data], { type: type || "application/octet-stream" }),
1841- );
1842+ this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));
1843 const link = element("a", "Save " + name);
1844 link.href = this.downloadURL;
1845 link.download = name;
1846diff --git a/internal/webui/private-services.js b/internal/webui/private-services.js
1847index c931348..1435b7b 100644
1848--- a/internal/webui/private-services.js
1849+++ b/internal/webui/private-services.js
1850@@ -3,70 +3,28 @@
1851 (() => {
1852 "use strict";
1853
1854- const validURL = (value) => {
1855- try {
1856- const parsed = new URL(String(value).trim());
1857- return (
1858- (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&
1859- parsed.hostname !== "" &&
1860- !parsed.username &&
1861- !parsed.password &&
1862- parsed.search === "" &&
1863- parsed.hash === ""
1864- );
1865- } catch {
1866- return false;
1867- }
1868- };
1869- const relay = (value) => {
1870- const parsed = new URL(String(value || "").trim());
1871- parsed.hostname = parsed.hostname.toLowerCase();
1872- if (parsed.pathname.length > 1)
1873- parsed.pathname = parsed.pathname.replace(/\/+$/, "");
1874- return parsed.toString().replace(/\/$/, "");
1875- };
1876+ const {relayURL} = window.tiny.util;
1877+ const {signer, nip44Encrypt: encrypt, nip44Decrypt: decrypt} = window.tiny;
1878+ const validURL = value => relayURL(value, {strict: true}) !== null;
1879+ const relay = value => relayURL(value, {strict: true});
1880 const mergeRows = (rows, urls) =>
1881- rows
1882- .filter((row) => !(Array.isArray(row) && row[0] === "g"))
1883- .concat(urls.map((value) => ["g", value]));
1884- const signer = () => window.tinySigner || window.nostr;
1885+ rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));
1886 const publicKey = async () => {
1887 const active = signer();
1888 if (active?.getPublicKey) return active.getPublicKey();
1889 if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();
1890 throw Error("Connect a signer first.");
1891 };
1892- const encrypt = async (pubkey, text) => {
1893- const active = signer();
1894- if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);
1895- if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);
1896- if (window.tiny?.nip44Encrypt)
1897- return window.tiny.nip44Encrypt(pubkey, text);
1898- throw Error("The connected signer does not support NIP-44.");
1899- };
1900- const decrypt = async (pubkey, text) => {
1901- const active = signer();
1902- if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);
1903- if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);
1904- if (window.tiny?.nip44Decrypt)
1905- return window.tiny.nip44Decrypt(pubkey, text);
1906- throw Error("The connected signer does not support NIP-44.");
1907- };
1908- const readList = async (pubkey) => {
1909- const body = JSON.stringify([
1910- { kinds: [10318], authors: [pubkey], limit: 1 },
1911- ]);
1912+ const readList = async pubkey => {
1913+ const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);
1914 const response = await window.tiny.signedFetch("/query", "POST", body, {
1915- contentType: "application/json",
1916+ contentType: "application/json"
1917 });
1918- if (!response || response.ok === false)
1919- throw Error("Could not read the private relay list.");
1920+ if (!response || response.ok === false) throw Error("Could not read the private relay list.");
1921 const rows = await response.json();
1922- if (!Array.isArray(rows) || rows.length > 1)
1923- throw Error("The private relay list response is malformed.");
1924+ if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");
1925 const event = rows[0];
1926- if (rows.length && !acceptEvent(event, pubkey))
1927- throw Error("The private relay list event is invalid.");
1928+ if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");
1929 return event;
1930 };
1931 const acceptEvent = (event, expectedPubkey) => {
1932@@ -85,13 +43,10 @@
1933 return false;
1934 }
1935 };
1936- const publish = async (event) => {
1937- const response = await window.tiny.signedFetch(
1938- "/events",
1939- "POST",
1940- JSON.stringify(event),
1941- { contentType: "application/json" },
1942- );
1943+ const publish = async event => {
1944+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {
1945+ contentType: "application/json"
1946+ });
1947 const text = await response.text();
1948 let value;
1949 try {
1950@@ -100,15 +55,13 @@
1951 value = undefined;
1952 }
1953 if (value?.error || value?.accepted === false)
1954- throw Error(
1955- value.error || value.message || "The relay rejected the event.",
1956- );
1957+ throw Error(value.error || value.message || "The relay rejected the event.");
1958 };
1959- const wireEvent = (content) => ({
1960+ const wireEvent = content => ({
1961 kind: 10318,
1962 created_at: Math.floor(Date.now() / 1000),
1963 tags: [],
1964- content,
1965+ content
1966 });
1967
1968 class PrivateServices extends HTMLElement {
1969@@ -118,13 +71,13 @@
1970 this.form = this.querySelector("form");
1971 this.lines = this.querySelector("textarea[name=lines]");
1972 this.output = this.querySelector("output");
1973- this.form?.addEventListener("submit", (event) => {
1974+ this.form?.addEventListener("submit", event => {
1975 event.preventDefault();
1976 if (this.busy) return;
1977 this.busy = true;
1978 this.form.querySelector("button").disabled = true;
1979 this.save()
1980- .catch((error) => this.say("Error: " + error.message, true))
1981+ .catch(error => this.say("Error: " + error.message, true))
1982 .finally(() => {
1983 this.busy = false;
1984 this.form.querySelector("button").disabled = false;
1985@@ -135,7 +88,7 @@
1986 .then(() => {
1987 this.form.querySelector("button").disabled = false;
1988 })
1989- .catch((error) => this.say("Error: " + error.message, true)),
1990+ .catch(error => this.say("Error: " + error.message, true))
1991 );
1992 }
1993
1994@@ -144,7 +97,7 @@
1995 whenSigned(fn) {
1996 if (signer()?.signEvent || window.nostr?.signEvent) return fn();
1997 this.say("Connect a signer to edit this list.");
1998- document.addEventListener("tiny:signer", () => fn(), { once: true });
1999+ document.addEventListener("tiny:signer", () => fn(), {once: true});
2000 }
2001
2002 say(text, error) {
2003@@ -162,32 +115,21 @@
2004 return;
2005 }
2006 const rows = JSON.parse(await decrypt(pubkey, event.content));
2007- if (!Array.isArray(rows))
2008- throw Error("The encrypted list is not a JSON array.");
2009+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
2010 const urls = rows
2011- .filter(
2012- (row) => Array.isArray(row) && row[0] === "g" && validURL(row[1]),
2013- )
2014- .map((row) => relay(row[1]));
2015+ .filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1]))
2016+ .map(row => relay(row[1]));
2017 this.lines.value = urls.join("\n");
2018- this.say(
2019- "Loaded " +
2020- urls.length +
2021- " private relay" +
2022- (urls.length === 1 ? "" : "s") +
2023- ".",
2024- );
2025+ this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");
2026 }
2027
2028 async save() {
2029 const rawURLs = this.lines.value
2030 .split(/\r?\n/)
2031- .map((value) => value.trim())
2032+ .map(value => value.trim())
2033 .filter(Boolean);
2034- if (rawURLs.some((value) => !validURL(value)))
2035- throw Error(
2036- "Use ws:// or wss:// URLs without credentials, queries or fragments.",
2037- );
2038+ if (rawURLs.some(value => !validURL(value)))
2039+ throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");
2040 const urls = rawURLs.map(relay);
2041 const unique = [...new Set(urls)];
2042 const pubkey = await publicKey();
2043@@ -195,28 +137,17 @@
2044 let rows = [];
2045 if (existing) {
2046 rows = JSON.parse(await decrypt(pubkey, existing.content));
2047- if (!Array.isArray(rows))
2048- throw Error("The encrypted list is not a JSON array.");
2049+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");
2050 }
2051 rows = mergeRows(rows, unique);
2052 const content = await encrypt(pubkey, JSON.stringify(rows));
2053 const active = signer();
2054 if (!active?.signEvent) throw Error("Connect a signer first.");
2055 const event = await active.signEvent(wireEvent(content));
2056- if (
2057- !acceptEvent(event, pubkey) ||
2058- event.content !== content ||
2059- event.tags.length !== 0
2060- )
2061+ if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0)
2062 throw Error("The signer returned an invalid private relay list event.");
2063 await publish(event);
2064- this.say(
2065- "Published " +
2066- unique.length +
2067- " private relay" +
2068- (unique.length === 1 ? "" : "s") +
2069- ".",
2070- );
2071+ this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");
2072 }
2073 }
2074
2075@@ -228,9 +159,9 @@
2076 decrypt,
2077 wireEvent,
2078 acceptEvent,
2079- readList,
2080+ readList
2081 });
2082 window.tiny = window.tiny || {};
2083- window.tiny.files = { ...window.tiny.files, privateServices: api };
2084+ window.tiny.files = {...window.tiny.files, privateServices: api};
2085 customElements.define("private-services", PrivateServices);
2086 })();
2087diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
2088index 79d6c74..755e3e8 100644
2089--- a/internal/webui/tiny.js
2090+++ b/internal/webui/tiny.js
2091@@ -1,4 +1,5 @@
2092-// Shared browser helpers. This file is loaded before feature modules.
2093+// Shared browser helpers. This file is loaded before every feature module
2094+// and the signer bridge, so each helper exists exactly once.
2095 (() => {
2096 "use strict";
2097 const tiny = globalThis.tiny || {};
2098@@ -10,7 +11,52 @@
2099 };
2100 const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");
2101 const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));
2102- tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256});
2103+ // fromHex parses a 64-character lowercase hex hash or key.
2104+ const fromHex = (value, message = "expected a 64-character lowercase hex value") => {
2105+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw Error(message);
2106+ return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));
2107+ };
2108+ const element = (tag, text) => {
2109+ const node = document.createElement(tag);
2110+ if (text !== undefined) node.textContent = text;
2111+ return node;
2112+ };
2113+ // relayURL normalizes a ws or wss URL without credentials or a fragment, or
2114+ // returns null. Strict mode also rejects query strings, which operator-entered
2115+ // relay lists never need.
2116+ const relayURL = (value, {strict = false} = {}) => {
2117+ if (typeof value !== "string" || value.length > 2048) return null;
2118+ let url;
2119+ try {
2120+ url = new URL(value.trim());
2121+ } catch {
2122+ return null;
2123+ }
2124+ if (
2125+ (url.protocol !== "wss:" && url.protocol !== "ws:") ||
2126+ !url.hostname ||
2127+ url.username ||
2128+ url.password ||
2129+ url.hash
2130+ )
2131+ return null;
2132+ if (strict && url.search) return null;
2133+ url.hostname = url.hostname.toLowerCase();
2134+ if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
2135+ return url.toString().replace(/\/$/, "");
2136+ };
2137+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL});
2138+ // signer returns the active signer: a resumed remote signer first, then a
2139+ // NIP-07 extension.
2140+ tiny.signer = () => globalThis.tinySigner || globalThis.nostr;
2141+ const nip44 = (method, pubkey, text, signer = tiny.signer()) => {
2142+ if (typeof signer?.["nip44" + method] === "function") return signer["nip44" + method](pubkey, text);
2143+ const inner = signer?.nip44?.[method.toLowerCase()];
2144+ if (typeof inner === "function") return inner.call(signer.nip44, pubkey, text);
2145+ throw Error("The connected signer does not support NIP-44.");
2146+ };
2147+ tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);
2148+ tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);
2149 // Feature modules add to these groups; guards can read them before load.
2150 tiny.blossom = tiny.blossom || {};
2151 tiny.files = tiny.files || {};
2152diff --git a/scripts/file-messages.test.mjs b/scripts/file-messages.test.mjs
2153index 72cd9bd..c492835 100644
2154--- a/scripts/file-messages.test.mjs
2155+++ b/scripts/file-messages.test.mjs
2156@@ -6,6 +6,7 @@ import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, ni
2157
2158 globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data };
2159 globalThis.tiny = {files: {}};
2160+vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"), { filename: "tiny.js" });
2161 vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), { filename: "file-messages.js" });
2162 const fileMessages = globalThis.tiny.files.messages;
2163
2164diff --git a/scripts/private-services.test.mjs b/scripts/private-services.test.mjs
2165index e238296..a2da3d3 100644
2166--- a/scripts/private-services.test.mjs
2167+++ b/scripts/private-services.test.mjs
2168@@ -3,6 +3,7 @@ import {readFile} from "node:fs/promises";
2169 import test from "node:test";
2170 import vm from "node:vm";
2171
2172+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");
2173 const source = await readFile(new URL("../internal/webui/private-services.js", import.meta.url), "utf8");
2174 class Element {}
2175 const sandbox = {
2176@@ -19,7 +20,7 @@ const sandbox = {
2177 };
2178 sandbox.window = sandbox;
2179 sandbox.tiny = {files: {}};
2180-vm.runInNewContext(source, sandbox);
2181+vm.runInNewContext(shared + source, sandbox);
2182 const api = sandbox.tiny.files.privateServices;
2183
2184 test("accepts tenant paths while rejecting unsafe relay URLs", () => {

About

ownerc01503e8035f
visibilitypublic
head3d07a13fd864

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs