tinyrelayfile
tinyrelay/tinyclient/file-workspace.js/

tinyclient/file-workspace.js raw
1// File workspace controls. Selected files and folders become encrypted
2// Blossom chunks and manifests; references are read back in the browser.
3(() => {
4 "use strict";
5
6 const MAX_FILES = 10000;
7 const MAX_DEPTH = 32;
8 const MAX_FILE_BYTES = 256 * 1024 * 1024;
9 const MAX_FOLDER_BYTES = 1024 * 1024 * 1024;
10 const MAX_FETCH_BYTES = 16 * 1024 * 1024 + 16;
11 const directoryType = "application/vnd.blossom.directory+msgpack";
12 const codec = () => globalThis.tiny?.blossom?.manifests;
13 const encryption = () => globalThis.tiny?.blossom?.encryption;
14 const upload = () => globalThis.tiny?.blossom?.upload;
15 const hex = value => globalThis.tiny.util.hex(value);
16 const fromHex = value => globalThis.tiny.util.fromHex(value || "", "Invalid hash or encryption key.");
17 const element = globalThis.tiny.util.element;
18 const fileSize = value => {
19 let size = Number(value) || 0;
20 for (const unit of ["B", "KiB", "MiB", "GiB"]) {
21 if (size < 1024 || unit === "GiB") return (unit === "B" ? size : size.toFixed(1)) + " " + unit;
22 size /= 1024;
23 }
24 };
25 const checkAbort = signal => {
26 if (signal?.aborted)
27 throw Object.assign(Error("Upload canceled. Retry while this page stays open."), {name: "AbortError"});
28 };
29 const validName = name =>
30 typeof name === "string" &&
31 name.length > 0 &&
32 name.isWellFormed() &&
33 ![".", ".."].includes(name) &&
34 !/[\/\u0000]/.test(name) &&
35 new TextEncoder().encode(name).length <= 1024;
36 const folderNode = () => ({directories: new Map(), files: new Map()});
37
38 function selectionTree(files) {
39 if (!files.length || files.length > MAX_FILES) throw Error("Choose a folder with 1 to 10,000 files.");
40 const root = folderNode();
41 let total = 0;
42 const paths = files.map(file => (file.webkitRelativePath || file.name).split("/"));
43 const commonRoot = paths.every(path => path.length > 1 && path[0] === paths[0][0]);
44 for (let index = 0; index < files.length; index++) {
45 const file = files[index];
46 const path = commonRoot ? paths[index].slice(1) : paths[index];
47 if (path.length > MAX_DEPTH || path.some(name => !validName(name))) throw Error("Invalid folder path.");
48 if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > MAX_FILE_BYTES)
49 throw Error("Files must be at most 256 MiB in the browser workspace.");
50 total += file.size;
51 if (total > MAX_FOLDER_BYTES) throw Error("The selected folder exceeds 1 GiB.");
52 let directory = root;
53 for (const name of path.slice(0, -1)) {
54 if (directory.files.has(name)) throw Error("A file and folder use the same name.");
55 if (!directory.directories.has(name)) directory.directories.set(name, folderNode());
56 directory = directory.directories.get(name);
57 }
58 const name = path.at(-1);
59 if (directory.files.has(name) || directory.directories.has(name)) throw Error("Duplicate folder entry.");
60 directory.files.set(name, file);
61 }
62 return {root, name: commonRoot ? paths[0][0] : "folder"};
63 }
64
65 async function storeEncrypted(plaintext, type, signal, session) {
66 checkAbort(signal);
67 const encrypted = await encryption().encryptCHK(plaintext);
68 checkAbort(signal);
69 if (session?.completed.has(encrypted.hash)) return session.completed.get(encrypted.hash);
70 const url = new URL(tiny.localPath("/"), location.href).href;
71 const result = await upload().upload(encrypted.ciphertext, {
72 url,
73 hash: encrypted.hash,
74 type,
75 signal,
76 metadata: {purpose: "chunk"},
77 retries: 3,
78 state: session?.states.get(encrypted.hash) || (() => {
79 const state = new Map();
80 session?.states.set(encrypted.hash, state);
81 return state;
82 })(),
83 authorize: (target, method, body) => tiny.authorization(target, method, body)
84 });
85 if (result.descriptor.sha256 !== encrypted.hash || result.descriptor.size !== encrypted.ciphertext.length)
86 throw Error("Relay returned an unexpected blob descriptor.");
87 const stored = {hash: encrypted.hash, key: fromHex(encrypted.key)};
88 session?.completed.set(encrypted.hash, stored);
89 return stored;
90 }
91
92 const manifestStore =
93 (signal, session) =>
94 async ({bytes, node}) =>
95 storeEncrypted(bytes, node.t === 2 || node.t === 3 ? directoryType : "application/octet-stream", signal, session);
96 const rootReference = details => {
97 const stored = details.manifests.at(-1);
98 return {
99 hash: stored.hash,
100 key: stored.key,
101 type: details.root.t,
102 size: details.root.l.reduce((sum, link) => sum + link.s, 0)
103 };
104 };
105
106 async function storeFile(file, signal, progress, session) {
107 if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");
108 const chunks = [];
109 for (let offset = 0; offset < file.size || offset === 0; offset += codec().CHUNK_SIZE) {
110 checkAbort(signal);
111 const plain = new Uint8Array(await file.slice(offset, offset + codec().CHUNK_SIZE).arrayBuffer());
112 const stored = await storeEncrypted(plain, "application/octet-stream", signal, session);
113 chunks.push({...stored, size: plain.length});
114 progress?.(plain.length);
115 if (file.size === 0) break;
116 }
117 if (chunks.length === 1) return {...chunks[0], type: 0};
118 return rootReference(
119 await codec().buildFile(chunks, {
120 store: manifestStore(signal, session),
121 returnDetails: true
122 })
123 );
124 }
125
126 async function storeDirectory(directory, signal, progress, session) {
127 const entries = [];
128 for (const [name, file] of directory.files) {
129 const stored = await storeFile(file, signal, progress, session);
130 entries.push({
131 ...stored,
132 name,
133 metadata: {type: file.type || "application/octet-stream"}
134 });
135 }
136 for (const [name, child] of directory.directories) {
137 const stored = await storeDirectory(child, signal, progress, session);
138 entries.push({...stored, name});
139 }
140 return rootReference(
141 await codec().buildDirectory(entries, {
142 store: manifestStore(signal, session),
143 returnDetails: true
144 })
145 );
146 }
147
148 const shareURL = (reference, name, type) => {
149 const url = new URL(tiny.localPath("/file"), location.href);
150 url.searchParams.set("hash", reference.hash);
151 url.hash = new URLSearchParams({
152 manifest: reference.hash,
153 key: hex(reference.key),
154 node: String(reference.type),
155 name,
156 type: type || "application/octet-stream"
157 }).toString();
158 return url.href;
159 };
160
161 // FileUpload is the one upload control. Plain uploads store each chosen
162 // file as it is. Encrypt keeps the contents and key in the browser: a single
163 // file gets a fresh random key, a folder or a large file becomes encrypted
164 // manifests, and the share link carries the key only in its fragment.
165 const LARGE_FILE_BYTES = 64 * 1024 * 1024;
166 // Transfers at least this large go through Background Fetch where the
167 // browser offers it, so closing the app does not stop them.
168 const BACKGROUND_BYTES = 8 * 1024 * 1024;
169 const backgroundAvailable = () =>
170 "BackgroundFetchManager" in globalThis && Boolean(navigator.serviceWorker?.controller);
171 const {b64url} = globalThis.tiny.util;
172 class FileUpload extends HTMLElement {
173 connectedCallback() {
174 if (this.bound) return;
175 this.bound = true;
176 this.innerHTML =
177 '<form><label>Files <input type="file" name="file" multiple></label><input type="file" name="folder" webkitdirectory multiple hidden><p data-drop><button type="button" data-pick-folder>Choose folder</button> or drop files or a folder here</p><label>Who can open <select name="access"><option value="public">Anyone with the link</option><option value="members">Relay members</option></select></label><details data-advanced><summary>Advanced</summary><label><input type="checkbox" name="encrypt"> Encrypt with a secret link</label><p><small>Only people with the complete link can decrypt it. Saved in this browser for your account.</small></p><label data-background hidden><input type="checkbox" name="background"> Continue in the background</label></details><button>Upload</button></form><progress max="1" value="0" hidden aria-label="Upload progress"></progress><output role="status"></output><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><div data-share hidden><p><a data-open>Open file</a> <button type="button" data-copy-link>Copy secret link</button></p><label>Secret link <input data-share-url readonly></label></div>';
178 this.out = this.querySelector("output");
179 const form = this.querySelector("form");
180 const files = form.elements.namedItem("file");
181 const folder = form.elements.namedItem("folder");
182 const access = form.elements.namedItem("access");
183 access.value = "public";
184 access.addEventListener("change", () => {
185 const encrypt = form.elements.namedItem("encrypt");
186 if (access.value === "members") encrypt.checked = false;
187 encrypt.disabled = access.value === "members";
188 });
189 this.querySelector("[data-background]").hidden = !backgroundAvailable();
190 files.addEventListener("change", () => this.choose([...files.files]));
191 folder.addEventListener("change", () => this.choose([...folder.files]));
192 this.querySelector("[data-pick-folder]").addEventListener("click", () => folder.click());
193 this.addEventListener("dragover", event => {
194 event.preventDefault();
195 this.dataset.over = "";
196 });
197 this.addEventListener("dragleave", () => delete this.dataset.over);
198 this.addEventListener("drop", event => {
199 event.preventDefault();
200 delete this.dataset.over;
201 this.dropped(event.dataTransfer).catch(error => this.say(error.message, true));
202 });
203 form.addEventListener("submit", event => {
204 event.preventDefault();
205 this.run(form).catch(() => {});
206 });
207 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());
208 this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));
209 this.querySelector("[data-copy-link]").addEventListener("click", () => this.copyLink());
210 this.intake().catch(error => this.say(error.message, true));
211 this.resumeBackground().catch(() => {});
212 }
213 // storeBackground hands one blob to the browser's Background Fetch with
214 // every request signed up front, then waits for the parked descriptor.
215 async storeBackground(bytes, {hash, type, name, fragment, metadata}) {
216 const registration = await navigator.serviceWorker.ready;
217 const base = new URL(tiny.localPath("/"), location.href).href;
218 const authorize = (target, method, body) => tiny.authorization(target, method, body);
219 let requests;
220 const probe = await fetch(base, {method: "OPTIONS"}).catch(() => null);
221 if (probe && /(?:^|,|\s)PATCH(?:,|\s|$)/i.test(probe.headers.get("allow") || "")) {
222 const planned = await upload().plan(bytes, {url: base, hash, type});
223 requests = await Promise.all(planned.requests.map(async item => {
224 const target = this.uploadTarget(item.url, metadata);
225 const headers = {...item.headers, authorization: await authorize(target, item.method, item.body)};
226 return new Request(target, {method: item.method, headers, body: item.body});
227 }));
228 } else {
229 const target = this.uploadTarget(base.replace(/\/$/, "") + "/upload", metadata);
230 requests = [
231 new Request(target, {
232 method: "PUT",
233 headers: {"content-type": type, authorization: await authorize(target, "PUT", bytes)},
234 body: bytes
235 })
236 ];
237 }
238 const id = "tiny-upload-" + crypto.randomUUID();
239 localStorage.setItem("tiny.bg." + id, JSON.stringify({hash, fragment: fragment || "", name, size: bytes.byteLength - (fragment ? 16 : 0), pubkey: this.getAttribute("pubkey") || "", root: tiny.root || "", at: Date.now()}));
240 const task = await registration.backgroundFetch.fetch(id, requests, {
241 title: "Uploading " + name,
242 icons: [
243 {src: new URL(tiny.localPath("/icon-192.png"), location.href).href, sizes: "192x192", type: "image/png"}
244 ],
245 uploadTotal: bytes.byteLength
246 });
247 this.say("Uploading in the background. You can close this page.");
248 return this.awaitBackground(task, hash, id);
249 }
250 awaitBackground(task, hash, id) {
251 return new Promise((resolve, reject) => {
252 const settle = async () => {
253 if (task.result === "") {
254 if (task.uploadTotal)
255 this.say("Uploading in the background " + Math.round((task.uploaded / task.uploadTotal) * 100) + "%…");
256 return false;
257 }
258 task.removeEventListener?.("progress", settle);
259 try {
260 resolve(
261 await this.backgroundDescriptor(id, hash, task.result === "success" ? "" : task.failureReason || "failed")
262 );
263 } catch (error) {
264 reject(error);
265 }
266 return true;
267 };
268 task.addEventListener("progress", settle);
269 settle();
270 });
271 }
272 // backgroundDescriptor reads the parked result, verifies the hash and
273 // forgets the transfer.
274 async backgroundDescriptor(id, hash, failure) {
275 const cache = await caches.open("tiny-uploads");
276 const key = new URL(tiny.localPath("/uploads/" + id), location.href).href;
277 const response = await cache.match(key);
278 await cache.delete(key);
279 localStorage.removeItem("tiny.bg." + id);
280 if (failure) throw Error("Background upload " + failure + ".");
281 const descriptor = response ? await response.json().catch(() => null) : null;
282 if (!descriptor || descriptor.sha256 !== hash) throw Error("Relay returned an unexpected hash.");
283 return descriptor;
284 }
285 // resumeBackground picks up transfers started on an earlier visit.
286 async resumeBackground() {
287 if (!backgroundAvailable()) return;
288 const pending = Object.keys(localStorage).filter(key => key.startsWith("tiny.bg."));
289 if (!pending.length) return;
290 const registration = await navigator.serviceWorker.ready;
291 for (const key of pending) {
292 const id = key.slice("tiny.bg.".length);
293 let info;
294 try {
295 info = JSON.parse(localStorage.getItem(key));
296 } catch {
297 localStorage.removeItem(key);
298 continue;
299 }
300 if (info.pubkey !== (this.getAttribute("pubkey") || "") || info.root !== (tiny.root || "")) continue;
301 const task = await registration.backgroundFetch.get(id).catch(() => null);
302 const finish = async descriptor => {
303 if (!info.fragment) {
304 this.say("Background upload of " + info.name + " finished.");
305 return tiny.navigate?.(location.href);
306 }
307 const link = new URL(tiny.localPath("/file"), location.href);
308 link.search = "?hash=" + descriptor.sha256;
309 link.hash = info.fragment;
310 const params = new URLSearchParams(info.fragment);
311 await this.remember(link.href, [{name: info.name, size: info.size || Math.max(0, descriptor.size - 16), type: params.get("type") || "application/octet-stream"}], false);
312 return this.showLink(link.href);
313 };
314 try {
315 if (task && task.result === "") await this.awaitBackground(task, info.hash, id).then(finish);
316 else
317 await this.backgroundDescriptor(
318 id,
319 info.hash,
320 task && task.result !== "success" ? task.failureReason || "failed" : ""
321 ).then(finish);
322 } catch (error) {
323 this.say(error.message, true);
324 }
325 }
326 }
327 // intake collects files parked by the service worker for a share from
328 // another app. They wait here until the person presses Upload.
329 async intake() {
330 const id = new URLSearchParams(location.hash.slice(1)).get("share");
331 if (!id || !/^[0-9a-f-]{36}$/.test(id) || !globalThis.caches) return;
332 const cache = await caches.open("tiny-share");
333 const meta = await (await cache.match("/share/" + id + "/meta"))?.json();
334 if (!meta) return;
335 const files = [];
336 for (let index = 0; index < (meta.count || 0); index++) {
337 const response = await cache.match("/share/" + id + "/" + index);
338 if (!response) continue;
339 const blob = await response.blob();
340 files.push(
341 new File([blob], decodeURIComponent(response.headers.get("x-name") || "shared"), {
342 type: response.headers.get("content-type") || blob.type
343 })
344 );
345 }
346 for (const key of await cache.keys())
347 if (new URL(key.url).pathname.startsWith("/share/" + id + "/")) await cache.delete(key);
348 history.replaceState?.(null, "", location.pathname + location.search);
349 if (files.length) {
350 this.choose(files);
351 this.say(files.length + " shared file" + (files.length === 1 ? "" : "s") + " ready. Press Upload.");
352 return;
353 }
354 const link = meta.url || (meta.text || "").match(/https?:\/\/\S+/)?.[0];
355 const mirror = link && document.querySelector("file-mirror input[name=url]");
356 if (mirror) {
357 mirror.value = link;
358 mirror.closest("details")?.setAttribute("open", "");
359 this.say("Shared link ready to import.");
360 }
361 }
362 // choose records a selection. A folder is recognized from the relative
363 // paths that folder pickers and folder drops attach to their files.
364 choose(files) {
365 const folder = files.some(file => (file.webkitRelativePath || "").includes("/"));
366 this.chosen = {files, folder};
367 if (files.length)
368 this.say(
369 folder
370 ? files.length + " files in a folder selected."
371 : files.length + (files.length === 1 ? " file selected." : " files selected.")
372 );
373 }
374 async dropped(transfer) {
375 const entries = [...(transfer.items || [])].map(item => item.webkitGetAsEntry?.()).filter(Boolean);
376 if (!entries.length) return this.choose([...transfer.files]);
377 const files = [];
378 const walk = (entry, path) =>
379 new Promise((resolve, reject) => {
380 if (files.length > MAX_FILES) return reject(Error("Choose a folder with at most 10,000 files."));
381 if (entry.isFile) {
382 entry.file(file => {
383 if (path) Object.defineProperty(file, "webkitRelativePath", {value: path + file.name});
384 files.push(file);
385 resolve();
386 }, reject);
387 return;
388 }
389 const reader = entry.createReader();
390 const next = () =>
391 reader.readEntries(async batch => {
392 if (!batch.length) return resolve();
393 try {
394 for (const child of batch) await walk(child, path + entry.name + "/");
395 } catch (error) {
396 return reject(error);
397 }
398 next();
399 }, reject);
400 next();
401 });
402 for (const entry of entries) await walk(entry, "");
403 this.choose(files);
404 }
405 disconnectedCallback() {
406 this.controller?.abort();
407 }
408 say(message, error = false) {
409 this.out.textContent = message;
410 if (error) this.out.dataset.error = "";
411 else delete this.out.dataset.error;
412 }
413 progress(sent, total, phase = "Uploading") {
414 const fraction = total ? Math.min(1, sent / total) : 0;
415 const meter = this.querySelector("progress");
416 meter.hidden = false;
417 meter.value = fraction;
418 this.say(phase + " " + Math.round(fraction * 100) + "% — " + fileSize(sent) + " of " + fileSize(total));
419 }
420 controls(busy, completed) {
421 const retryable = Boolean(this.selection) && !completed;
422 this.querySelector("[data-controls]").hidden = !busy && !retryable;
423 this.querySelector("[data-cancel]").disabled = !busy;
424 this.querySelector("[data-retry]").disabled = busy || !retryable;
425 }
426 async run(form) {
427 if (this.busy) return;
428 if (form) {
429 if (!this.chosen) this.choose([...form.elements.namedItem("file").files]);
430 const access = form.elements.namedItem("access").value === "members" ? "members" : "public";
431 this.selection = {...this.chosen, access, encrypt: access !== "members" && Boolean(form.elements.namedItem("encrypt").checked), background: Boolean(form.elements.namedItem("background").checked)};
432 this.pending = null;
433 this.completedLink = null;
434 this.manifestSession = {completed: new Map(), states: new Map()};
435 this.plainStates = new Map();
436 }
437 if (!this.selection?.files.length) { this.say("Choose a file or folder first.", true); return; }
438 this.busy = true;
439 this.controller = new AbortController();
440 this.controls(true, false);
441 this.querySelector("[data-share]").hidden = true;
442 let completed = false;
443 // Keep the phone awake for the length of the upload.
444 const wakeLock = await navigator.wakeLock?.request?.("screen").catch(() => null);
445 try {
446 const {files, folder, encrypt} = this.selection;
447 let link = this.completedLink;
448 if (!link) {
449 if (!encrypt) await this.storePlain(files);
450 else if (folder || files.length > 1) link = await this.storeManifest(files, true);
451 else if (files[0].size > LARGE_FILE_BYTES) link = await this.storeManifest(files, false);
452 else link = await this.storeSealed(files[0]);
453 }
454 if (link) {
455 this.completedLink = link;
456 await this.remember(link, files, folder);
457 await this.showLink(link);
458 } else {
459 this.say("Stored " + files.length + (files.length === 1 ? " file." : " files."));
460 await tiny.navigate?.(location.href);
461 }
462 completed = true;
463 if (link && this.catalogSaved) await tiny.navigate?.(location.href);
464 return link || undefined;
465 } catch (error) {
466 const canceled = this.controller.signal.aborted;
467 this.say(
468 (canceled ? "Upload canceled." : "Upload stopped: " + error.message) + " Retry while this page stays open.",
469 true
470 );
471 throw error;
472 } finally {
473 wakeLock?.release?.().catch?.(() => {});
474 this.busy = false;
475 this.controls(false, completed);
476 if (completed) this.querySelector("progress").hidden = true;
477 if (completed) this.selection = this.chosen = null;
478 }
479 }
480 async storePlain(files) {
481 const query = new URL(location.href).searchParams;
482 const parent = (!query.get("view") || query.get("view") === "library") ? query.get("path") || "" : "";
483 const total = files.reduce((size, file) => size + file.size, 0);
484 let sent = 0;
485 for (const [index, file] of files.entries()) {
486 checkAbort(this.controller.signal);
487 if (file.size > MAX_FILE_BYTES) throw Error("Files must be at most 256 MiB in the browser workspace.");
488 this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…");
489 const bytes = new Uint8Array(await file.arrayBuffer());
490 const type = file.type || "application/octet-stream";
491 const selectedPath = file.webkitRelativePath || file.name;
492 const metadata = {filename: file.name, path: parent ? parent + "/" + selectedPath : selectedPath};
493 if (this.selection?.access === "members") metadata.access = "members";
494 if (this.selection?.background && files.length === 1 && bytes.byteLength >= BACKGROUND_BYTES && backgroundAvailable()) {
495 await this.storeBackground(bytes, {hash: await tiny.sha256hex(bytes), type, name: file.name, metadata});
496 continue;
497 }
498 this.progress(sent, total, "Uploading " + file.name);
499 if (bytes.byteLength >= BACKGROUND_BYTES && upload()?.upload) {
500 this.plainStates ||= new Map();
501 if (!this.plainStates.has(index)) this.plainStates.set(index, new Map());
502 await upload().upload(bytes, {
503 url: new URL(tiny.localPath("/"), location.href).href,
504 type, metadata, retries: 3, state: this.plainStates.get(index), signal: this.controller.signal,
505 authorize: (target, method, body) => tiny.authorization(target, method, body),
506 onProgress: progress => this.progress(sent + progress.sent, total, "Uploading " + file.name),
507 onState: state => { if (state.status === "retrying") this.say("Connection interrupted. Retrying " + file.name + "…"); }
508 });
509 } else {
510 await tiny.signedFetch(this.uploadTarget("/upload", metadata), "PUT", bytes, {contentType: type, signal: this.controller.signal});
511 }
512 sent += file.size;
513 this.progress(sent, total);
514 }
515 }
516 uploadTarget(target, metadata) {
517 return metadata ? target + "?" + new URLSearchParams(metadata) : target;
518 }
519 // storeSealed encrypts one file with a fresh AES-GCM key. The ciphertext
520 // and key stay in memory so a retry resumes without re-encrypting.
521 async storeSealed(file) {
522 if (!this.pending) {
523 if (file.size > MAX_FILE_BYTES) throw Error("Encrypted browser uploads are limited to 256 MiB.");
524 this.say("Encrypting " + file.name + " (" + fileSize(file.size) + ")…");
525 const plaintext = new Uint8Array(await file.arrayBuffer());
526 const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]);
527 const iv = crypto.getRandomValues(new Uint8Array(12));
528 const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext));
529 const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));
530 const fragment =
531 "key=" +
532 b64url(rawKey) +
533 "&iv=" +
534 b64url(iv) +
535 "&name=" +
536 encodeURIComponent(file.name) +
537 "&type=" +
538 encodeURIComponent(file.type || "application/octet-stream") +
539 "&ox=" +
540 (await tiny.sha256hex(plaintext));
541 this.pending = {ciphertext, fragment, state: new Map()};
542 }
543 checkAbort(this.controller.signal);
544 const {ciphertext, fragment, state} = this.pending;
545 const hash = await tiny.sha256hex(ciphertext);
546 this.progress(0, file.size);
547 let descriptor;
548 if (this.selection?.background && ciphertext.byteLength >= BACKGROUND_BYTES && backgroundAvailable() && upload()?.plan) {
549 descriptor = await this.storeBackground(ciphertext, {
550 hash,
551 type: "application/octet-stream",
552 metadata: {purpose: "chunk"},
553 name: file.name,
554 fragment
555 });
556 } else if (upload()?.upload) {
557 const task = upload().upload(ciphertext, {
558 url: new URL(tiny.localPath("/"), location.href).href,
559 hash,
560 type: "application/octet-stream",
561 metadata: {purpose: "chunk"},
562 retries: 3,
563 state,
564 signal: this.controller.signal,
565 authorize: (target, method, body) => tiny.authorization(target, method, body),
566 onProgress: progress => this.progress(Math.min(file.size, progress.sent), file.size),
567 onState: state => { if (state.status === "retrying") this.say("Connection interrupted. Retrying " + file.name + "…"); }
568 });
569 descriptor = (await task).descriptor;
570 } else {
571 const response = await tiny.signedFetch("/upload?purpose=chunk", "PUT", ciphertext, {
572 contentType: "application/octet-stream",
573 signal: this.controller.signal
574 });
575 descriptor = await response.json();
576 }
577 if (descriptor.sha256 !== hash) throw Error("Relay returned an unexpected hash.");
578 this.pending = null;
579 const link = new URL(tiny.localPath("/file"), location.href);
580 link.search = "?hash=" + hash;
581 link.hash = fragment;
582 return link.href;
583 }
584 async storeManifest(files, folder) {
585 let sent = 0;
586 const total = files.reduce((size, file) => size + file.size, 0);
587 const progress = size => {
588 sent += size;
589 this.progress(sent, total, "Encrypting and uploading");
590 };
591 this.progress(0, total, "Encrypting and uploading");
592 this.manifestSession ||= {completed: new Map(), states: new Map()};
593 let reference, name, type;
594 if (folder) {
595 const selected = selectionTree(files);
596 name = selected.name || "files";
597 reference = await storeDirectory(selected.root, this.controller.signal, progress, this.manifestSession);
598 } else {
599 const file = files[0];
600 name = file.name;
601 type = file.type;
602 reference = await storeFile(file, this.controller.signal, progress, this.manifestSession);
603 }
604 checkAbort(this.controller.signal);
605 return shareURL(reference, name, type);
606 }
607 async remember(link, files, folder) {
608 const url = new URL(link);
609 const params = new URLSearchParams(url.hash.slice(1));
610 const hash = url.searchParams.get("hash");
611 const name = params.get("name") || files[0].name;
612 const type = params.get("type") || files[0].type || "application/octet-stream";
613 const size = files.reduce((sum, file) => sum + file.size, 0);
614 const pubkey = this.getAttribute("pubkey");
615 const isFolder = folder || ["2", "3"].includes(params.get("node"));
616 this.catalogSaved = tiny.files?.catalog?.save({hash, link, name, type, size, folder: isFolder}, pubkey) || false;
617 // Keep the working key visible even if recording the library entry fails.
618 await this.showLink(link);
619 if (tiny.signedFetch) {
620 const label = (isFolder ? "Encrypted folder " : "Encrypted file ") + hash.slice(0, 12);
621 const response = await tiny.signedFetch("/files/metadata", "POST", JSON.stringify({hash, name: label, path: label, purpose: "file", size, type: "application/octet-stream"}), {contentType: "application/json", signal: this.controller?.signal});
622 if (response.ok === false) throw Error("File uploaded; its library entry could not be saved. Retry to finish.");
623 }
624 tiny.files?.catalog?.decorate(pubkey);
625 }
626 async showLink(link) {
627 const share = this.querySelector("[data-share-url]");
628 share.value = link;
629 this.querySelector("[data-share]").hidden = false;
630 const open = this.querySelector("[data-open]");
631 open.href = link;
632 open.setAttribute("fx-ignore", "");
633 this.say(this.catalogSaved ? "Stored. You can reopen this file from My files in this browser." : "Stored. Keep the secret link to reopen this encrypted file.");
634 }
635 async copyLink() {
636 const share = this.querySelector("[data-share-url]");
637 try {
638 if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable");
639 await navigator.clipboard.writeText(share.value);
640 this.say("Secret link copied.");
641 } catch {
642 share.select?.();
643 this.say("Copy the secret link from the field above.");
644 }
645 }
646 }
647
648 async function fetchBlob(hash, signal) {
649 fromHex(hash);
650 const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {
651 signal
652 });
653 if (!response.ok) throw Error("Download failed (" + response.status + ").");
654 if (Number(response.headers.get("Content-Length")) > MAX_FETCH_BYTES)
655 throw Error("Linked blob exceeds the browser fetch limit.");
656 const reader = response.body?.getReader();
657 if (!reader) {
658 const bytes = new Uint8Array(await response.arrayBuffer());
659 if (bytes.length > MAX_FETCH_BYTES) throw Error("Linked blob exceeds the browser fetch limit.");
660 return bytes;
661 }
662 const parts = [];
663 let size = 0;
664 try {
665 for (;;) {
666 const {value, done} = await reader.read();
667 if (done) break;
668 size += value.length;
669 if (size > MAX_FETCH_BYTES) {
670 await reader.cancel();
671 throw Error("Linked blob exceeds the browser fetch limit.");
672 }
673 parts.push(value);
674 }
675 } finally {
676 reader.releaseLock();
677 }
678 const bytes = new Uint8Array(size);
679 let offset = 0;
680 for (const part of parts) {
681 bytes.set(part, offset);
682 offset += part.length;
683 }
684 return bytes;
685 }
686
687 async function plaintext(reference, signal) {
688 const hash = hex(reference.h);
689 const raw = await fetchBlob(hash, signal);
690 if (hex(await codec().sha256(raw)) !== hash) throw Error("Blob hash mismatch.");
691 return reference.k ? encryption().decryptCHK(raw, hex(reference.k), hash) : raw;
692 }
693 const decrypt = (raw, link) => encryption().decryptCHK(raw, hex(link.k), hex(link.h));
694
695 class Root extends HTMLElement {
696 connectedCallback() {
697 if (this.bound) return;
698 this.bound = true;
699 const params = new URLSearchParams(location.hash.slice(1));
700 if (!params.has("manifest") && this.getAttribute("type") !== directoryType) {
701 this.hidden = true;
702 return;
703 }
704 this.hidden = false;
705 this.load().catch(error => this.fail(error.message));
706 }
707 disconnectedCallback() {
708 this.controller?.abort();
709 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
710 }
711 // fail reports an error without discarding a listing that has already
712 // rendered, so one failed download leaves the folder browsable.
713 fail(message) {
714 const error = element("p", "Content unavailable: " + message);
715 error.setAttribute("role", "alert");
716 this.querySelector("p[role=alert]")?.remove();
717 if (this.querySelector("h2")) this.querySelector("h2").after(error);
718 else this.replaceChildren(error);
719 }
720 async load() {
721 this.controller?.abort();
722 this.controller = new AbortController();
723 const params = new URLSearchParams(location.hash.slice(1));
724 const reference = {
725 h: fromHex(params.get("manifest") || this.getAttribute("hash"))
726 };
727 if (params.has("key")) reference.k = fromHex(params.get("key"));
728 const type = params.has("node") ? Number(params.get("node")) : 2;
729 if (![0, 1, 2, 3].includes(type)) throw Error("Unsupported manifest type.");
730 const data = await plaintext(reference, this.controller.signal);
731 const heading = element("h2", params.get("name") || "Stored content");
732 this.replaceChildren(heading);
733 if (type === 0) {
734 this.offerDownload(data, params.get("name") || "file", params.get("type"));
735 return;
736 }
737 const root = codec().decodeManifest(data);
738 if (root.t !== type) throw Error("Manifest type does not match its reference.");
739 if (type === 1) {
740 const media = /^(video\/(mp4|webm|ogg)|audio\/(mpeg|mp4|ogg|wav|webm))$/.test(params.get("type") || "");
741 const button = element("button", media ? "Load media player" : "Prepare download");
742 button.type = "button";
743 const open = () => this.download({
744 ...reference,
745 t: 1,
746 s: root.l.reduce((sum, link) => sum + link.s, 0),
747 n: params.get("name") || "file",
748 m: {type: params.get("type")}
749 }).catch(error => this.fail(error.message));
750 button.addEventListener("click", open);
751 this.append(button);
752 if (media) await open();
753 return;
754 }
755 const entries = await codec().resolveDirectory(root, hash => fetchBlob(hash, this.controller.signal), {
756 decrypt,
757 maxDepth: MAX_DEPTH,
758 maxEntries: MAX_FILES,
759 maxBytes: 64 * 1024 * 1024
760 });
761 const list = element("ul");
762 if (!entries.length) this.append(element("p", "This folder is empty."));
763 for (const link of entries) {
764 const item = element("li");
765 if (link.t === 2 || link.t === 3) {
766 const open = element("a", link.n + "/");
767 const url = new URL(tiny.localPath("/file"), location.href);
768 url.searchParams.set("hash", hex(link.h));
769 const fragment = new URLSearchParams({
770 manifest: hex(link.h),
771 node: String(link.t),
772 name: link.n
773 });
774 if (link.k) fragment.set("key", hex(link.k));
775 url.hash = fragment.toString();
776 open.href = url.href;
777 item.append(open);
778 } else {
779 const button = element("button", link.n + " (" + fileSize(link.s) + ")");
780 button.type = "button";
781 button.addEventListener("click", () => this.download(link).catch(error => this.fail(error.message)));
782 item.append(button);
783 }
784 list.append(item);
785 }
786 this.append(list);
787 this.entries = entries;
788 }
789 async download(link) {
790 if (this.downloading) return;
791 this.downloading = true;
792 const status = this.querySelector("output") || element("output");
793 status.setAttribute("role", "status");
794 if (!status.parentNode) this.append(status);
795 status.textContent = "Loading " + link.n + " (" + fileSize(link.s) + ")…";
796 try {
797 let data;
798 if (link.t === 0) data = await plaintext(link, this.controller.signal);
799 else if (link.t === 1) {
800 const manifest = codec().decodeManifest(await plaintext(link, this.controller.signal));
801 let fetched = 0;
802 data = await codec().readFile(manifest, async (hash, part) => {
803 const raw = await fetchBlob(hash, this.controller.signal);
804 if (part.t === 0) fetched += part.s;
805 status.textContent = "Loading " + Math.min(100, Math.round(fetched / link.s * 100)) + "% — " + fileSize(fetched) + " of " + fileSize(link.s);
806 return raw;
807 }, {
808 decrypt,
809 maxDepth: MAX_DEPTH,
810 maxBytes: MAX_FILE_BYTES
811 });
812 } else throw Error("Unsupported file link.");
813 if (data.length !== link.s) throw Error("File size does not match its directory entry.");
814 this.offerDownload(data, link.n, link.m?.type);
815 status.textContent = "Ready. " + fileSize(data.length) + " decrypted in your browser.";
816 return data;
817 } finally { this.downloading = false; }
818 }
819 offerDownload(data, name, type) {
820 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);
821 this.downloadURL = URL.createObjectURL(new Blob([data], {type: type || "application/octet-stream"}));
822 const link = element("a", "Save " + name);
823 link.href = this.downloadURL;
824 link.download = name;
825 this.querySelector("[data-prepared-file]")?.remove();
826 const prepared = element("div");
827 prepared.setAttribute("data-prepared-file", "");
828 let media;
829 if (/^video\/(mp4|webm|ogg)$/.test(type || "")) media = element("video");
830 else if (/^audio\/(mpeg|mp4|ogg|wav|webm)$/.test(type || "")) media = element("audio");
831 else if (/^image\/(png|jpeg|gif|webp|avif)$/.test(type || "")) media = element("img");
832 if (media) {
833 media.src = this.downloadURL;
834 media.controls = true;
835 media.preload = "metadata";
836 if (media.tagName === "IMG") media.alt = name;
837 prepared.append(media);
838 } else if (/^(text\/|application\/(json|javascript|xml|x-(javascript|sh|python|ruby))|image\/svg\+xml$)/.test(type || "")) {
839 const preview = element("pre", new TextDecoder().decode(new Uint8Array(data).subarray(0, 256 * 1024)));
840 prepared.append(preview);
841 if (data.byteLength > 256 * 1024) prepared.append(element("p", "Preview shows the first 256 KiB. Download the file to read it all."));
842 }
843 prepared.append(link);
844 this.append(prepared);
845 }
846 }
847 customElements.define("file-upload", FileUpload);
848 customElements.define("file-workspace-root", Root);
849})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs