- commit
- 3d07a13fd864cf8e20b351fc0e0de3253b2028df
- parent
- 741d106cbfcf999deb12dda48b43356ccf4b0bda
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 77 | +++------ |
| M | internal/ | 225 | +++++++++----------------- |
| M | internal/ | 152 | +++++------------- |
| M | internal/ | 2 | +- |
| M | internal/ | 214 | +++++++++----------------- |
| M | internal/ | 254 | ++++++++++-------------------- |
| M | internal/ | 137 | ++++------------ |
| M | internal/ | 50 | ++++++- |
| M | scripts/ | 1 | + |
| M | scripts/ | 3 | +- |
@@ -10,44 +10,29 @@
1010 const info = encoder.encode("encryption-key");1111 1212 const {bytes, hex, sha256: digest} = globalThis.tiny.util;13- const fromHex = (value) => {14- if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))15- throw new Error("expected a 64-character lowercase hex key");16- const result = new Uint8Array(32);17- for (let index = 0; index < result.length; index++)18- result[index] = parseInt(value.slice(index * 2, index * 2 + 2), 16);19- return result;20- };21- const equal = (left, right) =>22- left.length === right.length &&23- left.every((value, index) => value === right[index]);24- const aesKey = async (key) => {25- const material = await crypto.subtle.importKey("raw", key, "HKDF", false, [26- "deriveKey",27- ]);13+ const fromHex = value => globalThis.tiny.util.fromHex(value, "expected a 64-character lowercase hex key");14+ const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);15+ const aesKey = async key => {16+ const material = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);2817 return crypto.subtle.deriveKey(29- { name: "HKDF", hash: "SHA-256", salt, info },18+ {name: "HKDF", hash: "SHA-256", salt, info},3019 material,31- { name: "AES-GCM", length: 256 },20+ {name: "AES-GCM", length: 256},3221 false,33- ["encrypt", "decrypt"],22+ ["encrypt", "decrypt"]3423 );3524 };3625 37- const encryptCHK = async (plaintext) => {26+ const encryptCHK = async plaintext => {3827 const input = bytes(plaintext);3928 const key = await digest(input);4029 const encrypted = new Uint8Array(41- await crypto.subtle.encrypt(42- { name: "AES-GCM", iv: zeroNonce },43- await aesKey(key),44- input,45- ),30+ await crypto.subtle.encrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(key), input)4631 );4732 return {4833 ciphertext: encrypted,4934 key: hex(key),50- hash: hex(await digest(encrypted)),35+ hash: hex(await digest(encrypted))5136 };5237 };5338 @@ -56,55 +41,39 @@
5641 const chkKey = fromHex(key);5742 if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))5843 throw new Error("expected a 64-character lowercase blob hash");59- if (hex(await digest(input)) !== hash)60- throw new Error("ciphertext hash mismatch");44+ if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch");6145 let plain;6246 try {6347 plain = new Uint8Array(64- await crypto.subtle.decrypt(65- { name: "AES-GCM", iv: zeroNonce },66- await aesKey(chkKey),67- input,68- ),48+ await crypto.subtle.decrypt({name: "AES-GCM", iv: zeroNonce}, await aesKey(chkKey), input)6949 );7050 } catch {7151 throw new Error("CHK decryption failed");7252 }73- if (!equal(await digest(plain), chkKey))74- throw new Error("plaintext hash mismatch");53+ if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch");7554 return plain;7655 };7756 7857 const createURI = (hash, extension, key) => {7958 if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash))8059 throw new Error("expected a 64-character lowercase blob hash");81- const path =82- "blossom:" +83- hash +84- (extension ? "." + String(extension).replace(/^\.+/, "") : "");60+ const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "") : "");8561 fromHex(key);86- const params = new URLSearchParams({ enc: "chk-v1", k: key });62+ const params = new URLSearchParams({enc: "chk-v1", k: key});8763 return path + "?" + params;8864 };8965 90- const parseURI = (value) => {91- if (typeof value !== "string" || !value.startsWith("blossom:"))92- throw new Error("expected a Blossom URI");93- const parsed = new URL(94- value.slice("blossom:".length),95- "https://blossom.invalid",96- );97- const match = parsed.pathname.match(98- /^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/,99- );100- if (!match || parsed.searchParams.get("enc") !== "chk-v1")101- throw new Error("invalid CHK Blossom URI");66+ const parseURI = value => {67+ if (typeof value !== "string" || !value.startsWith("blossom:")) throw new Error("expected a Blossom URI");68+ const parsed = new URL(value.slice("blossom:".length), "https://blossom.invalid");69+ const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/);70+ if (!match || parsed.searchParams.get("enc") !== "chk-v1") throw new Error("invalid CHK Blossom URI");10271 const key = parsed.searchParams.get("k");10372 fromHex(key);104- return { hash: match[1], extension: match[2] || "", key };73+ return {hash: match[1], extension: match[2] || "", key};10574 };10675 107- const api = Object.freeze({ encryptCHK, decryptCHK, createURI, parseURI });76+ const api = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});10877 globalThis.tiny = globalThis.tiny || {};109- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, encryption: api };78+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, encryption: api};11079 })();@@ -7,34 +7,25 @@
77 const MAX_DEPTH = 64;88 const MAX_MANIFEST_BYTES = 16 * 1024 * 1024;99 const textEncoder = new TextEncoder(),10- textDecoder = new TextDecoder("utf-8", { fatal: true });10+ textDecoder = new TextDecoder("utf-8", {fatal: true});1111 const {bytes: asBytes, hex, sha256} = globalThis.tiny.util;12- const hashBytes = (h) => {13- if (typeof h !== "string" || !/^[0-9a-f]{64}$/.test(h))14- throw new Error("expected lowercase SHA-256 hash");15- return Uint8Array.from(h.match(/../g), (x) => parseInt(x, 16));16- };17- const keyBytes = (k) => {12+ const hashBytes = h => globalThis.tiny.util.fromHex(h, "expected lowercase SHA-256 hash");13+ const keyBytes = k => {1814 if (k === undefined) return;1915 const b = asBytes(k);2016 if (b.length !== 32) throw new Error("manifest keys must be 32 bytes");2117 return b;2218 };23- const wellFormed = (value) => {19+ const wellFormed = value => {2420 for (let i = 0; i < value.length; i++) {2521 const c = value.charCodeAt(i);2622 if (c >= 55296 && c <= 56319) {27- if (28- ++i >= value.length ||29- value.charCodeAt(i) < 56320 ||30- value.charCodeAt(i) > 5734331- )32- return false;23+ if (++i >= value.length || value.charCodeAt(i) < 56320 || value.charCodeAt(i) > 57343) return false;3324 } else if (c >= 56320 && c <= 57343) return false;3425 }3526 return true;3627 };37- const nameOK = (n) =>28+ const nameOK = n =>3829 typeof n === "string" &&3930 n.length > 0 &&4031 n !== "." &&@@ -43,12 +34,11 @@
4334 !n.includes("/") &&4435 !n.includes("\0") &&4536 textEncoder.encode(n).length <= 1024;46- const int = (n) => Number.isSafeInteger(n) && n >= 0;37+ const int = n => Number.isSafeInteger(n) && n >= 0;4738 const utf8cmp = (a, b) => {4839 const x = textEncoder.encode(a),4940 y = textEncoder.encode(b);50- for (let i = 0; i < Math.min(x.length, y.length); i++)51- if (x[i] !== y[i]) return x[i] - y[i];41+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];5242 return x.length - y.length;5343 };5444 class MessagePackWriter {@@ -66,8 +56,7 @@
6656 }6757 }6858 const writeInteger = (w, n) => {69- if (!Number.isSafeInteger(n))70- throw new Error("manifest integers must be safe integers");59+ if (!Number.isSafeInteger(n)) throw new Error("manifest integers must be safe integers");7160 if (n >= 0) {7261 if (n < 128) w.u8(n);7362 else if (n < 256) w.push(204, n);@@ -121,7 +110,7 @@
121110 if (v.length < 16) w.u8(144 | v.length);122111 else if (v.length < 65536) w.push(220, v.length >> 8, v.length);123112 else throw new Error("array too large");124- v.forEach((x) => writeValue(w, x));113+ v.forEach(x => writeValue(w, x));125114 return;126115 }127116 if (v && typeof v === "object") {@@ -129,7 +118,7 @@
129118 if (keys.length < 16) w.u8(128 | keys.length);130119 else if (keys.length < 65536) w.push(222, keys.length >> 8, keys.length);131120 else throw new Error("map too large");132- keys.forEach((k) => {121+ keys.forEach(k => {133122 writeValue(w, k);134123 writeValue(w, v[k]);135124 });@@ -160,8 +149,7 @@
160149 const o = Object.create(null);161150 for (let i = 0; i < n; i++) {162151 const k = readValue(r, depth + 1);163- if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k))164- throw Error("invalid map key");152+ if (typeof k !== "string" || Object.prototype.hasOwnProperty.call(o, k)) throw Error("invalid map key");165153 o[k] = readValue(r, depth + 1);166154 }167155 return o;@@ -172,8 +160,7 @@
172160 if (t <= 127) return t;173161 if (t >= 224) return t - 256;174162 if ((t & 224) === 160) return textDecoder.decode(r.n(t & 31));175- if ((t & 240) === 144)176- return Array.from({ length: t & 15 }, () => readValue(r, depth + 1));163+ if ((t & 240) === 144) return Array.from({length: t & 15}, () => readValue(r, depth + 1));177164 if ((t & 240) === 128) return readMap(r, t & 15, depth);178165 if (t === 192) return null;179166 if (t === 194) return false;@@ -181,13 +168,11 @@
181168 let n;182169 if (t === 204) n = r.u8();183170 else if (t === 205) n = (r.u8() << 8) | r.u8();184- else if (t === 206)185- n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();171+ else if (t === 206) n = r.u8() * 16777216 + (r.u8() << 16) + (r.u8() << 8) + r.u8();186172 else if (t === 207) {187173 let x = 0n;188174 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());189- if (x > BigInt(Number.MAX_SAFE_INTEGER))190- throw Error("integer exceeds safe range");175+ if (x > BigInt(Number.MAX_SAFE_INTEGER)) throw Error("integer exceeds safe range");191176 n = Number(x);192177 } else if (t === 208) n = (r.u8() << 24) >> 24;193178 else if (t === 209) {@@ -200,10 +185,7 @@
200185 let x = 0n;201186 for (let i = 0; i < 8; i++) x = (x << 8n) | BigInt(r.u8());202187 if (x & 0x8000000000000000n) x -= 0x10000000000000000n;203- if (204- x > BigInt(Number.MAX_SAFE_INTEGER) ||205- x < BigInt(Number.MIN_SAFE_INTEGER)206- )188+ if (x > BigInt(Number.MAX_SAFE_INTEGER) || x < BigInt(Number.MIN_SAFE_INTEGER))207189 throw Error("integer exceeds safe range");208190 n = Number(x);209191 } else if (t === 202) {@@ -222,7 +204,7 @@
222204 else if (t === 197) n = r.n((r.u8() << 8) | r.u8());223205 else if (t === 220) {224206 n = (r.u8() << 8) | r.u8();225- return Array.from({ length: n }, () => readValue(r, depth + 1));207+ return Array.from({length: n}, () => readValue(r, depth + 1));226208 } else if (t === 222) {227209 n = (r.u8() << 8) | r.u8();228210 return readMap(r, n, depth);@@ -231,19 +213,12 @@
231213 };232214 const validateMeta = (m, depth = 0) => {233215 if (m === undefined) return;234- if (235- depth > MAX_DEPTH ||236- !m ||237- Array.isArray(m) ||238- typeof m !== "object" ||239- m instanceof Uint8Array240- )216+ if (depth > MAX_DEPTH || !m || Array.isArray(m) || typeof m !== "object" || m instanceof Uint8Array)241217 throw Error("metadata must be a JSON map");242218 const keys = Object.keys(m);243219 if (keys.length > 64) throw Error("too many metadata keys");244220 for (const [k, v] of Object.entries(m)) {245- if (typeof k !== "string" || textEncoder.encode(k).length > 256)246- throw Error("invalid metadata key");221+ if (typeof k !== "string" || textEncoder.encode(k).length > 256) throw Error("invalid metadata key");247222 if (248223 v === undefined ||249224 typeof v === "bigint" ||@@ -255,32 +230,25 @@
255230 if (v && typeof v === "object") {256231 if (Array.isArray(v)) {257232 if (v.length > 256) throw Error("metadata array too large");258- v.forEach((x) => validateMeta({ x }, depth + 1));233+ v.forEach(x => validateMeta({x}, depth + 1));259234 } else validateMeta(v, depth + 1);260235 }261236 }262237 };263238 const validateLink = (x, nodeType) => {264- if (!x || typeof x !== "object" || Array.isArray(x))265- throw Error("invalid manifest link");266- if (Object.keys(x).some((k) => !["h", "k", "m", "n", "s", "t"].includes(k)))239+ if (!x || typeof x !== "object" || Array.isArray(x)) throw Error("invalid manifest link");240+ if (Object.keys(x).some(k => !["h", "k", "m", "n", "s", "t"].includes(k)))267241 throw Error("unknown manifest link field");268- if (!(x.h instanceof Uint8Array) || x.h.length !== 32)269- throw Error("link hash must be 32 bytes");242+ if (!(x.h instanceof Uint8Array) || x.h.length !== 32) throw Error("link hash must be 32 bytes");270243 if (x.k !== undefined) keyBytes(x.k);271244 if (x.m !== undefined) validateMeta(x.m);272245 if (!int(x.s)) throw Error("invalid link size");273246 if (!int(x.t) || x.t < 0 || x.t > 3) throw Error("unsupported link type");274- if (nodeType === 1 && x.t !== 0 && x.t !== 1)275- throw Error("invalid file link type");276- if (nodeType === 1 && x.n !== undefined)277- throw Error("file links cannot have names");278- if (nodeType === 3 && x.n !== undefined)279- throw Error("fanout links cannot have names");280- if (nodeType === 3 && x.t !== 2 && x.t !== 3)281- throw Error("invalid fanout child type");282- if (nodeType === 2 && !nameOK(x.n))283- throw Error("invalid directory entry name");247+ if (nodeType === 1 && x.t !== 0 && x.t !== 1) throw Error("invalid file link type");248+ if (nodeType === 1 && x.n !== undefined) throw Error("file links cannot have names");249+ if (nodeType === 3 && x.n !== undefined) throw Error("fanout links cannot have names");250+ if (nodeType === 3 && x.t !== 2 && x.t !== 3) throw Error("invalid fanout child type");251+ if (nodeType === 2 && !nameOK(x.n)) throw Error("invalid directory entry name");284252 };285253 const validateNode = (node, depth = 0) => {286254 if (depth > MAX_DEPTH) throw Error("manifest recursion limit exceeded");@@ -291,7 +259,7 @@
291259 !Array.isArray(node.l) ||292260 !int(node.t) ||293261 ![1, 2, 3].includes(node.t) ||294- Object.keys(node).some((k) => k !== "l" && k !== "t")262+ Object.keys(node).some(k => k !== "l" && k !== "t")295263 )296264 throw Error("invalid manifest node");297265 if (node.l.length > MAX_LINKS) throw Error("too many manifest links");@@ -315,14 +283,11 @@
315283 throw Error("invalid fanout bounds");316284 }317285 }318- if (319- node.t === 2 &&320- links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0)321- )286+ if (node.t === 2 && links.some((x, i) => i && utf8cmp(links[i - 1].n, x.n) >= 0))322287 throw Error("directory links are not canonical");323288 return node;324289 };325- const encodeManifest = (node) => {290+ const encodeManifest = node => {326291 validateNode(node);327292 const w = new MessagePackWriter();328293 w.u8(130);@@ -336,7 +301,7 @@
336301 ...(x.m !== undefined ? ["m"] : []),337302 ...(x.n !== undefined ? ["n"] : []),338303 "s",339- "t",304+ "t"340305 ];341306 if (fields.length < 16) w.u8(128 | fields.length);342307 else w.push(222, fields.length >> 8, fields.length);@@ -360,78 +325,71 @@
360325 validateNode(node);361326 if (node.t === 3)362327 for (const x of node.l) {363- if (x.m.count > Number(options.maxEntries || 1e9))364- throw Error("fanout count limit exceeded");328+ if (x.m.count > Number(options.maxEntries || 1e9)) throw Error("fanout count limit exceeded");365329 }366330 return node;367331 };368- const manifestHash = async (node) => hex(await sha256(encodeManifest(node)));332+ const manifestHash = async node => hex(await sha256(encodeManifest(node)));369333 const leaf = (hash, size, key) => {370- const x = { h: hashBytes(hash), s: size, t: 0 };334+ const x = {h: hashBytes(hash), s: size, t: 0};371335 if (key !== undefined) x.k = keyBytes(key);372336 return x;373337 };374338 const storeNode = async (node, options, manifests) => {375339 const bytes = encodeManifest(node),376340 computed = hex(await sha256(bytes));377- let result = { hash: computed };341+ let result = {hash: computed};378342 if (options.store) {379- const stored = await options.store({ hash: computed, bytes, node });343+ const stored = await options.store({hash: computed, bytes, node});380344 if (stored) {381345 if (typeof stored === "string") result.hash = stored;382- else result = { ...result, ...stored };346+ else result = {...result, ...stored};383347 }384348 }385- if (!/^[0-9a-f]{64}$/.test(result.hash))386- throw Error("store returned invalid manifest hash");349+ if (!/^[0-9a-f]{64}$/.test(result.hash)) throw Error("store returned invalid manifest hash");387350 if (result.key !== undefined) keyBytes(result.key);388- manifests.push({ hash: result.hash, bytes, node, key: result.key });351+ manifests.push({hash: result.hash, bytes, node, key: result.key});389352 return result;390353 };391354 const buildFile = async (chunks, options = {}) => {392355 const max = options.maxLinks ?? MAX_LINKS;393- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)394- throw Error("invalid maxLinks");395- const leaves = chunks.map((c) =>396- typeof c === "string"397- ? leaf(c, options.chunkSize || 0)398- : leaf(c.hash, c.size, c.key),356+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");357+ const leaves = chunks.map(c =>358+ typeof c === "string" ? leaf(c, options.chunkSize || 0) : leaf(c.hash, c.size, c.key)399359 );400- if (leaves.some((x) => x.s < 0 || x.s > CHUNK_SIZE))401- throw Error("invalid chunk size");360+ if (leaves.some(x => x.s < 0 || x.s > CHUNK_SIZE)) throw Error("invalid chunk size");402361 const manifests = [];403362 let level = leaves;404363 while (level.length > max) {405364 const next = [];406365 for (let i = 0; i < level.length; i += max) {407366 const group = level.slice(i, i + max),408- child = { l: group, t: 1 },367+ child = {l: group, t: 1},409368 stored = await storeNode(child, options, manifests);410369 next.push({411370 h: hashBytes(stored.hash),412- ...(stored.key ? { k: keyBytes(stored.key) } : {}),371+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),413372 s: group.reduce((n, x) => n + x.s, 0),414- t: 1,373+ t: 1415374 });416375 }417376 level = next;418377 }419- const root = { l: level, t: 1 };378+ const root = {l: level, t: 1};420379 await storeNode(root, options, manifests);421- return options.returnDetails ? { root, manifests } : root;380+ return options.returnDetails ? {root, manifests} : root;422381 };423382 const buildDirectory = async (entries, options = {}) => {424383 if (!Array.isArray(entries)) throw Error("entries must be an array");425384 const max = options.maxLinks ?? MAX_LINKS;426- if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS)427- throw Error("invalid maxLinks");385+ if (!Number.isInteger(max) || max < 2 || max > MAX_LINKS) throw Error("invalid maxLinks");428386 const sorted = entries429- .map((e) => {387+ .map(e => {430388 const x = {431389 h: hashBytes(e.hash),432390 n: e.name,433391 s: e.size || 0,434- t: e.type || 0,392+ t: e.type || 0435393 };436394 if (e.key !== undefined) x.k = keyBytes(e.key);437395 if (e.metadata !== undefined) x.m = e.metadata;@@ -439,50 +397,49 @@
439397 })440398 .sort((a, b) => utf8cmp(a.n, b.n));441399 for (let i = 1; i < sorted.length; i++)442- if (sorted[i - 1].n === sorted[i].n)443- throw Error("duplicate directory entry");400+ if (sorted[i - 1].n === sorted[i].n) throw Error("duplicate directory entry");444401 const manifests = [];445402 if (sorted.length <= max) {446- const directory = { l: sorted, t: 2 };403+ const directory = {l: sorted, t: 2};447404 await storeNode(directory, options, manifests);448- return options.returnDetails ? { root: directory, manifests } : directory;405+ return options.returnDetails ? {root: directory, manifests} : directory;449406 }450407 let level = [];451408 for (let i = 0; i < sorted.length; i += max) {452409 const l = sorted.slice(i, i + max),453- child = { l, t: 2 },410+ child = {l, t: 2},454411 stored = await storeNode(child, options, manifests);455412 level.push({456413 h: hashBytes(stored.hash),457- ...(stored.key ? { k: keyBytes(stored.key) } : {}),458- m: { count: l.length, first: l[0].n, last: l[l.length - 1].n },414+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),415+ m: {count: l.length, first: l[0].n, last: l[l.length - 1].n},459416 s: l.reduce((n, x) => n + x.s, 0),460- t: 2,417+ t: 2461418 });462419 }463420 while (level.length > max) {464421 const next = [];465422 for (let i = 0; i < level.length; i += max) {466423 const l = level.slice(i, i + max),467- child = { l, t: 3 },424+ child = {l, t: 3},468425 stored = await storeNode(child, options, manifests);469426 next.push({470427 h: hashBytes(stored.hash),471- ...(stored.key ? { k: keyBytes(stored.key) } : {}),428+ ...(stored.key ? {k: keyBytes(stored.key)} : {}),472429 m: {473430 count: l.reduce((n, x) => n + x.m.count, 0),474431 first: l[0].m.first,475- last: l[l.length - 1].m.last,432+ last: l[l.length - 1].m.last476433 },477434 s: l.reduce((n, x) => n + x.s, 0),478- t: 3,435+ t: 3479436 });480437 }481438 level = next;482439 }483- const root = { l: level, t: 3 };440+ const root = {l: level, t: 3};484441 await storeNode(root, options, manifests);485- return options.returnDetails ? { root, manifests } : root;442+ return options.returnDetails ? {root, manifests} : root;486443 };487444 const flattenDirectory = (node, out = []) => {488445 validateNode(node);@@ -500,11 +457,7 @@
500457 maxEntries = options.maxEntries ?? 1e5,501458 maxManifests = options.maxManifests ?? 1e4,502459 maxBytes = options.maxBytes ?? 64 * 1024 * 1024;503- if (504- [maxDepth, maxEntries, maxManifests, maxBytes].some(505- (value) => !Number.isSafeInteger(value) || value < 0,506- )507- )460+ if ([maxDepth, maxEntries, maxManifests, maxBytes].some(value => !Number.isSafeInteger(value) || value < 0))508461 throw Error("invalid directory limits");509462 let count = 0,510463 manifestCount = 0,@@ -512,8 +465,7 @@
512465 const active = new Set();513466 const walk = async (node, depth) => {514467 if (depth > maxDepth) throw Error("directory recursion limit exceeded");515- if (++manifestCount > maxManifests)516- throw Error("manifest count limit exceeded");468+ if (++manifestCount > maxManifests) throw Error("manifest count limit exceeded");517469 validateNode(node);518470 if (node.t === 2) {519471 count += node.l.length;@@ -524,8 +476,7 @@
524476 const result = [];525477 let previous = "";526478 for (const link of node.l) {527- if (previous && utf8cmp(previous, link.m.first) >= 0)528- throw Error("fanout bounds are not ordered");479+ if (previous && utf8cmp(previous, link.m.first) >= 0) throw Error("fanout bounds are not ordered");529480 previous = link.m.last;530481 const linkHash = hex(link.h);531482 if (active.has(linkHash)) throw Error("manifest cycle detected");@@ -533,28 +484,21 @@
533484 let raw = asBytes(await fetchNode(linkHash, link));534485 fetched += raw.length;535486 if (fetched > maxBytes) throw Error("directory byte limit exceeded");536- if (hex(await sha256(raw)) !== linkHash)537- throw Error("manifest hash mismatch");487+ if (hex(await sha256(raw)) !== linkHash) throw Error("manifest hash mismatch");538488 if (link.k) {539- if (typeof options.decrypt !== "function")540- throw Error("encrypted manifest requires decrypt callback");489+ if (typeof options.decrypt !== "function") throw Error("encrypted manifest requires decrypt callback");541490 raw = asBytes(await options.decrypt(raw, link));542491 }543492 const child = decodeManifest(raw);544493 if (child.t !== link.t) throw Error("fanout child type mismatch");545494 const links = await walk(child, depth + 1);546495 active.delete(linkHash);547- if (548- links.length !== link.m.count ||549- links[0]?.n !== link.m.first ||550- links.at(-1)?.n !== link.m.last551- )496+ if (links.length !== link.m.count || links[0]?.n !== link.m.first || links.at(-1)?.n !== link.m.last)552497 throw Error("fanout bounds mismatch");553498 result.push(...links);554499 }555500 for (let i = 1; i < result.length; i++)556- if (utf8cmp(result[i - 1].n, result[i].n) >= 0)557- throw Error("duplicate or unordered directory entry");501+ if (utf8cmp(result[i - 1].n, result[i].n) >= 0) throw Error("duplicate or unordered directory entry");558502 return result;559503 };560504 return walk(root, 0);@@ -564,13 +508,8 @@
564508 const maxDepth = options.maxDepth ?? MAX_DEPTH,565509 maxBytes = options.maxBytes ?? 512 * 1024 * 1024,566510 maxManifests = options.maxManifests ?? 1e4;567- const maxFetchedBytes =568- options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;569- if (570- [maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(571- (value) => !Number.isSafeInteger(value) || value < 0,572- )573- )511+ const maxFetchedBytes = options.maxFetchedBytes ?? maxBytes + 16 * 1024 * 1024;512+ if ([maxDepth, maxBytes, maxManifests, maxFetchedBytes].some(value => !Number.isSafeInteger(value) || value < 0))574513 throw Error("invalid file limits");575514 let used = 0,576515 fetched = 0,@@ -581,16 +520,14 @@
581520 if (depth > maxDepth) throw Error("file recursion limit exceeded");582521 validateNode(node);583522 if (node.t !== 1) throw Error("not a file manifest");584- if (++manifests > maxManifests)585- throw Error("manifest count limit exceeded");523+ if (++manifests > maxManifests) throw Error("manifest count limit exceeded");586524 for (const link of node.l) {587525 const hash = hex(link.h);588526 if (active.has(hash)) throw Error("manifest cycle detected");589527 active.add(hash);590528 let raw = asBytes(await fetchBlob(hash, link));591529 fetched += raw.length;592- if (fetched > maxFetchedBytes)593- throw Error("file fetched byte limit exceeded");530+ if (fetched > maxFetchedBytes) throw Error("file fetched byte limit exceeded");594531 if (hex(await sha256(raw)) !== hash) throw Error("blob hash mismatch");595532 if (link.t === 1) {596533 if (typeof options.decrypt !== "function" && link.k)@@ -600,11 +537,9 @@
600537 if (child.t !== 1) throw Error("file child type mismatch");601538 const before = used;602539 await readNode(child, depth + 1);603- if (used - before !== link.s)604- throw Error("file manifest size mismatch");540+ if (used - before !== link.s) throw Error("file manifest size mismatch");605541 } else {606- if (link.k && typeof options.decrypt !== "function")607- throw Error("encrypted chunk requires decrypt callback");542+ if (link.k && typeof options.decrypt !== "function") throw Error("encrypted chunk requires decrypt callback");608543 const data = link.k ? asBytes(await options.decrypt(raw, link)) : raw;609544 if (data.length !== link.s) throw Error("file chunk size mismatch");610545 used += data.length;@@ -637,8 +572,8 @@
637572 resolveDirectory,638573 readFile,639574 sha256,640- hex,575+ hex641576 });642577 globalThis.tiny = globalThis.tiny || {};643- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, manifests: api };578+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, manifests: api};644579 })();@@ -6,15 +6,14 @@
66 ;77 88 const {sha256, hex} = globalThis.tiny.util;9- const asBytes = async (value) => {9+ const asBytes = async value => {1010 if (value instanceof Uint8Array) return value;11- if (ArrayBuffer.isView(value))12- return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);11+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);1312 if (value instanceof ArrayBuffer) return new Uint8Array(value);1413 if (value instanceof Blob) return new Uint8Array(await value.arrayBuffer());1514 throw new TypeError("expected Blob, ArrayBuffer, or Uint8Array");1615 };17- const responseBody = async (response) => {16+ const responseBody = async response => {1817 try {1918 return await response.json();2019 } catch {@@ -27,12 +26,7 @@
2726 return error;2827 };2928 const verifyDescriptor = (descriptor, hash, size) => {30- if (31- !descriptor ||32- typeof descriptor !== "object" ||33- descriptor.sha256 !== hash ||34- descriptor.size !== size35- )29+ if (!descriptor || typeof descriptor !== "object" || descriptor.sha256 !== hash || descriptor.size !== size)3630 throw new Error("invalid Blossom upload descriptor");3731 return descriptor;3832 };@@ -40,66 +34,41 @@
4034 const uploadTask = async (source, options = {}, controller) => {4135 const bytes = await asBytes(source);4236 const hash = options.hash || hex(await sha256(bytes));43- if (!/^[0-9a-f]{64}$/.test(hash))44- throw new Error("expected a lowercase SHA-256 hash");45- if (options.hash && hash !== hex(await sha256(bytes)))46- throw new Error("upload hash mismatch");37+ if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error("expected a lowercase SHA-256 hash");38+ if (options.hash && hash !== hex(await sha256(bytes))) throw new Error("upload hash mismatch");4739 const type = options.type || source.type || "application/octet-stream";4840 const base = options.url || options.endpoint;49- if (typeof base !== "string" || base === "")50- throw new Error("upload URL is required");41+ if (typeof base !== "string" || base === "") throw new Error("upload URL is required");5142 let parsed;5243 try {53- parsed = new URL(54- base,55- globalThis.location?.href || "https://blossom.invalid",56- );44+ parsed = new URL(base, globalThis.location?.href || "https://blossom.invalid");5745 } catch {5846 throw new Error("invalid upload URL");5947 }6048 if (parsed.protocol !== "http:" && parsed.protocol !== "https:")6149 throw new Error("upload URL must use HTTP or HTTPS");6250 if (parsed.username || parsed.password || parsed.hash || parsed.search)63- throw new Error(64- "upload URL must not contain credentials, query, or fragment",65- );66- const extension = options.extension67- ? String(options.extension).replace(/^\.+/, "")68- : "";69- if (extension && !/^[a-z0-9]{1,8}$/.test(extension))70- throw new Error("invalid file extension");51+ throw new Error("upload URL must not contain credentials, query, or fragment");52+ const extension = options.extension ? String(options.extension).replace(/^\.+/, "") : "";53+ if (extension && !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error("invalid file extension");7154 const path = parsed.pathname;72- const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(73- path,74- )55+ const target = new RegExp("/" + hash + "(?:\\.[a-z0-9]{1,8})?/?$").test(path)7556 ? base76- : base.replace(/\/$/, "") +77- "/" +78- hash +79- (extension ? "." + extension : "");57+ : base.replace(/\/$/, "") + "/" + hash + (extension ? "." + extension : "");8058 const url = target;8159 const fetcher = options.fetch || globalThis.fetch;8260 if (typeof fetcher !== "function") throw new Error("fetch is unavailable");8361 const chunkSize =84- Number.isSafeInteger(options.chunkSize) && options.chunkSize > 085- ? options.chunkSize86- : 5 * 1024 * 1024;87- const timeoutMs =88- Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 089- ? options.timeoutMs90- : 30000;91- const retries =92- Number.isSafeInteger(options.retries) && options.retries >= 093- ? options.retries94- : 0;62+ Number.isSafeInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 5 * 1024 * 1024;63+ const timeoutMs = Number.isSafeInteger(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 30000;64+ const retries = Number.isSafeInteger(options.retries) && options.retries >= 0 ? options.retries : 0;9565 const state = options.state instanceof Map ? options.state : new Map();96- const notify = (offset, length, status) =>97- options.onState?.({ offset, length, status, state });98- const progress = (sent) =>66+ const notify = (offset, length, status) => options.onState?.({offset, length, status, state});67+ const progress = sent =>9968 options.onProgress?.({10069 sent,10170 total: bytes.byteLength,102- fraction: bytes.byteLength ? sent / bytes.byteLength : 1,71+ fraction: bytes.byteLength ? sent / bytes.byteLength : 110372 });10473 const request = async (method, body, headers, authBody = body) => {10574 if (controller.signal.aborted) throw abortError();@@ -109,40 +78,35 @@
10978 requestController.abort();11079 rejectCanceled?.(abortError());11180 };112- controller.signal.addEventListener("abort", cancel, { once: true });81+ controller.signal.addEventListener("abort", cancel, {once: true});11382 try {11483 return await Promise.race([11584 (async () => {116- const requestHeaders = { ...headers };85+ const requestHeaders = {...headers};11786 if (options.authorize && method !== "OPTIONS") {11887 const value = await options.authorize(url, method, authBody, {11988 hash,120- type,89+ type12190 });122- if (typeof value === "string")123- requestHeaders.authorization = value;124- else if (value && typeof value === "object")125- Object.assign(requestHeaders, value);91+ if (typeof value === "string") requestHeaders.authorization = value;92+ else if (value && typeof value === "object") Object.assign(requestHeaders, value);12693 }12794 if (requestController.signal.aborted) throw abortError();12895 const response = await fetcher(url, {12996 method,13097 headers: requestHeaders,13198 body: method === "OPTIONS" ? undefined : body,132- signal: requestController.signal,99+ signal: requestController.signal133100 });134101 // Keep the request deadline and cancellation active until the135102 // descriptor body has arrived, not only until headers arrive.136- const descriptor =137- response.status === 200 || response.status === 201138- ? await responseBody(response)139- : null;103+ const descriptor = response.status === 200 || response.status === 201 ? await responseBody(response) : null;140104 if (!descriptor) await response.body?.cancel?.();141105 return {142106 status: response.status,143107 ok: response.ok,144108 headers: response.headers,145- json: async () => descriptor,109+ json: async () => descriptor146110 };147111 })(),148112 new Promise((_, reject) => {@@ -153,7 +117,7 @@
153117 }),154118 new Promise((_, reject) => {155119 rejectCanceled = reject;156- }),120+ })157121 ]);158122 } finally {159123 clearTimeout(timer);@@ -184,29 +148,18 @@
184148 if (options.probe !== false) {185149 try {186150 const response = await request("OPTIONS", null, {});187- supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(188- response.headers?.get?.("allow") || "",189- );151+ supportsPatch = /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(response.headers?.get?.("allow") || "");190152 } catch {191153 supportsPatch = false;192154 }193155 }194156 if (!supportsPatch) {195- const response = await retry(196- () => request("PUT", bytes, { "content-type": type }),197- 0,198- bytes.byteLength,199- );200- if (!response.ok)201- throw new Error("Blossom upload failed: " + response.status);157+ const response = await retry(() => request("PUT", bytes, {"content-type": type}), 0, bytes.byteLength);158+ if (!response.ok) throw new Error("Blossom upload failed: " + response.status);202159 return {203160 hash,204- descriptor: verifyDescriptor(205- await responseBody(response),206- hash,207- bytes.byteLength,208- ),209- state,161+ descriptor: verifyDescriptor(await responseBody(response), hash, bytes.byteLength),162+ state210163 };211164 }212165 @@ -227,26 +180,18 @@
227180 chunkSize,228181 url,229182 type,230- lastAcceptedAt: 0,183+ lastAcceptedAt: 0231184 };232185 }233186 let sent = 0;234187 let replayed = false;235188 while (true) {236- for (237- let offset = 0;238- offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0);239- offset += chunkSize240- ) {189+ for (let offset = 0; offset < bytes.byteLength || (bytes.byteLength === 0 && offset === 0); offset += chunkSize) {241190 const end = Math.min(offset + chunkSize, bytes.byteLength);242191 const chunk = bytes.slice(offset, end);243192 const length = chunk.byteLength;244193 const prior = state.get(offset);245- if (246- !replayed &&247- prior?.status === 204 &&248- Date.now() - (state.meta.lastAcceptedAt || 0) < 60000249- ) {194+ if (!replayed && prior?.status === 204 && Date.now() - (state.meta.lastAcceptedAt || 0) < 60000) {250195 sent = end;251196 progress(sent);252197 continue;@@ -260,16 +205,15 @@
260205 "upload-type": type,261206 "upload-length": String(bytes.byteLength),262207 "upload-offset": String(offset),263- "content-type": "application/octet-stream",208+ "content-type": "application/octet-stream"264209 },265- options.authorizationMode === "final-hash" ? bytes : chunk,210+ options.authorizationMode === "final-hash" ? bytes : chunk266211 ),267212 offset,268- length,213+ length269214 );270- if (![200, 201, 204].includes(result.status))271- throw new Error("Blossom chunk upload failed: " + result.status);272- state.set(offset, { offset, length, status: result.status });215+ if (![200, 201, 204].includes(result.status)) throw new Error("Blossom chunk upload failed: " + result.status);216+ state.set(offset, {offset, length, status: result.status});273217 state.meta.lastAcceptedAt = Date.now();274218 sent = end;275219 notify(offset, length, result.status === 204 ? "uploaded" : "complete");@@ -277,12 +221,8 @@
277221 if (result.status !== 204)278222 return {279223 hash,280- descriptor: verifyDescriptor(281- await responseBody(result),282- hash,283- bytes.byteLength,284- ),285- state,224+ descriptor: verifyDescriptor(await responseBody(result), hash, bytes.byteLength),225+ state286226 };287227 if (bytes.byteLength === 0) break;288228 }@@ -300,7 +240,7 @@
300240 if (options.signal.aborted) controller.abort();301241 else302242 options.signal.addEventListener("abort", () => controller.abort(), {303- once: true,243+ once: true304244 });305245 }306246 const task = uploadTask(source, options, controller);@@ -308,7 +248,7 @@
308248 return task;309249 };310250 311- const api = Object.freeze({ upload });251+ const api = Object.freeze({upload});312252 globalThis.tiny = globalThis.tiny || {};313- globalThis.tiny.blossom = { ...globalThis.tiny.blossom, upload: api };253+ globalThis.tiny.blossom = {...globalThis.tiny.blossom, upload: api};314254 })();@@ -378,7 +378,7 @@
378378 const recipient = form.elements.namedItem("recipient").value.trim();379379 if (!recipient) { this.say("Enter a public key or npub.", true); return; }380380 const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));381- const signer = globalThis.tinySigner || globalThis.nostr;381+ const signer = tiny.signer();382382 if (!tiny.files.messages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }383383 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }384384 this.sharing = true;@@ -1,60 +1,40 @@
11 /* NIP-17 file messages. This module never publishes to ordinary relays. */2-(function (root) {2+(() => {33 "use strict";4+ const {relayURL} = globalThis.tiny.util;5+ const {signer: activeSigner, nip44Encrypt} = globalThis.tiny;46 57 const MAX_RELAYS = 8;68 const DAY = 86400;79 810 function api() {9- const signer = root.NostrSigner;10- if (11- !signer ||12- !signer.nip44 ||13- !signer.finalizeEvent ||14- !signer.generateSecretKey15- ) {16- throw new Error("NIP-44 support is unavailable");11+ const signer = globalThis.NostrSigner;12+ if (!signer || !signer.nip44 || !signer.finalizeEvent || !signer.generateSecretKey) {13+ throw Error("NIP-44 support is unavailable");1714 }1815 return signer;1916 }2017 21- function relayURL(value) {22- if (typeof value !== "string" || value.length > 2048) return null;23- let url;24- try {25- url = new URL(value);26- } catch {27- return null;28- }29- if (url.protocol !== "wss:" && url.protocol !== "ws:") return null;30- if (url.username || url.password || url.hash) return null;31- return url.toString().replace(/\/$/, "");32- }33-3418 function relayList(event, pubkey) {3519 if (Array.isArray(event)) {3620 event = event37- .filter(38- (candidate) =>39- candidate?.kind === 10050 && candidate.pubkey === pubkey,40- )21+ .filter(candidate => candidate?.kind === 10050 && candidate.pubkey === pubkey)4122 .sort((a, b) => b.created_at - a.created_at)[0];4223 }4324 if (!event || event.kind !== 10050 || event.pubkey !== pubkey) {44- throw new Error("recipient has no valid kind 10050 relay list");25+ throw Error("recipient has no valid kind 10050 relay list");4526 }4627 const signer = api();47- if (!signer.verifyEvent(event))48- throw new Error("invalid kind 10050 relay list signature");28+ if (!signer.verifyEvent(event)) throw Error("invalid kind 10050 relay list signature");4929 const relays = [5030 ...new Set(5131 event.tags52- .filter((tag) => tag[0] === "relay")53- .map((tag) => relayURL(tag[1]))54- .filter(Boolean),55- ),32+ .filter(tag => tag[0] === "relay")33+ .map(tag => relayURL(tag[1]))34+ .filter(Boolean)35+ )5636 ];57- if (!relays.length) throw new Error("kind 10050 relay list is empty");37+ if (!relays.length) throw Error("kind 10050 relay list is empty");5838 return relays.slice(0, MAX_RELAYS);5939 }6040 @@ -62,9 +42,8 @@
6242 const text = String(value || "").trim();6343 if (/^[0-9a-f]{64}$/i.test(text)) return text.toLowerCase();6444 const decoded = api().decodeNpub?.(text);65- if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded))66- return decoded.toLowerCase();67- throw new Error("public key must be a 64 character hex key or npub");45+ if (typeof decoded === "string" && /^[0-9a-f]{64}$/i.test(decoded)) return decoded.toLowerCase();46+ throw Error("public key must be a 64 character hex key or npub");6847 }6948 7049 async function bounded(value, timeout, label) {@@ -74,8 +53,8 @@
7453 return await Promise.race([7554 Promise.resolve(value),7655 new Promise((_, reject) => {77- timer = setTimeout(() => reject(new Error(label + " timed out")), ms);78- }),56+ timer = setTimeout(() => reject(Error(label + " timed out")), ms);57+ })7958 ]);8059 } finally {8160 clearTimeout(timer);@@ -90,53 +69,42 @@
9069 }9170 9271 function past(now) {93- return Math.max(94- 0,95- Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)),96- );72+ return Math.max(0, Math.floor(now) - Math.floor(Math.random() * (2 * DAY + 1)));9773 }9874 9975 async function publicKey(signer, getPublicKey) {100- const key = getPublicKey101- ? await getPublicKey()102- : await signer.getPublicKey();103- if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key))104- throw new Error("invalid signer public key");76+ const key = getPublicKey ? await getPublicKey() : await signer.getPublicKey();77+ if (typeof key !== "string" || !/^[0-9a-f]{64}$/i.test(key)) throw Error("invalid signer public key");10578 return key.toLowerCase();10679 }10780 10881 async function queryRelayList(pubkey, options) {109- if (!root.tiny?.signedFetch)110- throw new Error("signed local query is unavailable");111- const response = await root.tiny.signedFetch(82+ if (!globalThis.tiny?.signedFetch) throw Error("signed local query is unavailable");83+ const response = await globalThis.tiny.signedFetch(11284 "/query",11385 "POST",114- JSON.stringify([{ kinds: [10050], authors: [pubkey], limit: 1 }]),115- { contentType: "application/json" },86+ JSON.stringify([{kinds: [10050], authors: [pubkey], limit: 1}]),87+ {contentType: "application/json"}11688 );11789 return response.json();11890 }11991 12092 function publishEvent(event, relays, options) {121- const signer = options.signer || root.tinySigner || root.nostr;122- const WebSocketImpl = options.WebSocket || root.WebSocket;123- if (typeof WebSocketImpl !== "function")124- throw new Error("WebSocket support is unavailable");125- const timeout =126- Number.isFinite(options.timeoutMs) && options.timeoutMs > 0127- ? options.timeoutMs128- : 10000;93+ const signer = options.signer || activeSigner();94+ const WebSocketImpl = options.WebSocket || globalThis.WebSocket;95+ if (typeof WebSocketImpl !== "function") throw Error("WebSocket support is unavailable");96+ const timeout = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 10000;12997 return Promise.all(13098 relays.map(131- (url) =>132- new Promise((resolve) => {99+ url =>100+ new Promise(resolve => {133101 let socket;134102 let timer;135103 let settled = false;136104 let authID = "";137105 let authAttempts = 0;138106 let eventSent = false;139- const finish = (ok) => {107+ const finish = ok => {140108 if (settled) return;141109 settled = true;142110 clearTimeout(timer);@@ -155,12 +123,10 @@
155123 socket = new WebSocketImpl(url);156124 timer = setTimeout(() => finish(false), timeout);157125 socket.onopen = sendEvent;158- socket.onmessage = async (message) => {126+ socket.onmessage = async message => {159127 let frame;160128 try {161- frame = JSON.parse(162- typeof message.data === "string" ? message.data : "",163- );129+ frame = JSON.parse(typeof message.data === "string" ? message.data : "");164130 } catch {165131 return;166132 }@@ -185,11 +151,7 @@
185151 finish(frame[2] === true);186152 return;187153 }188- if (189- frame[0] !== "AUTH" ||190- typeof signer?.signEvent !== "function"191- )192- return;154+ if (frame[0] !== "AUTH" || typeof signer?.signEvent !== "function") return;193155 if (typeof frame[1] !== "string" || frame[1].length > 4096) {194156 finish(false);195157 return;@@ -205,9 +167,9 @@
205167 tags: [206168 ["relay", url],207169 ["challenge", frame[1]],208- ["method", "AUTH"],170+ ["method", "AUTH"]209171 ],210- content: "",172+ content: ""211173 });212174 if (213175 auth &&@@ -236,20 +198,15 @@
236198 } catch {237199 finish(false);238200 }239- }),240- ),201+ })202+ )241203 );242204 }243205 244206 function unsignedRumor(input, sender, now) {245- if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL))246- throw new Error("fileURL must be an HTTP(S) URL");247- if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || ""))248- throw new Error("ciphertextHash must be SHA-256 hex");249- if (!input.key || !input.nonce)250- throw new Error(251- "encrypted file messages require a decryption key and nonce",252- );207+ if (!input.fileURL || !/^https?:\/\//i.test(input.fileURL)) throw Error("fileURL must be an HTTP(S) URL");208+ if (!/^[0-9a-f]{64}$/i.test(input.ciphertextHash || "")) throw Error("ciphertextHash must be SHA-256 hex");209+ if (!input.key || !input.nonce) throw Error("encrypted file messages require a decryption key and nonce");253210 const tags = [["p", input.recipient]];254211 if (input.mimeType) tags.push(["file-type", input.mimeType]);255212 tags.push(["encryption-algorithm", "aes-gcm"]);@@ -257,8 +214,7 @@
257214 if (input.nonce) tags.push(["decryption-nonce", input.nonce]);258215 tags.push(["x", input.ciphertextHash.toLowerCase()]);259216 if (input.plaintextHash) {260- if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash))261- throw new Error("plaintextHash must be SHA-256 hex");217+ if (!/^[0-9a-f]{64}$/i.test(input.plaintextHash)) throw Error("plaintextHash must be SHA-256 hex");262218 tags.push(["ox", input.plaintextHash.toLowerCase()]);263219 }264220 if (input.size != null) tags.push(["size", String(input.size)]);@@ -267,19 +223,13 @@
267223 created_at: Math.floor(now),268224 kind: 15,269225 tags,270- content: input.fileURL,226+ content: input.fileURL271227 };272228 rumor.id = api().getEventHash(rumor);273229 return rumor;274230 }275231 276- async function encryptFor(signer, recipient, plaintext) {277- if (typeof signer.nip44Encrypt === "function")278- return signer.nip44Encrypt(recipient, plaintext);279- if (signer.nip44?.encrypt)280- return signer.nip44.encrypt(recipient, plaintext);281- throw new Error("signer has no NIP-44 encrypt method");282- }232+ const encryptFor = (signer, recipient, plaintext) => nip44Encrypt(recipient, plaintext, signer);283233 284234 async function seal(rumor, recipient, signer, now) {285235 const content = await encryptFor(signer, recipient, JSON.stringify(rumor));@@ -288,7 +238,7 @@
288238 tags: [],289239 content,290240 created_at: past(now),291- pubkey: rumor.pubkey,241+ pubkey: rumor.pubkey292242 };293243 const event = await signer.signEvent(unsigned);294244 if (@@ -299,9 +249,8 @@
299249 event.content !== unsigned.content ||300250 event.created_at !== unsigned.created_at301251 )302- throw new Error("signer returned an invalid NIP-17 seal");303- if (!api().verifyEvent(event))304- throw new Error("signer returned an invalid seal signature");252+ throw Error("signer returned an invalid NIP-17 seal");253+ if (!api().verifyEvent(event)) throw Error("signer returned an invalid seal signature");305254 return event;306255 }307256 @@ -311,27 +260,23 @@
311260 const pubkey = signer.getPublicKey(secret);312261 const key = signer.nip44.getConversationKey(secret, recipient);313262 const content = signer.nip44.encrypt(JSON.stringify(sealEvent), key);314- const event = signer.finalizeEvent(315- { kind: 1059, tags: [["p", recipient]], content, created_at: past(now) },316- secret,317- );318- if (!signer.verifyEvent(event))319- throw new Error("failed to verify gift wrap");320- return { event, secret, pubkey };263+ const event = signer.finalizeEvent({kind: 1059, tags: [["p", recipient]], content, created_at: past(now)}, secret);264+ if (!signer.verifyEvent(event)) throw Error("failed to verify gift wrap");265+ return {event, secret, pubkey};321266 }322267 323268 async function build(input, options = {}) {324- const signer = options.signer || root.tinySigner || root.nostr;269+ const signer = options.signer || activeSigner();325270 if (326271 !signer ||327272 typeof signer.signEvent !== "function" ||328273 (typeof signer.nip44Encrypt !== "function" && !signer.nip44?.encrypt)329274 )330- throw new Error("a NIP-44 signer is required");275+ throw Error("a NIP-44 signer is required");331276 const recipient = normalizePubkey(input.recipient);332277 const sender = await publicKey(signer, options.getPublicKey);333278 const now = options.now || Date.now() / 1000;334- const rumor = unsignedRumor({ ...input, recipient }, sender, now);279+ const rumor = unsignedRumor({...input, recipient}, sender, now);335280 const receiverSeal = await seal(rumor, recipient, signer, now);336281 const senderSeal = await seal(rumor, sender, signer, now);337282 const receiver = wrap(receiverSeal, recipient, now).event;@@ -339,51 +284,36 @@
339284 return {340285 rumor,341286 events: [342- { recipient, event: receiver },343- { recipient: sender, event: own },344- ],287+ {recipient, event: receiver},288+ {recipient: sender, event: own}289+ ]345290 };346291 }347292 348293 async function share(input, options = {}) {349- const signer = options.signer || root.tinySigner || root.nostr;350- const query =351- options.queryRelayList || ((pubkey) => queryRelayList(pubkey, options));352- const publish =353- options.publish ||354- ((event, relays) => publishEvent(event, relays, options));294+ const signer = options.signer || activeSigner();295+ const query = options.queryRelayList || (pubkey => queryRelayList(pubkey, options));296+ const publish = options.publish || ((event, relays) => publishEvent(event, relays, options));355297 if (typeof query !== "function" || typeof publish !== "function")356- throw new Error("private NIP-17 delivery is unavailable");298+ throw Error("private NIP-17 delivery is unavailable");357299 const sender = await publicKey(signer, options.getPublicKey);358300 const recipient = normalizePubkey(input.recipient);359301 const timeout = options.timeoutMs;360- const receiverList = relayList(361- await bounded(query(recipient), timeout, "recipient relay lookup"),362- recipient,363- );364- const senderList = relayList(365- await bounded(query(sender), timeout, "sender relay lookup"),366- sender,367- );302+ const receiverList = relayList(await bounded(query(recipient), timeout, "recipient relay lookup"), recipient);303+ const senderList = relayList(await bounded(query(sender), timeout, "sender relay lookup"), sender);368304 const built = await build(input, options);369- if (built.rumor.pubkey !== sender)370- throw new Error("signer changed during file sharing; try again");305+ if (built.rumor.pubkey !== sender) throw Error("signer changed during file sharing; try again");371306 const deliveries = [];372307 for (const target of built.events) {373308 const relays = target.recipient === recipient ? receiverList : senderList;374- const delivery = await bounded(375- publish(target.event, relays),376- timeout,377- "NIP-17 delivery",378- );379- if (!sent(delivery))380- throw new Error("NIP-17 delivery failed on every inbox relay");309+ const delivery = await bounded(publish(target.event, relays), timeout, "NIP-17 delivery");310+ if (!sent(delivery)) throw Error("NIP-17 delivery failed on every inbox relay");381311 deliveries.push(delivery);382312 }383313 return {384314 ...built,385- relays: { recipient: receiverList, sender: senderList },386- deliveries,315+ relays: {recipient: receiverList, sender: senderList},316+ deliveries387317 };388318 }389319 @@ -392,8 +322,8 @@
392322 share,393323 relayList,394324 queryRelayList,395- publishEvent,325+ publishEvent396326 });397- root.tiny = root.tiny || {};398- root.tiny.files = { ...root.tiny.files, messages: apiObject };399-})(globalThis);327+ globalThis.tiny = globalThis.tiny || {};328+ globalThis.tiny.files = {...globalThis.tiny.files, messages: apiObject};329+})();@@ -8,78 +8,50 @@
88 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;99 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;1010 const directoryType = "application/vnd.blossom.directory+msgpack";11- const codec = () =>12- globalThis.tiny?.blossom?.manifests;13- const encryption = () =>14- globalThis.tiny?.blossom?.encryption;15- const upload = () =>16- globalThis.tiny?.blossom?.upload;17- const hex = (value) => globalThis.tiny.util.hex(value);18- const fromHex = (value) => {19- if (!/^[0-9a-f]{64}$/.test(value || ""))20- throw Error("Invalid hash or encryption key.");21- return Uint8Array.from(value.match(/../g), (byte) => parseInt(byte, 16));22- };23- const element = (tag, text) => {24- const node = document.createElement(tag);25- if (text !== undefined) node.textContent = text;26- return node;27- };28- const checkAbort = (signal) => {11+ const codec = () => globalThis.tiny?.blossom?.manifests;12+ const encryption = () => globalThis.tiny?.blossom?.encryption;13+ const upload = () => globalThis.tiny?.blossom?.upload;14+ const hex = value => globalThis.tiny.util.hex(value);15+ const fromHex = value => globalThis.tiny.util.fromHex(value || "", "Invalid hash or encryption key.");16+ const element = globalThis.tiny.util.element;17+ const checkAbort = signal => {2918 if (signal?.aborted)30- throw Object.assign(31- Error("Upload canceled. Retry while this page stays open."),32- { name: "AbortError" },33- );19+ throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});3420 };35- const validName = (name) =>21+ const validName = name =>3622 typeof name === "string" &&3723 name.length > 0 &&3824 name.isWellFormed() &&3925 ![".", ".."].includes(name) &&4026 !/[\/\u0000]/.test(name) &&4127 new TextEncoder().encode(name).length <= 1024;42- const folderNode = () => ({ directories: new Map(), files: new Map() });28+ const folderNode = () => ({directories: new Map(), files: new Map()});4329 4430 function selectionTree(files) {45- if (!files.length || files.length > MAX_FILES)46- throw Error("Choose a folder with 1 to 10,000 files.");31+ if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");4732 const root = folderNode();4833 let total = 0;49- const paths = files.map((file) =>50- (file.webkitRelativePath || file.name).split("/"),51- );52- const commonRoot = paths.every(53- (path) => path.length > 1 && path[0] === paths[0][0],54- );34+ const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));35+ const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);5536 for (let index = 0; index < files.length; index++) {5637 const file = files[index];5738 const path = commonRoot ? paths[index].slice(1) : paths[index];58- if (path.length > MAX_DEPTH || path.some((name) => !validName(name)))59- throw Error("Invalid folder path.");60- if (61- !Number.isSafeInteger(file.size) ||62- file.size < 0 ||63- file.size > MAX_FILE_BYTES64- )39+ if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");40+ if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_FILE_BYTES)6541 throw Error("Files must be at most 256 MiB in the browser workspace.");6642 total += file.size;67- if (total > MAX_FOLDER_BYTES)68- throw Error("The selected folder exceeds 1 GiB.");43+ if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");6944 let directory = root;7045 for (const name of path.slice(0, -1)) {71- if (directory.files.has(name))72- throw Error("A file and folder use the same name.");73- if (!directory.directories.has(name))74- directory.directories.set(name, folderNode());46+ if (directory.files.has(name)) throw Error("A file and folder use the same name.");47+ if (!directory.directories.has(name)) directory.directories.set(name, folderNode());7548 directory = directory.directories.get(name);7649 }7750 const name = path.at(-1);78- if (directory.files.has(name) || directory.directories.has(name))79- throw Error("Duplicate folder entry.");51+ if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");8052 directory.files.set(name, file);8153 }82- return { root, name: commonRoot ? paths[0][0] : "folder" };54+ return {root, name: commonRoot ? paths[0][0] : "folder"};8355 }8456 8557 async function storeEncrypted(plaintext, type, signal) {@@ -92,65 +64,44 @@
9264 hash: encrypted.hash,9365 type,9466 signal,95- authorize: (target, method, body) =>96- tiny.authorization(target, method, body),67+ authorize: (target, method, body) => tiny.authorization(target, method, body)9768 });98- if (99- result.descriptor.sha256 !== encrypted.hash ||100- result.descriptor.size !== encrypted.ciphertext.length101- )69+ if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length)10270 throw Error("Relay returned an unexpected blob descriptor.");103- return { hash: encrypted.hash, key: fromHex(encrypted.key) };71+ return {hash: encrypted.hash, key: fromHex(encrypted.key)};10472 }10573 10674 const manifestStore =107- (signal) =>108- async ({ bytes, node }) =>109- storeEncrypted(110- bytes,111- node.t === 2 || node.t === 3112- ? directoryType113- : "application/octet-stream",114- signal,115- );116- const rootReference = (details) => {75+ signal =>76+ async ({bytes, node}) =>77+ storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal);78+ const rootReference = details => {11779 const stored = details.manifests.at(-1);11880 return {11981 hash: stored.hash,12082 key: stored.key,12183 type: details.root.t,122- size: details.root.l.reduce((sum, link) => sum + link.s, 0),84+ size: details.root.l.reduce((sum, link) => sum + link.s, 0)12385 };12486 };12587 12688 async function storeFile(file, signal, progress) {127- if (file.size > MAX_FILE_BYTES)128- throw Error("Files must be at most 256 MiB in the browser workspace.");89+ if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");12990 const chunks = [];130- for (131- let offset = 0;132- offset < file.size || offset === 0;133- offset += codec().CHUNK_SIZE134- ) {91+ for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {13592 checkAbort(signal);136- const plain = new Uint8Array(137- await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer(),138- );139- const stored = await storeEncrypted(140- plain,141- "application/octet-stream",142- signal,143- );144- chunks.push({ ...stored, size: plain.length });93+ const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());94+ const stored = await storeEncrypted(plain, "application/octet-stream", signal);95+ chunks.push({...stored, size: plain.length});14596 progress?.(plain.length);14697 if (file.size === 0) break;14798 }148- if (chunks.length === 1) return { ...chunks[0], type: 0 };99+ if (chunks.length === 1) return {...chunks[0], type: 0};149100 return rootReference(150101 await codec().buildFile(chunks, {151102 store: manifestStore(signal),152- returnDetails: true,153- }),103+ returnDetails: true104+ })154105 );155106 }156107 @@ -161,18 +112,18 @@
161112 entries.push({162113 ...stored,163114 name,164- metadata: { type: file.type || "application/octet-stream" },115+ metadata: {type: file.type || "application/octet-stream"}165116 });166117 }167118 for (const [name, child] of directory.directories) {168119 const stored = await storeDirectory(child, signal, progress);169- entries.push({ ...stored, name });120+ entries.push({...stored, name});170121 }171122 return rootReference(172123 await codec().buildDirectory(entries, {173124 store: manifestStore(signal),174- returnDetails: true,175- }),125+ returnDetails: true126+ })176127 );177128 }178129 @@ -184,7 +135,7 @@
184135 key: hex(reference.key),185136 node: String(reference.type),186137 name,187- type: type || "application/octet-stream",138+ type: type || "application/octet-stream"188139 }).toString();189140 return url.href;190141 };@@ -197,15 +148,13 @@
197148 '<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>';198149 this.out = this.querySelector("output");199150 for (const form of this.querySelectorAll("form"))200- form.addEventListener("submit", (event) => {151+ form.addEventListener("submit", event => {201152 event.preventDefault();202- this.run(form).catch((error) => this.say(error.message, true));153+ this.run(form).catch(error => this.say(error.message, true));203154 });204- this.querySelector("[data-cancel]").addEventListener("click", () =>205- this.controller?.abort(),206- );155+ this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());207156 this.querySelector("[data-retry]").addEventListener("click", () =>208- this.run().catch((error) => this.say(error.message, true)),157+ this.run().catch(error => this.say(error.message, true))209158 );210159 }211160 disconnectedCallback() {@@ -221,10 +170,9 @@
221170 if (form) {222171 const folder = form.elements.namedItem("folder");223172 const input = folder || form.elements.namedItem("file");224- this.selection = { files: [...input.files], folder: Boolean(folder) };173+ this.selection = {files: [...input.files], folder: Boolean(folder)};225174 }226- if (!this.selection?.files.length)227- throw Error("Choose a file or folder first.");175+ if (!this.selection?.files.length) throw Error("Choose a file or folder first.");228176 this.busy = true;229177 this.controller = new AbortController();230178 this.querySelector("[data-cancel]").disabled = false;@@ -233,23 +181,15 @@
233181 try {234182 let reference, name, type;235183 let sent = 0;236- const progress = (size) => {184+ const progress = size => {237185 sent += size;238- this.say(239- "Stored " +240- sent.toLocaleString() +241- " bytes. Encrypting the remaining content…",242- );186+ this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…");243187 };244188 this.say("Encrypting selected content…");245189 if (this.selection.folder) {246190 const selected = selectionTree(this.selection.files);247191 name = selected.name;248- reference = await storeDirectory(249- selected.root,250- this.controller.signal,251- progress,252- );192+ reference = await storeDirectory(selected.root, this.controller.signal, progress);253193 } else {254194 const file = this.selection.files[0];255195 name = file.name;@@ -262,9 +202,7 @@
262202 const open = this.querySelector("[data-open]");263203 open.href = url;264204 open.hidden = false;265- this.say(266- "Stored. Keep the complete share link to decrypt and browse this content.",267- );205+ this.say("Stored. Keep the complete share link to decrypt and browse this content.");268206 completed = true;269207 return url;270208 } finally {@@ -279,7 +217,7 @@
279217 async function fetchBlob(hash, signal) {280218 fromHex(hash);281219 const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {282- signal,220+ signal283221 });284222 if (!response.ok) throw Error("Download failed (" + response.status + ").");285223 if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)@@ -287,15 +225,14 @@
287225 const reader = response.body?.getReader();288226 if (!reader) {289227 const bytes = new Uint8Array(await response.arrayBuffer());290- if (bytes.length > MAX_FETCH_BYTES)291- throw Error("Linked blob exceeds the browser fetch limit.");228+ if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");292229 return bytes;293230 }294231 const parts = [];295232 let size = 0;296233 try {297234 for (;;) {298- const { value, done } = await reader.read();235+ const {value, done} = await reader.read();299236 if (done) break;300237 size += value.length;301238 if (size > MAX_FETCH_BYTES) {@@ -319,29 +256,22 @@
319256 async function plaintext(reference, signal) {320257 const hash = hex(reference.h);321258 const raw = await fetchBlob(hash, signal);322- if (hex(await codec().sha256(raw)) !== hash)323- throw Error("Blob hash mismatch.");324- return reference.k325- ? encryption().decryptCHK(raw, hex(reference.k), hash)326- : raw;259+ if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");260+ return reference.k ? encryption().decryptCHK(raw, hex(reference.k), hash) : raw;327261 }328- const decrypt = (raw, link) =>329- encryption().decryptCHK(raw, hex(link.k), hex(link.h));262+ const decrypt = (raw, link) => encryption().decryptCHK(raw, hex(link.k), hex(link.h));330263 331264 class Root extends HTMLElement {332265 connectedCallback() {333266 if (this.bound) return;334267 this.bound = true;335268 const params = new URLSearchParams(location.hash.slice(1));336- if (337- !params.has("manifest") &&338- this.getAttribute("type") !== directoryType339- ) {269+ if (!params.has("manifest") && this.getAttribute("type") !== directoryType) {340270 this.hidden = true;341271 return;342272 }343273 this.hidden = false;344- this.load().catch((error) => this.fail(error.message));274+ this.load().catch(error => this.fail(error.message));345275 }346276 disconnectedCallback() {347277 this.controller?.abort();@@ -361,26 +291,20 @@
361291 this.controller = new AbortController();362292 const params = new URLSearchParams(location.hash.slice(1));363293 const reference = {364- h: fromHex(params.get("manifest") || this.getAttribute("hash")),294+ h: fromHex(params.get("manifest") || this.getAttribute("hash"))365295 };366296 if (params.has("key")) reference.k = fromHex(params.get("key"));367297 const type = params.has("node") ? Number(params.get("node")) : 2;368- if (![0, 1, 2, 3].includes(type))369- throw Error("Unsupported manifest type.");298+ if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");370299 const data = await plaintext(reference, this.controller.signal);371300 const heading = element("h2", params.get("name") || "Stored content");372301 this.replaceChildren(heading);373302 if (type === 0) {374- this.offerDownload(375- data,376- params.get("name") || "file",377- params.get("type"),378- );303+ this.offerDownload(data, params.get("name") || "file", params.get("type"));379304 return;380305 }381306 const root = codec().decodeManifest(data);382- if (root.t !== type)383- throw Error("Manifest type does not match its reference.");307+ if (root.t !== type) throw Error("Manifest type does not match its reference.");384308 if (type === 1) {385309 const button = element("button", "Download decrypted file");386310 button.type = "button";@@ -390,22 +314,18 @@
390314 t: 1,391315 s: root.l.reduce((sum, link) => sum + link.s, 0),392316 n: params.get("name") || "file",393- m: { type: params.get("type") },394- }).catch((error) => this.fail(error.message)),317+ m: {type: params.get("type")}318+ }).catch(error => this.fail(error.message))395319 );396320 this.append(button);397321 return;398322 }399- const entries = await codec().resolveDirectory(400- root,401- (hash) => fetchBlob(hash, this.controller.signal),402- {403- decrypt,404- maxDepth: MAX_DEPTH,405- maxEntries: MAX_FILES,406- maxBytes: 64 * 1024 * 1024,407- },408- );323+ const entries = await codec().resolveDirectory(root, hash => fetchBlob(hash, this.controller.signal), {324+ decrypt,325+ maxDepth: MAX_DEPTH,326+ maxEntries: MAX_FILES,327+ maxBytes: 64 * 1024 * 1024328+ });409329 const list = element("ul");410330 if (!entries.length) this.append(element("p", "This folder is empty."));411331 for (const link of entries) {@@ -417,21 +337,16 @@
417337 const fragment = new URLSearchParams({418338 manifest: hex(link.h),419339 node: String(link.t),420- name: link.n,340+ name: link.n421341 });422342 if (link.k) fragment.set("key", hex(link.k));423343 url.hash = fragment.toString();424344 open.href = url.href;425345 item.append(open);426346 } else {427- const button = element(428- "button",429- link.n + " (" + link.s.toLocaleString() + " bytes)",430- );347+ const button = element("button", link.n + " (" + link.s.toLocaleString() + " bytes)");431348 button.type = "button";432- button.addEventListener("click", () =>433- this.download(link).catch((error) => this.fail(error.message)),434- );349+ button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));435350 item.append(button);436351 }437352 list.append(item);@@ -443,25 +358,20 @@
443358 let data;444359 if (link.t === 0) data = await plaintext(link, this.controller.signal);445360 else if (link.t === 1) {446- const manifest = codec().decodeManifest(447- await plaintext(link, this.controller.signal),448- );449- data = await codec().readFile(450- manifest,451- (hash) => fetchBlob(hash, this.controller.signal),452- { decrypt, maxDepth: MAX_DEPTH, maxBytes: MAX_FILE_BYTES },453- );361+ const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));362+ data = await codec().readFile(manifest, hash => fetchBlob(hash, this.controller.signal), {363+ decrypt,364+ maxDepth: MAX_DEPTH,365+ maxBytes: MAX_FILE_BYTES366+ });454367 } else throw Error("Unsupported file link.");455- if (data.length !== link.s)456- throw Error("File size does not match its directory entry.");368+ if (data.length !== link.s) throw Error("File size does not match its directory entry.");457369 this.offerDownload(data, link.n, link.m?.type);458370 return data;459371 }460372 offerDownload(data, name, type) {461373 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);462- this.downloadURL = URL.createObjectURL(463- new Blob([data], { type: type || "application/octet-stream" }),464- );374+ this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));465375 const link = element("a", "Save " + name);466376 link.href = this.downloadURL;467377 link.download = name;@@ -3,70 +3,28 @@
33 (() => {44 "use strict";55 6- const validURL = (value) => {7- try {8- const parsed = new URL(String(value).trim());9- return (10- (parsed.protocol === "ws:" || parsed.protocol === "wss:") &&11- parsed.hostname !== "" &&12- !parsed.username &&13- !parsed.password &&14- parsed.search === "" &&15- parsed.hash === ""16- );17- } catch {18- return false;19- }20- };21- const relay = (value) => {22- const parsed = new URL(String(value || "").trim());23- parsed.hostname = parsed.hostname.toLowerCase();24- if (parsed.pathname.length > 1)25- parsed.pathname = parsed.pathname.replace(/\/+$/, "");26- return parsed.toString().replace(/\/$/, "");27- };6+ const {relayURL} = window.tiny.util;7+ const {signer, nip44Encrypt: encrypt, nip44Decrypt: decrypt} = window.tiny;8+ const validURL = value => relayURL(value, {strict: true}) !== null;9+ const relay = value => relayURL(value, {strict: true});2810 const mergeRows = (rows, urls) =>29- rows30- .filter((row) => !(Array.isArray(row) && row[0] === "g"))31- .concat(urls.map((value) => ["g", value]));32- const signer = () => window.tinySigner || window.nostr;11+ rows.filter(row => !(Array.isArray(row) && row[0] === "g")).concat(urls.map(value => ["g", value]));3312 const publicKey = async () => {3413 const active = signer();3514 if (active?.getPublicKey) return active.getPublicKey();3615 if (window.tiny?.getPublicKey) return window.tiny.getPublicKey();3716 throw Error("Connect a signer first.");3817 };39- const encrypt = async (pubkey, text) => {40- const active = signer();41- if (active?.nip44Encrypt) return active.nip44Encrypt(pubkey, text);42- if (active?.nip44?.encrypt) return active.nip44.encrypt(pubkey, text);43- if (window.tiny?.nip44Encrypt)44- return window.tiny.nip44Encrypt(pubkey, text);45- throw Error("The connected signer does not support NIP-44.");46- };47- const decrypt = async (pubkey, text) => {48- const active = signer();49- if (active?.nip44Decrypt) return active.nip44Decrypt(pubkey, text);50- if (active?.nip44?.decrypt) return active.nip44.decrypt(pubkey, text);51- if (window.tiny?.nip44Decrypt)52- return window.tiny.nip44Decrypt(pubkey, text);53- throw Error("The connected signer does not support NIP-44.");54- };55- const readList = async (pubkey) => {56- const body = JSON.stringify([57- { kinds: [10318], authors: [pubkey], limit: 1 },58- ]);18+ const readList = async pubkey => {19+ const body = JSON.stringify([{kinds: [10318], authors: [pubkey], limit: 1}]);5920 const response = await window.tiny.signedFetch("/query", "POST", body, {60- contentType: "application/json",21+ contentType: "application/json"6122 });62- if (!response || response.ok === false)63- throw Error("Could not read the private relay list.");23+ if (!response || response.ok === false) throw Error("Could not read the private relay list.");6424 const rows = await response.json();65- if (!Array.isArray(rows) || rows.length > 1)66- throw Error("The private relay list response is malformed.");25+ if (!Array.isArray(rows) || rows.length > 1) throw Error("The private relay list response is malformed.");6726 const event = rows[0];68- if (rows.length && !acceptEvent(event, pubkey))69- throw Error("The private relay list event is invalid.");27+ if (rows.length && !acceptEvent(event, pubkey)) throw Error("The private relay list event is invalid.");7028 return event;7129 };7230 const acceptEvent = (event, expectedPubkey) => {@@ -85,13 +43,10 @@
8543 return false;8644 }8745 };88- const publish = async (event) => {89- const response = await window.tiny.signedFetch(90- "/events",91- "POST",92- JSON.stringify(event),93- { contentType: "application/json" },94- );46+ const publish = async event => {47+ const response = await window.tiny.signedFetch("/events", "POST", JSON.stringify(event), {48+ contentType: "application/json"49+ });9550 const text = await response.text();9651 let value;9752 try {@@ -100,15 +55,13 @@
10055 value = undefined;10156 }10257 if (value?.error || value?.accepted === false)103- throw Error(104- value.error || value.message || "The relay rejected the event.",105- );58+ throw Error(value.error || value.message || "The relay rejected the event.");10659 };107- const wireEvent = (content) => ({60+ const wireEvent = content => ({10861 kind: 10318,10962 created_at: Math.floor(Date.now() / 1000),11063 tags: [],111- content,64+ content11265 });11366 11467 class PrivateServices extends HTMLElement {@@ -118,13 +71,13 @@
11871 this.form = this.querySelector("form");11972 this.lines = this.querySelector("textarea[name=lines]");12073 this.output = this.querySelector("output");121- this.form?.addEventListener("submit", (event) => {74+ this.form?.addEventListener("submit", event => {12275 event.preventDefault();12376 if (this.busy) return;12477 this.busy = true;12578 this.form.querySelector("button").disabled = true;12679 this.save()127- .catch((error) => this.say("Error: " + error.message, true))80+ .catch(error => this.say("Error: " + error.message, true))12881 .finally(() => {12982 this.busy = false;13083 this.form.querySelector("button").disabled = false;@@ -135,7 +88,7 @@
13588 .then(() => {13689 this.form.querySelector("button").disabled = false;13790 })138- .catch((error) => this.say("Error: " + error.message, true)),91+ .catch(error => this.say("Error: " + error.message, true))13992 );14093 }14194 @@ -144,7 +97,7 @@
14497 whenSigned(fn) {14598 if (signer()?.signEvent || window.nostr?.signEvent) return fn();14699 this.say("Connect a signer to edit this list.");147- document.addEventListener("tiny:signer", () => fn(), { once: true });100+ document.addEventListener("tiny:signer", () => fn(), {once: true});148101 }149102 150103 say(text, error) {@@ -162,32 +115,21 @@
162115 return;163116 }164117 const rows = JSON.parse(await decrypt(pubkey, event.content));165- if (!Array.isArray(rows))166- throw Error("The encrypted list is not a JSON array.");118+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");167119 const urls = rows168- .filter(169- (row) => Array.isArray(row) && row[0] === "g" && validURL(row[1]),170- )171- .map((row) => relay(row[1]));120+ .filter(row => Array.isArray(row) && row[0] === "g" && validURL(row[1]))121+ .map(row => relay(row[1]));172122 this.lines.value = urls.join("\n");173- this.say(174- "Loaded " +175- urls.length +176- " private relay" +177- (urls.length === 1 ? "" : "s") +178- ".",179- );123+ this.say("Loaded " + urls.length + " private relay" + (urls.length === 1 ? "" : "s") + ".");180124 }181125 182126 async save() {183127 const rawURLs = this.lines.value184128 .split(/\r?\n/)185- .map((value) => value.trim())129+ .map(value => value.trim())186130 .filter(Boolean);187- if (rawURLs.some((value) => !validURL(value)))188- throw Error(189- "Use ws:// or wss:// URLs without credentials, queries or fragments.",190- );131+ if (rawURLs.some(value => !validURL(value)))132+ throw Error("Use ws:// or wss:// URLs without credentials, queries or fragments.");191133 const urls = rawURLs.map(relay);192134 const unique = [...new Set(urls)];193135 const pubkey = await publicKey();@@ -195,28 +137,17 @@
195137 let rows = [];196138 if (existing) {197139 rows = JSON.parse(await decrypt(pubkey, existing.content));198- if (!Array.isArray(rows))199- throw Error("The encrypted list is not a JSON array.");140+ if (!Array.isArray(rows)) throw Error("The encrypted list is not a JSON array.");200141 }201142 rows = mergeRows(rows, unique);202143 const content = await encrypt(pubkey, JSON.stringify(rows));203144 const active = signer();204145 if (!active?.signEvent) throw Error("Connect a signer first.");205146 const event = await active.signEvent(wireEvent(content));206- if (207- !acceptEvent(event, pubkey) ||208- event.content !== content ||209- event.tags.length !== 0210- )147+ if (!acceptEvent(event, pubkey) || event.content !== content || event.tags.length !== 0)211148 throw Error("The signer returned an invalid private relay list event.");212149 await publish(event);213- this.say(214- "Published " +215- unique.length +216- " private relay" +217- (unique.length === 1 ? "" : "s") +218- ".",219- );150+ this.say("Published " + unique.length + " private relay" + (unique.length === 1 ? "" : "s") + ".");220151 }221152 }222153 @@ -228,9 +159,9 @@
228159 decrypt,229160 wireEvent,230161 acceptEvent,231- readList,162+ readList232163 });233164 window.tiny = window.tiny || {};234- window.tiny.files = { ...window.tiny.files, privateServices: api };165+ window.tiny.files = {...window.tiny.files, privateServices: api};235166 customElements.define("private-services", PrivateServices);236167 })();@@ -1,4 +1,5 @@
1-// Shared browser helpers. This file is loaded before feature modules.1+// Shared browser helpers. This file is loaded before every feature module2+// and the signer bridge, so each helper exists exactly once.23 (() => {34 "use strict";45 const tiny = globalThis.tiny || {};@@ -10,7 +11,52 @@
1011 };1112 const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");1213 const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));13- tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256});14+ // fromHex parses a 64-character lowercase hex hash or key.15+ const fromHex = (value, message = "expected a 64-character lowercase hex value") => {16+ if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw Error(message);17+ return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));18+ };19+ const element = (tag, text) => {20+ const node = document.createElement(tag);21+ if (text !== undefined) node.textContent = text;22+ return node;23+ };24+ // relayURL normalizes a ws or wss URL without credentials or a fragment, or25+ // returns null. Strict mode also rejects query strings, which operator-entered26+ // relay lists never need.27+ const relayURL = (value, {strict = false} = {}) => {28+ if (typeof value !== "string" || value.length > 2048) return null;29+ let url;30+ try {31+ url = new URL(value.trim());32+ } catch {33+ return null;34+ }35+ if (36+ (url.protocol !== "wss:" && url.protocol !== "ws:") ||37+ !url.hostname ||38+ url.username ||39+ url.password ||40+ url.hash41+ )42+ return null;43+ if (strict && url.search) return null;44+ url.hostname = url.hostname.toLowerCase();45+ if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");46+ return url.toString().replace(/\/$/, "");47+ };48+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL});49+ // signer returns the active signer: a resumed remote signer first, then a50+ // NIP-07 extension.51+ tiny.signer = () => globalThis.tinySigner || globalThis.nostr;52+ const nip44 = (method, pubkey, text, signer = tiny.signer()) => {53+ if (typeof signer?.["nip44" + method] === "function") return signer["nip44" + method](pubkey, text);54+ const inner = signer?.nip44?.[method.toLowerCase()];55+ if (typeof inner === "function") return inner.call(signer.nip44, pubkey, text);56+ throw Error("The connected signer does not support NIP-44.");57+ };58+ tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);59+ tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);1460 // Feature modules add to these groups; guards can read them before load.1561 tiny.blossom = tiny.blossom || {};1662 tiny.files = tiny.files || {};@@ -6,6 +6,7 @@ import { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip19, ni
66 77 globalThis.NostrSigner = { generateSecretKey, getEventHash, getPublicKey, finalizeEvent, verifyEvent, nip44, decodeNpub: (value) => nip19.decode(value).data };88 globalThis.tiny = {files: {}};9+vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"), { filename: "tiny.js" });910 vm.runInThisContext(fs.readFileSync("internal/webui/file-messages.js", "utf8"), { filename: "file-messages.js" });1011 const fileMessages = globalThis.tiny.files.messages;1112 @@ -3,6 +3,7 @@ import {readFile} from "node:fs/promises";
33 import test from "node:test";44 import vm from "node:vm";55 6+const shared = await readFile(new URL("../internal/webui/tiny.js", import.meta.url), "utf8");67 const source = await readFile(new URL("../internal/webui/private-services.js", import.meta.url), "utf8");78 class Element {}89 const sandbox = {@@ -19,7 +20,7 @@ const sandbox = {
1920 };2021 sandbox.window = sandbox;2122 sandbox.tiny = {files: {}};22-vm.runInNewContext(source, sandbox);23+vm.runInNewContext(shared + source, sandbox);2324 const api = sandbox.tiny.files.privateServices;2425 2526 test("accepts tenant paths while rejecting unsafe relay URLs", () => {