| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | import { webcrypto } from "node:crypto" ; |
| 6 | |
| 7 | class Node { |
| 8 | constructor(tag = "" ) { this.tagName = tag.toUpperCase(); this.children = []; this.attributes = {}; this.listeners = {}; this.dataset = {}; this.textContent = "" ; } |
| 9 | append(...nodes) { nodes.forEach(node => { if (node) { this.children.push(node); node.parentNode = this; } }); } |
| 10 | replaceChildren(...nodes) { this.children = []; this.append(...nodes); } |
| 11 | click() {} |
| 12 | remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); } |
| 13 | setAttribute(name, value) { this.attributes[name] = String(value); } |
| 14 | getAttribute(name) { return this.attributes[name] ?? null; } |
| 15 | hasAttribute(name) { return name in this.attributes; } |
| 16 | addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } |
| 17 | set innerHTML(value) { this.children = []; parse(value, this); } |
| 18 | get childNodes() { return this.children; } |
| 19 | querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } |
| 20 | querySelectorAll(selector) { |
| 21 | const result = []; |
| 22 | const match = node => { |
| 23 | if (selector === "output" ) return node.tagName === "OUTPUT" ; |
| 24 | if (selector === "form:not([data-share])" ) return node.tagName === "FORM" && !node.hasAttribute("data-share" ); |
| 25 | if (selector === "input[type=file]" ) return node.tagName === "INPUT" && node.getAttribute("type" ) === "file" ; |
| 26 | const attr = selector.match(/^([^[]+)?\[([^=\]]+)(?:=\"([^\"]+)\")?\]$/); |
| 27 | if (attr) return (!attr[1] || node.tagName === attr[1].toUpperCase()) && node.hasAttribute(attr[2]) && (attr[3] === undefined || node.getAttribute(attr[2]) === attr[3]); |
| 28 | return node.tagName === selector.toUpperCase(); |
| 29 | }; |
| 30 | const walk = node => { node.children.forEach(child => { if (match(child)) result.push(child); walk(child); }); }; |
| 31 | walk(this); return result; |
| 32 | } |
| 33 | } |
| 34 | class Form extends Node { |
| 35 | constructor() { super("form" ); this.elements = { namedItem: name => this.querySelectorAll("input" ).concat(this.querySelectorAll("select" )).find(node => node.getAttribute("name" ) === name) }; } |
| 36 | } |
| 37 | const parse = (html, parent) => { |
| 38 | const stack = [parent]; |
| 39 | for (const token of html.match(/<[^>]+>|[^<]+/g) || []) { |
| 40 | if (!token.startsWith("<" )) continue; |
| 41 | if (/^<\/(option|input|br|meta|img)>/.test(token)) continue; |
| 42 | if (/^<\//.test(token)) { stack.pop(); continue; } |
| 43 | const match = token.match(/^<([\w-]+)([^>]*)>/); if (!match) continue; |
| 44 | const node = match[1] === "form" ? new Form() : new Node(match[1]); |
| 45 | for (const [, name, quote, value] of match[2].matchAll(/([\w-]+)(?:=(['"])(.*?)\2)?/g)) node.setAttribute(name, value ?? ""); |
| 46 | if (node.tagName === "INPUT" || node.tagName === "SELECT" ) node.value = node.getAttribute("value" ) || (node.tagName === "SELECT" ? "random" : "" ); |
| 47 | stack.at(-1).append(node); |
| 48 | if (!/^(input|br|meta|option|img)$/.test(match[1])) stack.push(node); |
| 49 | } |
| 50 | }; |
| 51 | const load = (workspace = false) => { |
| 52 | const document = new Node("document" ); document.documentElement = new Node("html" ); document.body = new Node("body" ); document.createElement = name => name === "form" ? new Form() : new Node(name); document.addEventListener = () => {}; document.dispatchEvent = () => {}; |
| 53 | const customElements = {registry: {}, define(name, ctor) { this.registry[name] = ctor; }}; |
| 54 | const window = {document, customElements, tiny: {blossom: {}, files: {}}, location: {href: "https://relay.test/files" , origin: "https://relay.test" , pathname: "/files" , hash: "" }, addEventListener: () => {}, matchMedia: () => ({matches: false, addEventListener: () => {}})}; |
| 55 | class HTMLElement extends Node {} |
| 56 | Object.assign(globalThis, {window, document, customElements, HTMLElement, location: window.location, Blob, URL, CustomEvent: class {}}); |
| 57 | globalThis.tiny = window.tiny; |
| 58 | Object.defineProperty(globalThis, "navigator" , {configurable: true, value: {clipboard: {writeText: async () => { throw Error("clipboard unavailable" ); }}}}); |
| 59 | globalThis.btoa = value => Buffer.from(value, "binary" ).toString("base64" ); globalThis.atob = value => Buffer.from(value, "base64" ).toString("binary" ); |
| 60 | vm.runInThisContext(fs.readFileSync("tinyclient/tiny.js" , "utf8" )); |
| 61 | if (workspace) { vm.runInThisContext(fs.readFileSync("tinyclient/blossom-manifests.js" , "utf8" )); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-encryption.js" , "utf8" )); vm.runInThisContext(fs.readFileSync("tinyclient/blossom-upload.js" , "utf8" )); vm.runInThisContext(fs.readFileSync("tinyclient/file-workspace.js" , "utf8" )); } else { delete globalThis.tiny.blossom.upload; } |
| 62 | vm.runInThisContext(fs.readFileSync("tinyclient/components.js" , "utf8" )); |
| 63 | return {document, window, FileTools: customElements.registry["file-tools" ], FileMirror: customElements.registry["file-mirror" ], FileUpload: customElements.registry["file-upload" ]}; |
| 64 | }; |
| 65 | |
| 66 | const workspaceServer = t => { |
| 67 | const loaded = load(true); |
| 68 | const {window} = loaded; |
| 69 | const blobs = new Map(), pending = new Map(), requests = []; |
| 70 | const originalFetch = globalThis.fetch; |
| 71 | window.tiny.localPath = path => "/r/test" + path; |
| 72 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 73 | window.tiny.authorization = async (url, method, body) => { |
| 74 | assert.equal(new URL(url).hash, "" ); |
| 75 | return "Nostr " + await window.tiny.sha256hex(body); |
| 76 | }; |
| 77 | globalThis.fetch = async (target, init = {}) => { |
| 78 | const url = new URL(target, window.location.href); |
| 79 | requests.push({url, init}); |
| 80 | assert.equal(url.hash, "" ); |
| 81 | assert.equal(url.searchParams.has("key" ), false); |
| 82 | if (init.method === "OPTIONS" ) return new Response(null, {status: 204, headers: {Allow: "PATCH" }}); |
| 83 | if (init.method === "PATCH" ) { |
| 84 | const hash = url.pathname.split("/" ).at(-1); |
| 85 | assert.match(url.pathname, /^\/r\/test\/[0-9a-f]{64}$/); |
| 86 | const length = Number(init.headers["upload-length" ]), offset = Number(init.headers["upload-offset" ]); |
| 87 | if (!pending.has(hash)) pending.set(hash, new Uint8Array(length)); |
| 88 | pending.get(hash).set(init.body, offset); |
| 89 | if (offset + init.body.length < length) return new Response(null, {status: 204}); |
| 90 | const bytes = pending.get(hash); |
| 91 | assert.equal(hash, await window.tiny.sha256hex(bytes)); |
| 92 | blobs.set(hash, bytes); pending.delete(hash); |
| 93 | return Response.json({sha256: hash, size: bytes.length}, {status: 201}); |
| 94 | } |
| 95 | const hash = url.searchParams.get("hash" ); |
| 96 | return blobs.has(hash) ? new Response(blobs.get(hash)) : new Response(null, {status: 404}); |
| 97 | }; |
| 98 | t.after(() => { globalThis.fetch = originalFetch; }); |
| 99 | const open = async url => { |
| 100 | const value = new URL(url); |
| 101 | Object.assign(window.location, {href: value.href, pathname: value.pathname, hash: value.hash}); |
| 102 | const root = new customElements.registry["file-workspace-root" ](); |
| 103 | await root.load(); |
| 104 | t.after(() => root.disconnectedCallback()); |
| 105 | return root; |
| 106 | }; |
| 107 | const store = async files => { |
| 108 | const upload = new customElements.registry["file-upload" ](); |
| 109 | upload.connectedCallback(); |
| 110 | const form = upload.querySelector("form" ); |
| 111 | form.elements.namedItem("file" ).files = files; |
| 112 | form.elements.namedItem("encrypt" ).checked = true; |
| 113 | return upload.run(form); |
| 114 | }; |
| 115 | return {...loaded, blobs, requests, store, open, originalFetch}; |
| 116 | }; |
| 117 | // sealedUpload stores one file through the upload control with encrypt checked |
| 118 | // and the plain signed PUT path, returning the share link and ciphertext. |
| 119 | const sealedUpload = async (window, FileUpload, file) => { |
| 120 | let uploaded; |
| 121 | delete window.tiny.blossom.upload; |
| 122 | window.tiny.localPath = path => path; |
| 123 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 124 | window.tiny.signedFetch = async (_path, _method, body) => { if (_path === "/files/metadata" ) return {ok: true}; uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; }; |
| 125 | const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback(); |
| 126 | const form = upload.querySelector("form" ); |
| 127 | form.elements.namedItem("file" ).files = [file]; |
| 128 | form.elements.namedItem("encrypt" ).checked = true; |
| 129 | const link = await upload.run(form); |
| 130 | return {upload, link, uploaded: () => uploaded}; |
| 131 | }; |
| 132 | const folderFile = (contents, path) => { |
| 133 | const file = new File([contents], path.split("/" ).at(-1), {type: "text/plain" }); |
| 134 | Object.defineProperty(file, "webkitRelativePath" , {value: path}); |
| 135 | return file; |
| 136 | }; |
| 137 | |
| 138 | test("nested folder components upload and decrypt large files without exposing keys" , async t => { |
| 139 | const server = workspaceServer(t); |
| 140 | const large = new Uint8Array(2 * 1024 * 1024 + 19); large[2] = 91; large[large.length - 1] = 73; |
| 141 | const url = await server.store([ |
| 142 | folderFile(large, "root/__proto__/f" ), folderFile("héllo" , "root/é.txt" ), |
| 143 | folderFile("emoji" , "root/😀.txt" ), folderFile("constructor" , "root/constructor" ) |
| 144 | ]); |
| 145 | const root = await server.open(url); |
| 146 | assert.deepEqual(root.entries.map(entry => entry.n), ["__proto__" , "constructor" , "é.txt" , "😀.txt" ]); |
| 147 | const child = await server.open(root.querySelector("a" ).href); |
| 148 | assert.equal(child.entries[0].n, "f" ); |
| 149 | assert.deepEqual(await child.download(child.entries[0]), large); |
| 150 | const key = new URLSearchParams(new URL(url).hash.slice(1)).get("key" ); |
| 151 | for (const {url: target, init} of server.requests) { |
| 152 | assert.equal(target.href.includes(key), false); |
| 153 | assert.equal(target.searchParams.has("filename" ), false); |
| 154 | assert.equal(target.searchParams.has("path" ), false); |
| 155 | if (init.body) assert.equal(new TextDecoder().decode(init.body).includes("héllo" ), false); |
| 156 | } |
| 157 | assert.ok(server.blobs.size >= 8); |
| 158 | }); |
| 159 | |
| 160 | test("plain uploads retain names and folder paths within the current library folder" , async () => { |
| 161 | const {window, FileUpload} = load(true); |
| 162 | window.location.href = "https://relay.test/files?view=library&path=Projects" ; |
| 163 | const requests = []; |
| 164 | window.tiny.signedFetch = async (target, method, bytes) => requests.push({target, method, bytes}); |
| 165 | const upload = new FileUpload(); |
| 166 | upload.connectedCallback(); |
| 167 | upload.controller = new AbortController(); |
| 168 | await upload.storePlain([folderFile("image" , "Trip/photos/snow & sun.jpg" ), new File(["notes" ], "notes.txt" )]); |
| 169 | const first = new URL(requests[0].target, window.location.href); |
| 170 | assert.equal(first.pathname, "/upload" ); |
| 171 | assert.equal(first.searchParams.get("filename" ), "snow & sun.jpg" ); |
| 172 | assert.equal(first.searchParams.get("path" ), "Projects/Trip/photos/snow & sun.jpg" ); |
| 173 | assert.equal(new URL(requests[1].target, window.location.href).searchParams.get("path" ), "Projects/notes.txt" ); |
| 174 | assert.equal(new TextDecoder().decode(requests[0].bytes), "image" ); |
| 175 | window.location.href = "https://relay.test/files?view=sites&path=weather" ; |
| 176 | await upload.storePlain([new File(["file" ], "new.txt" )]); |
| 177 | assert.equal(new URL(requests[2].target, window.location.href).searchParams.get("path" ), "new.txt" ); |
| 178 | }); |
| 179 | |
| 180 | test("directory fanout stays invisible while 175 entries remain browsable" , async t => { |
| 181 | const server = workspaceServer(t); |
| 182 | const files = Array.from({length: 175}, (_, index) => folderFile("content " + index, "wide/file-" + String(index).padStart(3, "0" ))); |
| 183 | const root = await server.open(await server.store(files)); |
| 184 | assert.equal(root.entries.length, 175); |
| 185 | assert.equal(root.entries[174].n, "file-174" ); |
| 186 | assert.equal(new TextDecoder().decode(await root.download(root.entries[174])), "content 174" ); |
| 187 | }); |
| 188 | |
| 189 | test("folder upload rejects unexpected server hashes" , async t => { |
| 190 | const server = workspaceServer(t); |
| 191 | globalThis.fetch = async (_url, init = {}) => init.method === "OPTIONS" ? new Response(null, {status:204, headers:{Allow:"PATCH" }}) : Response.json({sha256:"0" .repeat(64), size: init.body.length}, {status:201}); |
| 192 | await assert.rejects(server.store([folderFile("private" , "root/private.txt" )]), /descriptor/); |
| 193 | }); |
| 194 | |
| 195 | test("encrypted upload never touches the clipboard until Copy is pressed" , async () => { |
| 196 | const {window, FileUpload} = load(true); |
| 197 | let copies = 0; |
| 198 | navigator.clipboard.writeText = async () => { copies++; throw Error("denied" ); }; |
| 199 | const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext" ], "secret.txt" , {type: "text/plain" })); |
| 200 | assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext" ); |
| 201 | assert.match(link, /\/file\?hash=[0-9a-f]{64}#key=.*&iv=/); |
| 202 | assert.equal(upload.querySelector("[data-share-url]" ).value, link); |
| 203 | assert.equal(upload.querySelector("[data-share]" ).hidden, false); |
| 204 | assert.equal(copies, 0); |
| 205 | assert.equal(upload.querySelector("[data-open]" ).href, link); |
| 206 | await upload.copyLink(); |
| 207 | assert.equal(copies, 1); |
| 208 | assert.match(upload.out.textContent, /Copy the secret link from the field/); |
| 209 | }); |
| 210 | |
| 211 | test("plain uploads store every chosen file and refresh the listing" , async () => { |
| 212 | const {window, FileUpload} = load(true); |
| 213 | const sent = []; |
| 214 | let refreshed = 0; |
| 215 | window.tiny.signedFetch = async (path, method, body, options) => { sent.push({path, method, size: body.byteLength, type: options.contentType}); return {}; }; |
| 216 | window.tiny.navigate = async () => { refreshed++; }; |
| 217 | const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback(); |
| 218 | const form = upload.querySelector("form" ); |
| 219 | form.elements.namedItem("file" ).files = [new File(["one" ], "a.txt" , {type: "text/plain" }), new File(["two" ], "b.bin" )]; |
| 220 | assert.equal(await upload.run(form), undefined); |
| 221 | assert.deepEqual(sent, [{path: "/upload?filename=a.txt&path=a.txt" , method: "PUT" , size: 3, type: "text/plain" }, {path: "/upload?filename=b.bin&path=b.bin" , method: "PUT" , size: 3, type: "application/octet-stream" }]); |
| 222 | assert.equal(refreshed, 1); |
| 223 | assert.equal(upload.out.textContent, "Stored 2 files." ); |
| 224 | }); |
| 225 | |
| 226 | test("BUD15 key is rejected before mirror request" , async () => { |
| 227 | const {document, window, FileMirror} = load(); const mirror = new FileMirror(); document.append(mirror); mirror.connectedCallback(); |
| 228 | mirror.form.querySelector("input" ).value = "blossom:" + "a" .repeat(64) + "?enc=chk-v1&k=" + "1" .repeat(64); |
| 229 | let sent = false; window.tiny.signedFetch = async () => { sent = true; }; |
| 230 | await assert.rejects(() => mirror.submit(mirror.form), /keep k client side/); |
| 231 | assert.equal(sent, false); |
| 232 | }); |
| 233 | |
| 234 | test("sharing a newly uploaded file uses its current key, hash and ciphertext size" , async () => { |
| 235 | const {document, window, FileTools, FileUpload} = load(true); |
| 236 | const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["new secret" ], "private.txt" , {type: "text/plain" })); |
| 237 | window.location.hash = new URL(link).hash; |
| 238 | const element = new FileTools(); |
| 239 | element.setAttribute("hash" , await window.tiny.sha256hex(uploaded())); |
| 240 | element.setAttribute("type" , "text/plain" ); |
| 241 | element.setAttribute("size" , String(uploaded().length)); |
| 242 | document.append(element); element.connectedCallback(); |
| 243 | const shareForm = element.querySelector("[data-share]" ); |
| 244 | shareForm.querySelector("input" ).value = "b" .repeat(64); |
| 245 | globalThis.nostr = {signEvent: async value => value}; |
| 246 | let input; |
| 247 | globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }}; |
| 248 | await element.share(shareForm); |
| 249 | assert.ok(input, element.output.textContent); |
| 250 | assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded())); |
| 251 | assert.equal(input.size, uploaded().length); |
| 252 | assert.equal(input.plaintextHash, await window.tiny.sha256hex(new TextEncoder().encode("new secret" ))); |
| 253 | assert.match(input.key, /^[0-9a-f]{64}$/); |
| 254 | assert.match(input.nonce, /^[0-9a-f]{24}$/); |
| 255 | const key = await webcrypto.subtle.importKey("raw" , Buffer.from(input.key, "hex" ), "AES-GCM" , false, ["decrypt" ]); |
| 256 | const plain = await webcrypto.subtle.decrypt({name: "AES-GCM" , iv: Buffer.from(input.nonce, "hex" )}, key, uploaded()); |
| 257 | assert.equal(new TextDecoder().decode(plain), "new secret" ); |
| 258 | assert.equal(new URL(input.fileURL).hash, "" ); |
| 259 | }); |
| 260 | |
| 261 | test("HTTP mirror input never submits its secret fragment" , async () => { |
| 262 | const {document, window, FileMirror} = load(); |
| 263 | const mirror = new FileMirror(); document.append(mirror); mirror.connectedCallback(); |
| 264 | mirror.form.querySelector("input" ).value = "https://files.example/blob#key=secret" ; |
| 265 | let body; |
| 266 | window.tiny.signedFetch = async (_path, _method, value) => { body = value; return {json: async () => ({sha256: "a" .repeat(64)})}; }; |
| 267 | await mirror.submit(mirror.form); |
| 268 | assert.equal(JSON.parse(body).url, "https://files.example/blob" ); |
| 269 | }); |
| 270 | |
| 271 | test("local downloads verify integrity, decrypt and release their temporary URL" , async () => { |
| 272 | const {document, window, FileTools, FileUpload} = load(true); |
| 273 | const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["round trip" ], "round.txt" , {type: "text/plain" })); |
| 274 | window.location.hash = new URL(link).hash; |
| 275 | const element = new FileTools(); |
| 276 | element.setAttribute("hash" , await window.tiny.sha256hex(uploaded())); |
| 277 | document.append(element); |
| 278 | const originalFetch = globalThis.fetch; |
| 279 | try { |
| 280 | globalThis.fetch = async () => new Response(uploaded()); |
| 281 | element.connectedCallback(); |
| 282 | await element.decryptFromFragment(); |
| 283 | assert.equal(element.output.textContent, "Decrypted locally." ); |
| 284 | assert.equal(await (await originalFetch(element.downloadURL)).text(), "round trip" ); |
| 285 | const temporaryURL = element.downloadURL; |
| 286 | element.disconnectedCallback(); |
| 287 | await assert.rejects(originalFetch(temporaryURL)); |
| 288 | uploaded()[0] ^= 1; |
| 289 | await element.decryptFromFragment(); |
| 290 | assert.match(element.output.textContent, /ciphertext hash mismatch/); |
| 291 | } finally { globalThis.fetch = originalFetch; } |
| 292 | }); |
| 293 | |
| 294 | test("retry keeps the original ciphertext and key after interrupted encrypted upload" , async () => { |
| 295 | const {document, window, FileUpload} = load(true); |
| 296 | window.tiny.localPath = path => path; |
| 297 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 298 | const sent = []; |
| 299 | globalThis.tiny.blossom.upload = {upload: async (bytes, options) => { |
| 300 | assert.equal(options.url, "https://relay.test/" ); |
| 301 | sent.push(bytes.slice()); |
| 302 | if (sent.length === 1) throw Error("connection interrupted" ); |
| 303 | return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}}; |
| 304 | }}; |
| 305 | const upload = new FileUpload(); document.append(upload); upload.connectedCallback(); |
| 306 | const form = upload.querySelector("form" ); |
| 307 | form.elements.namedItem("file" ).files = [new File(["keep the same key" ], "private.txt" )]; |
| 308 | form.elements.namedItem("encrypt" ).checked = true; |
| 309 | await assert.rejects(upload.run(form), /connection interrupted/); |
| 310 | const originalFragment = upload.pending.fragment; |
| 311 | assert.equal(upload.querySelector("[data-retry]" ).disabled, false); |
| 312 | const link = await upload.run(); |
| 313 | assert.deepEqual(sent[1], sent[0]); |
| 314 | assert.equal(new URL(link).hash.slice(1), originalFragment); |
| 315 | assert.equal(upload.pending, null); |
| 316 | }); |
| 317 | |
| 318 | test("manifest retry keeps completed chunks and reports readable progress" , async t => { |
| 319 | const server = workspaceServer(t); |
| 320 | const transport = globalThis.fetch; |
| 321 | let interrupted = true; |
| 322 | const attempts = new Map(); |
| 323 | globalThis.fetch = async (url, init = {}) => { |
| 324 | if (init.method === "PATCH" ) { |
| 325 | const hash = new URL(url).pathname.split("/" ).at(-1); |
| 326 | attempts.set(hash, (attempts.get(hash) || 0) + 1); |
| 327 | if (interrupted && attempts.size === 2) return new Response(null, {status: 403}); |
| 328 | } |
| 329 | return transport(url, init); |
| 330 | }; |
| 331 | const bytes = new Uint8Array(5 * 1024 * 1024); |
| 332 | bytes[0] = 1; bytes[2 * 1024 * 1024] = 2; bytes[4 * 1024 * 1024] = 3; |
| 333 | const upload = new server.FileUpload(); upload.connectedCallback(); |
| 334 | const form = upload.querySelector("form" ); |
| 335 | form.elements.namedItem("file" ).files = [folderFile(bytes, "video/clip.mp4" )]; |
| 336 | form.elements.namedItem("encrypt" ).checked = true; |
| 337 | await assert.rejects(upload.run(form), /403/); |
| 338 | const first = attempts.keys().next().value; |
| 339 | assert.equal(upload.manifestSession.completed.size, 1); |
| 340 | interrupted = false; |
| 341 | const link = await upload.run(); |
| 342 | assert.equal(attempts.get(first), 1, "accepted first chunk must not be reuploaded" ); |
| 343 | const root = await server.open(link); |
| 344 | assert.deepEqual(await root.download(root.entries[0]), bytes); |
| 345 | upload.progress(3 * 1024 * 1024, 5 * 1024 * 1024); |
| 346 | assert.match(upload.out.textContent, /60%.*3.0 MiB.*5.0 MiB/); |
| 347 | assert.equal(upload.querySelector("progress" ).value, 0.6); |
| 348 | for (const request of server.requests.filter(r => r.init.method === "PATCH" )) { |
| 349 | assert.equal(request.url.searchParams.get("purpose" ), "chunk" ); |
| 350 | } |
| 351 | }); |
| 352 | |
| 353 | test("members upload keeps the MP4 name and type and never creates a secret link" , async () => { |
| 354 | const {window, FileUpload} = load(true); |
| 355 | const sent = []; |
| 356 | window.tiny.signedFetch = async (path, method, bytes, options) => { sent.push({path, method, bytes, options}); }; |
| 357 | window.tiny.navigate = async () => {}; |
| 358 | const upload = new FileUpload(); upload.connectedCallback(); |
| 359 | const form = upload.querySelector("form" ); |
| 360 | form.elements.namedItem("file" ).files = [new File(["mp4 bytes" ], "holiday.mp4" , {type: "video/mp4" })]; |
| 361 | form.elements.namedItem("access" ).value = "members" ; |
| 362 | form.elements.namedItem("encrypt" ).checked = true; |
| 363 | await upload.run(form); |
| 364 | assert.equal(sent.length, 1); |
| 365 | const url = new URL(sent[0].path, location.href); |
| 366 | assert.equal(url.searchParams.get("access" ), "members" ); |
| 367 | assert.equal(url.searchParams.get("filename" ), "holiday.mp4" ); |
| 368 | assert.equal(sent[0].options.contentType, "video/mp4" ); |
| 369 | assert.equal(new TextDecoder().decode(sent[0].bytes), "mp4 bytes" ); |
| 370 | assert.equal(upload.querySelector("[data-share]" ).hidden, true); |
| 371 | }); |
| 372 | |
| 373 | test("retry finalizes the same encrypted root without reuploading or exposing its key" , async () => { |
| 374 | const {window, FileUpload} = load(true); |
| 375 | window.tiny.localPath = path => path; |
| 376 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 377 | let uploads = 0, records = 0; |
| 378 | window.tiny.blossom.upload = {upload: async (bytes, options) => { |
| 379 | uploads++; |
| 380 | assert.equal(options.metadata.purpose, "chunk" ); |
| 381 | return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}}; |
| 382 | }}; |
| 383 | window.tiny.signedFetch = async (path, method, body) => { |
| 384 | assert.equal(path, "/files/metadata" ); |
| 385 | assert.equal(method, "POST" ); |
| 386 | const record = JSON.parse(body); |
| 387 | assert.match(record.name, /^Encrypted file [a-f0-9]{12}$/); |
| 388 | assert.equal(record.purpose, "file" ); |
| 389 | assert.equal(record.size, 7); |
| 390 | assert.equal(body.includes("secret-name.txt" ), false); |
| 391 | assert.equal("key" in record, false); |
| 392 | if (++records === 1) throw Error("server restarting" ); |
| 393 | return {ok: true}; |
| 394 | }; |
| 395 | const upload = new FileUpload(); upload.connectedCallback(); |
| 396 | const form = upload.querySelector("form" ); |
| 397 | form.elements.namedItem("file" ).files = [new File(["private" ], "secret-name.txt" )]; |
| 398 | form.elements.namedItem("encrypt" ).checked = true; |
| 399 | await assert.rejects(upload.run(form), /server restarting/); |
| 400 | const saved = upload.querySelector("[data-share-url]" ).value; |
| 401 | assert.match(saved, /#key=/); |
| 402 | assert.equal(upload.querySelector("[data-retry]" ).disabled, false); |
| 403 | assert.equal(await upload.run(), saved); |
| 404 | assert.equal(uploads, 1); |
| 405 | assert.equal(records, 2); |
| 406 | }); |
| 407 | |
| 408 | test("a 100 MiB encrypted MP4 opens a media player after reconstruction" , async t => { |
| 409 | const server = workspaceServer(t); |
| 410 | const bytes = new Uint8Array(100 * 1024 * 1024); |
| 411 | for (let index = 0; index < 50; index++) bytes[index * 2 * 1024 * 1024] = index; |
| 412 | const link = await server.store([new File([bytes], "large.mp4" , {type: "video/mp4" })]); |
| 413 | const root = await server.open(link); |
| 414 | const video = root.querySelector("video" ); |
| 415 | assert.ok(video, "decrypted manifest MP4 must have a player" ); |
| 416 | assert.equal(video.controls, true); |
| 417 | const data = await server.originalFetch(video.src).then(response => response.arrayBuffer()); |
| 418 | assert.deepEqual(new Uint8Array(data), bytes); |
| 419 | assert.match(root.querySelector("output" ).textContent, /100.0 MiB/); |
| 420 | }); |
| 421 | |
| 422 | test("large uploads hand signed requests to Background Fetch and read the parked descriptor" , async () => { |
| 423 | const {window, FileUpload} = load(true); |
| 424 | window.tiny.localPath = path => path; |
| 425 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256" , bytes)).toString("hex" ); |
| 426 | window.tiny.authorization = async (url, method, body) => "Nostr " + method + ":" + new URL(url).pathname + ":" + body.byteLength; |
| 427 | window.tiny.navigate = async () => {}; |
| 428 | const bytes = new Uint8Array(9 * 1024 * 1024); |
| 429 | bytes[0] = 7; |
| 430 | const hash = await window.tiny.sha256hex(bytes); |
| 431 | const descriptor = {sha256: hash, size: bytes.byteLength}; |
| 432 | let started; |
| 433 | let signalStart; |
| 434 | const didStart = new Promise(resolve => { signalStart = resolve; }); |
| 435 | const task = {result: "" , uploaded: 0, uploadTotal: bytes.byteLength, listeners: [], addEventListener(name, fn) { this.listeners.push(fn); }, removeEventListener() {}}; |
| 436 | const parked = new Map(); |
| 437 | globalThis.caches = {open: async () => ({match: async key => parked.get(key), delete: async key => parked.delete(key), put: async (key, value) => parked.set(key, value)})}; |
| 438 | globalThis.BackgroundFetchManager = class {}; |
| 439 | const store = new Map(); |
| 440 | globalThis.localStorage = {getItem: key => store.get(key) ?? null, setItem: (key, value) => store.set(key, value), removeItem: key => store.delete(key), get length() { return store.size; }}; |
| 441 | Object.defineProperty(globalThis, "localStorage" , {configurable: true, value: globalThis.localStorage}); |
| 442 | Object.keys = ((original => target => target === globalThis.localStorage ? [...store.keys()] : original(target)))(Object.keys); |
| 443 | Object.defineProperty(globalThis, "navigator" , {configurable: true, value: {clipboard: {writeText: async () => { throw Error("unavailable" ); }}, serviceWorker: {controller: {}, ready: Promise.resolve({backgroundFetch: {fetch: async (id, requests, options) => { started = {id, requests, options}; signalStart(); return task; }, get: async () => null}})}}}); |
| 444 | const originalFetch = globalThis.fetch; |
| 445 | globalThis.fetch = async (_url, init = {}) => new Response(null, {status: 204, headers: {Allow: "PATCH, PUT" }}); |
| 446 | try { |
| 447 | const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback(); |
| 448 | const form = upload.querySelector("form" ); |
| 449 | form.elements.namedItem("file" ).files = [new File([bytes], "big.bin" , {type: "application/octet-stream" })]; |
| 450 | form.elements.namedItem("background" ).checked = true; |
| 451 | const run = upload.run(form); |
| 452 | let timeout; |
| 453 | try { |
| 454 | await Promise.race([didStart, new Promise((_, reject) => { |
| 455 | timeout = setTimeout(() => reject(new Error("background fetch was not started" )), 5000); |
| 456 | })]); |
| 457 | } finally { |
| 458 | clearTimeout(timeout); |
| 459 | } |
| 460 | assert.ok(started, "background fetch was not started" ); |
| 461 | assert.equal(started.requests.length, 2); |
| 462 | assert.equal(started.requests[0].method, "PATCH" ); |
| 463 | assert.equal(new URL(started.requests[0].url).searchParams.get("filename" ), "big.bin" ); |
| 464 | assert.equal(new URL(started.requests[1].url).searchParams.get("path" ), "big.bin" ); |
| 465 | assert.equal(started.requests[1].headers.get("upload-offset" ), String(5 * 1024 * 1024)); |
| 466 | assert.match(started.requests[0].headers.get("authorization" ), /^Nostr PATCH:/); |
| 467 | assert.equal(started.options.uploadTotal, bytes.byteLength); |
| 468 | assert.equal(store.get("tiny.bg." + started.id) !== undefined, true); |
| 469 | parked.set("https://relay.test/uploads/" + started.id, Response.json(descriptor)); |
| 470 | task.result = "success" ; task.uploaded = bytes.byteLength; |
| 471 | for (const fn of task.listeners) await fn(); |
| 472 | await run; |
| 473 | assert.equal(store.has("tiny.bg." + started.id), false); |
| 474 | assert.equal(parked.size, 0); |
| 475 | assert.equal(upload.out.textContent, "Stored 1 file." ); |
| 476 | } finally { globalThis.fetch = originalFetch; } |
| 477 | }); |