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