clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / tinyclient / file-workspace.js
file-workspace.js 39.1K
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 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 };160161 // FileUpload is the one upload control. Plain uploads store each chosen162 // file as it is. Encrypt keeps the contents and key in the browser: a single163 // file gets a fresh random key, a folder or a large file becomes encrypted164 // 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 the167 // 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 with214 // 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: bytes235 })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.byteLength246 });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 and273 // 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 else317 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 from328 // 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.type343 })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 relative363 // 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 folder370 ? 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 true470 );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 ciphertext520 // 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 fragment555 });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.signal574 });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 }647648 async function fetchBlob(hash, signal) {649 fromHex(hash);650 const response = await fetch(tiny.localPath("/files/raw?hash=" + hash), {651 signal652 });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 }686687 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));694695 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 already712 // 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 * 1024760 });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.n773 });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_BYTES811 });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})();