tinyrelayfile
tinyrelay/scripts/file-workspace.test.mjs/

scripts/file-workspace.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5import { webcrypto } from "node:crypto";
6
7class 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}
34class 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}
37const 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};
51const 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
66const 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.
119const 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};
132const 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
138test("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
160test("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
180test("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
189test("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
195test("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
211test("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
226test("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
234test("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
261test("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
271test("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
294test("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
318test("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
353test("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
373test("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
408test("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
422test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs