clone

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

move the remaining shared helpers into tiny.js and match the module style

Dami committed 3d07a13
commit
3d07a13fd864cf8e20b351fc0e0de3253b2028df
parent
741d106cbfcf999deb12dda48b43356ccf4b0bda
author
Dami <dami.etoile@gmail.com>
10 files changed, +389 -726
Minternal/webui/blossom-encryption.js77+++------
Minternal/webui/blossom-manifests.js225+++++++++-----------------
Minternal/webui/blossom-upload.js152+++++-------------
Minternal/webui/components.js2+-
Minternal/webui/file-messages.js214+++++++++-----------------
Minternal/webui/file-workspace.js254++++++++++--------------------
Minternal/webui/private-services.js137++++------------
Minternal/webui/tiny.js50++++++-
Mscripts/file-messages.test.mjs1+
Mscripts/private-services.test.mjs3+-
M internal/webui/blossom-encryption.js +23 -54
@@ -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 })();
M internal/webui/blossom-manifests.js +80 -145
@@ -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 })();
M internal/webui/blossom-upload.js +46 -106
@@ -6,15 +6,14 @@
66 "use strict";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 })();
M internal/webui/components.js +1 -1
M internal/webui/file-messages.js +72 -142
@@ -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+})();
M internal/webui/file-workspace.js +82 -172
@@ -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;
M internal/webui/private-services.js +34 -103
@@ -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 })();
M internal/webui/tiny.js +48 -2
@@ -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 || {};
M scripts/file-messages.test.mjs +1 -0
M scripts/private-services.test.mjs +2 -1