| 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 | })(); |