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