- commit
- c49b9072ae7ffef6d956cecd1ead029556b304a9
- parent
- 3d07a13fd864cf8e20b351fc0e0de3253b2028df
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 16 | +- |
| M | internal/ | 1 | - |
| M | internal/ | 135 | +++------------------ |
| M | internal/ | 193 | +++++++++++++++++++++++------- |
| M | internal/ | 2 | +- |
| M | internal/ | 21 | ++- |
| M | internal/ | 13 | ++- |
| M | scripts/ | 123 | ++++++++++++------- |
@@ -2,23 +2,25 @@
22 33 Use **Files** to search your uploads, import a public HTTPS URL or Blossom URI, inspect a file, and download it. Owners and moderators can browse the tenant's full inventory. Other users see the files they have uploaded or claimed.44 5-## Encrypted files5+## Uploading6+7+The **Upload** panel on the Files page stores files or a folder. Choose the files, check **folder** to select a whole folder, and check **encrypt** to encrypt in the browser before sending. Plain uploads store each file as it is and refresh the listing. **Import from URL** stores a copy of a public HTTPS URL or Blossom URI.68 7-Choose **Encrypt and upload** to encrypt a file in the browser before sending it to the relay. The default uses a fresh AES-256-GCM key and nonce for each upload. The resulting share link keeps the key, nonce, original name and file type in its URL fragment. Keep that link: the relay cannot recover the key. Anyone who has the complete link and permission to download the blob can decrypt it.9+## Encrypted files810 9-The optional **Deduplicated key** mode follows the [BUD-15 proposal](https://github.com/hzrd149/blossom/pull/104). It derives the encryption key from the file's content, allowing identical files to share storage. This also reveals when files are identical and permits guesses about predictable content. Use the default random key for sensitive or predictable files. Both modes verify the ciphertext hash before decrypting; the deduplicated mode also verifies the plaintext hash.11+An encrypted single file gets a fresh AES-256-GCM key and nonce. The resulting share link keeps the key, nonce, original name and file type in its URL fragment. Keep that link: the relay cannot recover the key. Anyone who has the complete link and permission to download the blob can decrypt it.1012 1113 Encrypted files remain subject to the tenant's download policy. Sending an encrypted link does not grant membership in a private tenant. Removing a file or revoking membership cannot recall copies someone has already downloaded.1214 13-Random-key files can be shared through [NIP-17 file messages](https://github.com/nostr-protocol/nips/blob/master/17.md). Connect a signer with NIP-44 support and enter the recipient's public key. Both parties need signed kind 10050 inbox relay lists available to this relay. The browser sends encrypted gift wraps to those inbox relays, including a copy for the sender. It refuses delivery when the required lists are missing.15+Open a stored file to copy its Blossom URI or share link. Random-key files can also be sent through [NIP-17 file messages](https://github.com/nostr-protocol/nips/blob/master/17.md). Connect a signer with NIP-44 support and enter the recipient's public key. Both parties need signed kind 10050 inbox relay lists available to this relay. The browser sends encrypted gift wraps to those inbox relays, including a copy for the sender. It refuses delivery when the required lists are missing.1416 1517 ## Folders and large files1618 17-Under **Files > Folders and large files**, choose a folder or a file to split into chunks. Files, chunks and all parent manifests use deduplicated keys. Names, child keys and file metadata appear only inside encrypted manifests. Keep the complete share link to browse the folder or download individual files.19+An encrypted folder, or an encrypted file larger than 64 MiB, is stored as encrypted manifests. Files, chunks and all parent manifests use deduplicated keys that follow the [BUD-15 proposal](https://github.com/hzrd149/blossom/pull/104): the key derives from the content, so identical files share storage. This also reveals when files are identical and permits guesses about predictable content. Names, child keys and file metadata appear only inside encrypted manifests. Keep the complete share link to browse the folder or download individual files.1820 1921 Folders use [draft BUD-16](https://github.com/hzrd149/blossom/pull/105). Large files use [draft BUD-17](https://github.com/hzrd149/blossom/pull/106), with 2 MiB plaintext chunks and up to 174 links per manifest. Larger directories use nested manifests that appear as one directory in the browser. Every retrieved object is checked against its hash before decryption, and file downloads verify their declared sizes.2022 21-The browser workspace accepts up to 256 MiB per file, 1 GiB per folder, 10,000 files and 32 path levels. Browser folder selection includes files and their paths; empty folders are omitted. These browser limits apply in addition to the tenant's storage allowances.23+The browser accepts up to 256 MiB per file, 1 GiB per folder, 10,000 files and 32 path levels. Browser folder selection includes files and their paths; empty folders are omitted. These browser limits apply in addition to the tenant's storage allowances.2224 2325 Cancel stops the current upload. Retry reuses the selected files while the page stays open. Completed chunks may remain in the Files inventory after an interrupted folder upload. Removing a root manifest does not remove its child blobs, which may be shared by other folders.2426 @@ -69,7 +71,7 @@ These formats may change before adoption.
6971 | --- | --- | --- |7072 | [BUD-13](https://github.com/hzrd149/blossom/pull/100) | Server | Uploads to `/<sha256>` and imports from remote `url` sources |7173 | [BUD-14](https://github.com/hzrd149/blossom/pull/102) | Server and browser | Resumable uploads with multipart `PATCH` |72-| [BUD-15](https://github.com/hzrd149/blossom/pull/104) | Browser | Optional deduplicated encryption |74+| [BUD-15](https://github.com/hzrd149/blossom/pull/104) | Browser | Deduplicated encryption for folders and large files |7375 | [BUD-16](https://github.com/hzrd149/blossom/pull/105) | Browser | Encrypted folder manifests and browsing |7476 | [BUD-17](https://github.com/hzrd149/blossom/pull/106) | Browser | Chunked files and nested directory manifests |7577 | [BUD-18](https://github.com/hzrd149/blossom/pull/107) | Not yet | Addressing files within immutable or named trees |@@ -15,7 +15,6 @@
1515 {{define "files"}}<section>1616 <h1>Files</h1>1717 <form method="get" action="/files" id="file-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="hash, type or uploader" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/files">clear</a>{{end}}</form>18-{{if .Actor}}<div id="file-actions"><h2>Store a file</h2><signed-form action="/upload" method="PUT"><label>File <input type="file" name="file" required></label><button>Upload</button></signed-form><file-mirror action="/mirror"></file-mirror><file-tools></file-tools><file-workspace></file-workspace><p><small>Encrypted uploads keep the file contents and key in your browser. The relay stores only encrypted bytes. Keep the complete share link to decrypt the file later.</small></p><noscript><p>Connect a signer and enable JavaScript to authorize uploads. Folder manifests require JavaScript.</p></noscript></div>{{end}}1918 {{if .Error}}{{template "alert" .Error}}2019 {{else if .Feed}}<table id="rows">2120 <thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead>@@ -20,13 +20,13 @@
2020 // Shortened key with the full hex as title; click copies it.2121 // <file-mirror action="/mirror">2222 // Signed BUD-04 mirror of a URL or Blossom URI into this relay.23-// <file-tools [hash="…"] [type="…"]>24-// Copies Blossom links, encrypts and uploads a file, decrypts a share25-// link fragment and sends a random-key file as a NIP-17 message.23+// <file-tools hash="…" type="…" size="…">24+// Shares one stored file: copies its Blossom URI or share link, decrypts25+// a share link fragment and sends a random-key file as a NIP-17 message.2626 // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>2727 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and2828 // posts it to the relay.29-// <file-workspace> and <file-workspace-root> live in file-workspace.js;29+// <file-upload> and <file-workspace-root> live in file-workspace.js;3030 // <private-services> lives in private-services.js.3131 (() => {3232 "use strict";@@ -202,54 +202,45 @@
202202 }203203 }204204 205- const b64url = bytes => {206- let binary = "";207- bytes.forEach(byte => { binary += String.fromCharCode(byte); });208- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");209- };210- const fromB64url = value => {211- const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);212- return Uint8Array.from(atob(text), char => char.charCodeAt(0));213- };205+ const {fromB64url} = tiny.util;214206 207+ // FileTools shares one stored file: copy its Blossom URI or share link,208+ // send a random-key encrypted file as a NIP-17 message and decrypt a share209+ // link whose key travels in the URL fragment.215210 class FileTools extends HTMLElement {216211 disconnectedCallback() {217212 if (this.downloadURL) URL.revokeObjectURL(this.downloadURL);218- this.uploadTask?.cancel?.();219- this.uploadController?.abort();220- this.uploadCanceled = true;221213 }222214 223215 connectedCallback() {224216 if (this.bound) return;225217 this.bound = true;226- this.innerHTML = '<h3>File tools</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button></p><form><label>Encrypt a file <input type="file" name="file"></label><label>Encryption <select name="scheme"><option value="random">Random key</option><option value="chk">Deduplicated key</option></select></label><button>Encrypt and upload</button></form><p><button type="button" data-upload-cancel disabled>Cancel upload</button> <button type="button" data-upload-retry disabled>Retry upload</button></p><p><small>Random keys keep identical files distinct. Deduplication reveals matching files and allows guesses about predictable content.</small></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><label>Share link <input data-share-url readonly></label><output role="status"></output>';218+ this.innerHTML = '<h3>Share</h3><p><button type="button" data-copy>Copy Blossom URI</button> <button type="button" data-link>Copy share link</button></p><form data-share><label>Send to <input name="recipient" inputmode="text" placeholder="npub or hex pubkey"></label><button>Send privately</button></form><output role="status"></output>';227219 this.output = this.querySelector("output");228220 this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI()));229221 this.querySelector("[data-link]").addEventListener("click", () => this.copy(this.shareLink()));230- this.querySelector("form:not([data-share])").addEventListener("submit", event => { event.preventDefault(); this.encryptUpload(event.currentTarget); });231- this.querySelector("[data-upload-cancel]").addEventListener("click", () => { this.uploadCanceled = true; this.uploadTask?.cancel?.(); this.uploadController?.abort(); });232- this.querySelector("[data-upload-retry]").addEventListener("click", () => this.retryUpload());233222 this.querySelector("[data-share]").addEventListener("submit", event => { event.preventDefault(); this.share(event.currentTarget); });223+ // Private delivery needs the random key from the share link fragment.224+ const params = new URLSearchParams(location.hash.slice(1));225+ this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv"));234226 this.updateFileControls();235227 this.decryptFromFragment();236228 }237229 238- hash() { return this.fileHash || this.getAttribute("hash") || ""; }230+ hash() { return this.getAttribute("hash") || ""; }239231 type() { return this.getAttribute("type") || "application/octet-stream"; }240232 updateFileControls() {241233 const available = Boolean(this.hash());242234 this.querySelectorAll("[data-copy], [data-link], [data-share] button").forEach(button => { button.disabled = !available; });243235 }244236 blossomURI() {245- const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));237+ const params = new URLSearchParams(location.hash.slice(1));246238 const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin";247239 const uri = (params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key") ? tiny.blossom.encryption.createURI(this.hash(), extension, params.get("key")) : "blossom:" + this.hash();248240 return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host);249241 }250242 shareLink() {251- if (this.fileShareLink) return this.fileShareLink;252- const fragment = this.fileFragment || location.hash.slice(1);243+ const fragment = location.hash.slice(1);253244 const params = new URLSearchParams(fragment);254245 if ((params.get("key") && params.get("iv")) || ((params.get("enc") === "chk-v1" || params.has("manifest")) && params.get("key"))) return location.href.split("#", 1)[0] + "#" + fragment;255246 return location.href.split("#", 1)[0] + "#blossom=" + encodeURIComponent(this.hash());@@ -260,100 +251,8 @@
260251 }261252 say(value, error) { this.output.textContent = value; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; }262253 263- uploadBusy(value) {264- this.busy = value;265- this.querySelector("[data-upload-cancel]").disabled = !value;266- this.querySelector("[data-upload-retry]").disabled = value || !this.pendingUpload;267- }268-269- async encryptUpload(form) {270- if (this.busy) return;271- const file = form.elements.namedItem("file").files[0];272- if (!file) { this.say("Choose a file first.", true); return; }273- this.uploadBusy(true);274- this.uploadCanceled = false;275- try {276- if (file.size > 256 * 1024 * 1024) throw Error("Encrypted browser uploads are limited to 256 MiB.");277- this.say("Encrypting and signing…");278- const scheme = form.elements.namedItem("scheme").value;279- const plaintext = new Uint8Array(await file.arrayBuffer());280- const plaintextHash = await tiny.sha256hex(plaintext);281- let ciphertext, fragment;282- if (scheme === "chk") {283- if (!tiny.blossom.encryption) throw Error("CHK encryption is unavailable. Reload this page.");284- const encrypted = await tiny.blossom.encryption.encryptCHK(plaintext);285- ciphertext = encrypted.ciphertext;286- fragment = "enc=chk-v1&key=" + encrypted.key;287- } else {288- const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]);289- const iv = crypto.getRandomValues(new Uint8Array(12));290- ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext));291- const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));292- fragment = "key=" + b64url(rawKey) + "&iv=" + b64url(iv);293- }294- fragment += "&name=" + encodeURIComponent(file.name) + "&type=" + encodeURIComponent(file.type || "application/octet-stream") + "&ox=" + plaintextHash;295- this.pendingUpload = {ciphertext, fragment, file, state: new Map()};296- if (this.uploadCanceled) throw Error("Upload canceled.");297- await this.storePreparedUpload();298- } catch (error) { this.say("Upload stopped: " + error.message + (this.pendingUpload ? " Retry while this page stays open." : ""), true); }299- finally { this.uploadBusy(false); }300- }301-302- async retryUpload() {303- if (this.busy || !this.pendingUpload) return;304- this.uploadBusy(true);305- this.uploadCanceled = false;306- try { await this.storePreparedUpload(); }307- catch (error) { this.say("Upload stopped: " + error.message + " Retry while this page stays open.", true); }308- finally { this.uploadBusy(false); }309- }310-311- async storePreparedUpload() {312- const {ciphertext, fragment, file, state} = this.pendingUpload;313- const actualHash = await tiny.sha256hex(ciphertext);314- this.uploadController = new AbortController();315- let descriptor;316- if (tiny.blossom.upload?.upload) {317- const task = tiny.blossom.upload.upload(ciphertext, {318- url: new URL(tiny.localPath("/"), location.href).href, hash: actualHash,319- type: "application/octet-stream", state, signal: this.uploadController.signal,320- authorize: (url, verb, body) => tiny.authorization(url, verb, body),321- onProgress: progress => this.say("Uploading " + Math.round(progress.fraction * 100) + "%…")322- });323- this.uploadTask = task;324- descriptor = (await task).descriptor;325- } else {326- const response = await tiny.signedFetch("/upload", "PUT", ciphertext, {contentType: "application/octet-stream", signal: this.uploadController.signal});327- descriptor = await response.json();328- }329- if (descriptor.sha256 !== actualHash) throw Error("relay returned an unexpected hash");330- this.fileHash = actualHash;331- this.fileFragment = fragment;332- this.setAttribute("hash", actualHash);333- this.setAttribute("type", file.type || "application/octet-stream");334- this.setAttribute("size", String(ciphertext.length));335- this.updateFileControls();336- const link = new URL(location.href);337- link.pathname = location.pathname.replace(/\/files?$/, "/file");338- link.search = "?hash=" + actualHash;339- link.hash = fragment;340- this.fileShareLink = link.href;341- const share = this.querySelector("[data-share-url]");342- if (share) share.value = link.href;343- this.pendingUpload = null;344- this.uploadTask = null;345- try {346- if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable");347- await navigator.clipboard.writeText(link.href);348- this.say("Encrypted file stored. Share link copied; the key is only in its fragment.");349- } catch {350- if (share) share.select?.();351- this.say("Encrypted file stored. Copy the share link above; the key is only in its fragment.");352- }353- }354-355254 async decryptFromFragment() {356- const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));255+ const params = new URLSearchParams(location.hash.slice(1));357256 if ((!params.get("key") || !params.get("iv")) && !(params.get("enc") === "chk-v1" && params.get("key"))) return;358257 try {359258 this.say("Decrypting…");@@ -377,7 +276,7 @@
377276 if (this.sharing) return;378277 const recipient = form.elements.namedItem("recipient").value.trim();379278 if (!recipient) { this.say("Enter a public key or npub.", true); return; }380- const params = new URLSearchParams(this.fileFragment || location.hash.slice(1));279+ const params = new URLSearchParams(location.hash.slice(1));381280 const signer = tiny.signer();382281 if (!tiny.files.messages || !signer) { this.say("Connect a NIP-44 signer first. Use the encrypted link, which keeps its key in the URL fragment.", true); return; }383282 if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; }@@ -140,22 +140,31 @@
140140 return url.href;141141 };142142 143- class Workspace extends HTMLElement {143+ // FileUpload is the one upload control. Plain uploads store each chosen144+ // file as it is. Encrypt keeps the contents and key in the browser: a single145+ // file gets a fresh random key, a folder or a large file becomes encrypted146+ // manifests, and the share link carries the key only in its fragment.147+ const LARGE_FILE_BYTES = 64 * 1024 * 1024;148+ const {b64url} = globalThis.tiny.util;149+ class FileUpload extends HTMLElement {144150 connectedCallback() {145151 if (this.bound) return;146152 this.bound = true;147153 this.innerHTML =148- '<h3>Folders and large files</h3><p>Encrypt folders and split large files into portable chunks. Deduplicated encryption reveals matching content and permits guesses about predictable files.</p><form data-folder><label>Folder <input type="file" name="folder" webkitdirectory directory multiple required></label><button>Encrypt and store folder</button></form><form data-chunked><label>File to split into chunks <input type="file" name="file" required></label><button>Encrypt and store file</button></form><p><button type="button" data-cancel disabled>Cancel upload</button> <button type="button" data-retry disabled>Retry upload</button></p><label>Share link <input data-tree-share readonly></label><p><a data-open hidden>Browse stored content</a></p><output role="status"></output>';154+ '<form><label>File or folder <input type="file" name="file" multiple required></label><label><input type="checkbox" name="folder"> folder</label><label><input type="checkbox" name="encrypt"> encrypt</label><button>Upload</button></form><p data-controls hidden><button type="button" data-cancel disabled>Cancel</button> <button type="button" data-retry disabled>Retry</button></p><label data-share hidden>Share link <input data-share-url readonly></label><output role="status"></output>';149155 this.out = this.querySelector("output");150- for (const form of this.querySelectorAll("form"))151- form.addEventListener("submit", event => {152- event.preventDefault();153- this.run(form).catch(error => this.say(error.message, true));154- });156+ const form = this.querySelector("form");157+ const input = form.elements.namedItem("file");158+ form.elements.namedItem("folder").addEventListener("change", event => {159+ if (event.currentTarget.checked) input.setAttribute("webkitdirectory", "");160+ else input.removeAttribute("webkitdirectory");161+ });162+ form.addEventListener("submit", event => {163+ event.preventDefault();164+ this.run(form).catch(() => {});165+ });155166 this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort());156- this.querySelector("[data-retry]").addEventListener("click", () =>157- this.run().catch(error => this.say(error.message, true))158- );167+ this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {}));159168 }160169 disconnectedCallback() {161170 this.controller?.abort();@@ -165,53 +174,153 @@
165174 if (error) this.out.dataset.error = "";166175 else delete this.out.dataset.error;167176 }177+ controls(busy, completed) {178+ const retryable = Boolean(this.selection) && !completed;179+ this.querySelector("[data-controls]").hidden = !busy && !retryable;180+ this.querySelector("[data-cancel]").disabled = !busy;181+ this.querySelector("[data-retry]").disabled = busy || !retryable;182+ }168183 async run(form) {169184 if (this.busy) return;170185 if (form) {171- const folder = form.elements.namedItem("folder");172- const input = folder || form.elements.namedItem("file");173- this.selection = {files: [...input.files], folder: Boolean(folder)};186+ const files = [...form.elements.namedItem("file").files];187+ this.selection = {188+ files,189+ folder: Boolean(form.elements.namedItem("folder").checked),190+ encrypt: Boolean(form.elements.namedItem("encrypt").checked)191+ };192+ this.pending = null;174193 }175194 if (!this.selection?.files.length) throw Error("Choose a file or folder first.");176195 this.busy = true;177196 this.controller = new AbortController();178- this.querySelector("[data-cancel]").disabled = false;179- this.querySelector("[data-retry]").disabled = true;197+ this.controls(true, false);180198 let completed = false;181199 try {182- let reference, name, type;183- let sent = 0;184- const progress = size => {185- sent += size;186- this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…");187- };188- this.say("Encrypting selected content…");189- if (this.selection.folder) {190- const selected = selectionTree(this.selection.files);191- name = selected.name;192- reference = await storeDirectory(selected.root, this.controller.signal, progress);200+ const {files, folder, encrypt} = this.selection;201+ let link;202+ if (!encrypt) await this.storePlain(files);203+ else if (folder || files.length > 1) link = await this.storeManifest(files, true);204+ else if (files[0].size > LARGE_FILE_BYTES) link = await this.storeManifest(files, false);205+ else link = await this.storeSealed(files[0]);206+ completed = true;207+ if (link) {208+ await this.showLink(link);193209 } else {194- const file = this.selection.files[0];195- name = file.name;196- type = file.type;197- reference = await storeFile(file, this.controller.signal, progress);210+ this.say("Stored " + files.length + (files.length === 1 ? " file." : " files."));211+ await tiny.navigate?.(location.href);198212 }199- checkAbort(this.controller.signal);200- const url = shareURL(reference, name, type);201- this.querySelector("[data-tree-share]").value = url;202- const open = this.querySelector("[data-open]");203- open.href = url;204- open.hidden = false;205- this.say("Stored. Keep the complete share link to decrypt and browse this content.");206- completed = true;207- return url;213+ return link;214+ } catch (error) {215+ const canceled = this.controller.signal.aborted;216+ this.say(217+ (canceled ? "Upload canceled." : "Upload stopped: " + error.message) + " Retry while this page stays open.",218+ true219+ );220+ throw error;208221 } finally {209222 this.busy = false;210- this.querySelector("[data-cancel]").disabled = true;211- this.querySelector("[data-retry]").disabled = completed;223+ this.controls(false, completed);212224 if (completed) this.selection = null;213225 }214226 }227+ async storePlain(files) {228+ for (const [index, file] of files.entries()) {229+ checkAbort(this.controller.signal);230+ this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…");231+ await tiny.signedFetch("/upload", "PUT", await file.arrayBuffer(), {232+ contentType: file.type || "application/octet-stream",233+ signal: this.controller.signal234+ });235+ }236+ }237+ // storeSealed encrypts one file with a fresh AES-GCM key. The ciphertext238+ // and key stay in memory so a retry resumes without re-encrypting.239+ async storeSealed(file) {240+ if (!this.pending) {241+ if (file.size > MAX_FILE_BYTES) throw Error("Encrypted browser uploads are limited to 256 MiB.");242+ this.say("Encrypting…");243+ const plaintext = new Uint8Array(await file.arrayBuffer());244+ const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]);245+ const iv = crypto.getRandomValues(new Uint8Array(12));246+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext));247+ const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));248+ const fragment =249+ "key=" +250+ b64url(rawKey) +251+ "&iv=" +252+ b64url(iv) +253+ "&name=" +254+ encodeURIComponent(file.name) +255+ "&type=" +256+ encodeURIComponent(file.type || "application/octet-stream") +257+ "&ox=" +258+ (await tiny.sha256hex(plaintext));259+ this.pending = {ciphertext, fragment, state: new Map()};260+ }261+ checkAbort(this.controller.signal);262+ const {ciphertext, fragment, state} = this.pending;263+ const hash = await tiny.sha256hex(ciphertext);264+ let descriptor;265+ if (upload()?.upload) {266+ const task = upload().upload(ciphertext, {267+ url: new URL(tiny.localPath("/"), location.href).href,268+ hash,269+ type: "application/octet-stream",270+ state,271+ signal: this.controller.signal,272+ authorize: (target, method, body) => tiny.authorization(target, method, body),273+ onProgress: progress => this.say("Uploading " + Math.round(progress.fraction * 100) + "%…")274+ });275+ descriptor = (await task).descriptor;276+ } else {277+ const response = await tiny.signedFetch("/upload", "PUT", ciphertext, {278+ contentType: "application/octet-stream",279+ signal: this.controller.signal280+ });281+ descriptor = await response.json();282+ }283+ if (descriptor.sha256 !== hash) throw Error("Relay returned an unexpected hash.");284+ this.pending = null;285+ const link = new URL(tiny.localPath("/file"), location.href);286+ link.search = "?hash=" + hash;287+ link.hash = fragment;288+ return link.href;289+ }290+ async storeManifest(files, folder) {291+ let sent = 0;292+ const progress = size => {293+ sent += size;294+ this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…");295+ };296+ this.say("Encrypting selected content…");297+ let reference, name, type;298+ if (folder) {299+ const selected = selectionTree(files);300+ name = selected.name || "files";301+ reference = await storeDirectory(selected.root, this.controller.signal, progress);302+ } else {303+ const file = files[0];304+ name = file.name;305+ type = file.type;306+ reference = await storeFile(file, this.controller.signal, progress);307+ }308+ checkAbort(this.controller.signal);309+ return shareURL(reference, name, type);310+ }311+ async showLink(link) {312+ const share = this.querySelector("[data-share-url]");313+ share.value = link;314+ this.querySelector("[data-share]").hidden = false;315+ try {316+ if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable");317+ await navigator.clipboard.writeText(link);318+ this.say("Stored. Share link copied; the key is only in its fragment.");319+ } catch {320+ share.select?.();321+ this.say("Stored. Copy the share link above; the key is only in its fragment.");322+ }323+ }215324 }216325 217326 async function fetchBlob(hash, signal) {@@ -379,6 +488,6 @@
379488 link.click();380489 }381490 }382- customElements.define("file-workspace", Workspace);491+ customElements.define("file-upload", FileUpload);383492 customElements.define("file-workspace-root", Root);384493 })();@@ -105,7 +105,7 @@
105105 <section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}106106 107107 {{define "panel-files"}}<section><h4>Storage</h4><table><tr><th>protocol</th><td>Blossom</td></tr>{{if .Policy.Features.Files}}<tr><th>uploads</th><td>members</td></tr>{{end}}</table></section>108-{{if .Actor}}<section><h4>Upload</h4><signed-form action="/upload" method="PUT"><label>file<input type="file" name="file" required></label><button>upload</button></signed-form></section>{{end}}{{end}}108+{{if .Actor}}<section><h4>Upload</h4><file-upload></file-upload><p><small>Encrypt keeps the contents and key in your browser. Keep the complete share link.</small></p><details><summary>Import from URL</summary><file-mirror action="/mirror"></file-mirror></details><noscript><p>Uploads need JavaScript and a connected signer.</p></noscript></section>{{end}}{{end}}109109 110110 {{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section>111111 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
2020 img{max-width:100%}2121 2222 /* Selection: chrome is not selectable, content and values are. */23-#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-workspace label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none}23+#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none}2424 2525 /* Shell: a table with three columns; each column scrolls on its own. */2626 #topbar{display:none}@@ -107,15 +107,18 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
107107 #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px}108108 109109 /* rpc-form, signed-form, publish-list: one signed call each. */110-rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-workspace,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}111-nostr-compose,file-tools,file-workspace,file-workspace-root{max-width:720px}112-file-tools h3,file-workspace h3{margin:0 0 4px}file-tools p,file-workspace p{margin:6px 0}113-rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-workspace label,#collaboration-filter label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}114-rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-workspace label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}115-file-tools label:has([data-share-url]),file-workspace label:has([data-tree-share]){display:block}file-tools input[data-share-url],file-workspace input[data-tree-share]{width:100%}110+rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-upload,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}111+nostr-compose,file-tools,file-upload,file-workspace-root{max-width:720px}112+file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6px 0}113+rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}114+rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}115+file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}116+file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}117+#panel file-upload [hidden],file-upload [hidden],file-tools [hidden]{display:none}118+#panel file-upload,#panel file-mirror{border:0;padding:0;max-width:none}#panel details{margin-top:8px}#panel details>summary{cursor:pointer;color:var(--muted);font-size:12px}116119 rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block}117-rpc-form output,signed-form output,publish-list output,nostr-compose output,file-tools output,file-workspace output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}118-rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)}120+rpc-form output,signed-form output,publish-list output,nostr-compose output,file-tools output,file-upload output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}121+rpc-form output[data-error],file-tools output[data-error],file-upload output[data-error]{color:var(--alert)}119122 file-workspace-root ul{list-style:none;margin:8px 0 0;padding:0 0 0 16px;border-left:1px solid var(--line)}file-workspace-root li{padding:3px 0}120123 file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left}121124 #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px}@@ -45,7 +45,18 @@
4545 if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");4646 return url.toString().replace(/\/$/, "");4747 };48- tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL});48+ const b64url = value => {49+ let binary = "";50+ bytes(value).forEach(byte => {51+ binary += String.fromCharCode(byte);52+ });53+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");54+ };55+ const fromB64url = value => {56+ const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);57+ return Uint8Array.from(atob(text), char => char.charCodeAt(0));58+ };59+ tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url});4960 // signer returns the active signer: a resumed remote signer first, then a5061 // NIP-07 extension.5162 tiny.signer = () => globalThis.tinySigner || globalThis.nostr;@@ -59,7 +59,7 @@ const load = (workspace = false) => {
5959 vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8"));6060 if (workspace) { vm.runInThisContext(fs.readFileSync("internal/webui/blossom-manifests.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-encryption.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/blossom-upload.js", "utf8")); vm.runInThisContext(fs.readFileSync("internal/webui/file-workspace.js", "utf8")); } else { delete globalThis.tiny.blossom.upload; }6161 vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8"));62- return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"]};62+ return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"], FileUpload: customElements.registry["file-upload"]};6363 };6464 6565 const workspaceServer = t => {@@ -104,14 +104,31 @@ const workspaceServer = t => {
104104 return root;105105 };106106 const store = async files => {107- const workspace = new customElements.registry["file-workspace"]();108- workspace.connectedCallback();109- const form = workspace.querySelector("[data-folder]");110- form.elements.namedItem("folder").files = files;111- return workspace.run(form);107+ const upload = new customElements.registry["file-upload"]();108+ upload.connectedCallback();109+ const form = upload.querySelector("form");110+ form.elements.namedItem("file").files = files;111+ form.elements.namedItem("folder").checked = true;112+ form.elements.namedItem("encrypt").checked = true;113+ return upload.run(form);112114 };113115 return {...loaded, blobs, requests, store, open, originalFetch};114116 };117+// sealedUpload stores one file through the upload control with encrypt checked118+// and the plain signed PUT path, returning the share link and ciphertext.119+const sealedUpload = async (window, FileUpload, file) => {120+ let uploaded;121+ delete window.tiny.blossom.upload;122+ window.tiny.localPath = path => path;123+ window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");124+ window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };125+ const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();126+ const form = upload.querySelector("form");127+ form.elements.namedItem("file").files = [file];128+ form.elements.namedItem("encrypt").checked = true;129+ const link = await upload.run(form);130+ return {upload, link, uploaded: () => uploaded};131+};115132 const folderFile = (contents, path) => {116133 const file = new File([contents], path.split("/").at(-1), {type: "text/plain"});117134 Object.defineProperty(file, "webkitRelativePath", {value: path});@@ -154,16 +171,28 @@ test("folder upload rejects unexpected server hashes", async t => {
154171 });155172 156173 test("encrypted upload keeps ciphertext and link when clipboard fails", async () => {157- const {document, window, FileTools} = load(); const requests = [];158- window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");159- window.tiny.signedFetch = async (path, method, body) => { const bytes = new Uint8Array(body); requests.push(bytes); return {json: async () => ({sha256: await window.tiny.sha256hex(bytes)})}; };160- const tools = new FileTools(); document.append(tools); tools.connectedCallback();161- const form = tools.querySelector("form:not([data-share])");162- form.querySelector("input[type=file]").files = [new File(["secret plaintext"], "secret.txt", {type: "text/plain"})];163- await tools.encryptUpload(form);164- assert.equal(requests.length, 1); assert.notEqual(new TextDecoder().decode(requests[0]), "secret plaintext");165- assert.match(tools.querySelector("[data-share-url]").value, /#key=.*&iv=/); assert.match(tools.output.textContent, /Copy the share link above/);166- assert.equal(tools.shareLink(), tools.querySelector("[data-share-url]").value);174+ const {window, FileUpload} = load(true);175+ const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext"], "secret.txt", {type: "text/plain"}));176+ assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext");177+ assert.match(link, /\/file\?hash=[0-9a-f]{64}#key=.*&iv=/);178+ assert.equal(upload.querySelector("[data-share-url]").value, link);179+ assert.equal(upload.querySelector("[data-share]").hidden, false);180+ assert.match(upload.out.textContent, /Copy the share link above/);181+});182+183+test("plain uploads store every chosen file and refresh the listing", async () => {184+ const {window, FileUpload} = load(true);185+ const sent = [];186+ let refreshed = 0;187+ window.tiny.signedFetch = async (path, method, body, options) => { sent.push({path, method, size: body.byteLength, type: options.contentType}); return {}; };188+ window.tiny.navigate = async () => { refreshed++; };189+ const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback();190+ const form = upload.querySelector("form");191+ form.elements.namedItem("file").files = [new File(["one"], "a.txt", {type: "text/plain"}), new File(["two"], "b.bin")];192+ assert.equal(await upload.run(form), undefined);193+ assert.deepEqual(sent, [{path: "/upload", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload", method: "PUT", size: 3, type: "application/octet-stream"}]);194+ assert.equal(refreshed, 1);195+ assert.equal(upload.out.textContent, "Stored 2 files.");167196 });168197 169198 test("BUD15 key is rejected before mirror request", async () => {@@ -175,15 +204,14 @@ test("BUD15 key is rejected before mirror request", async () => {
175204 });176205 177206 test("sharing a newly uploaded file uses its current key, hash and ciphertext size", async () => {178- const {document, window, FileTools} = load();179- window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");180- window.tiny.localPath = path => path;181- let uploaded;182- window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };183- const element = new FileTools(); document.append(element); element.connectedCallback();184- const form = element.querySelector("form:not([data-share])");185- form.querySelector("input[type=file]").files = [new File(["new secret"], "private.txt", {type: "text/plain"})];186- await element.encryptUpload(form);207+ const {document, window, FileTools, FileUpload} = load(true);208+ const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["new secret"], "private.txt", {type: "text/plain"}));209+ window.location.hash = new URL(link).hash;210+ const element = new FileTools();211+ element.setAttribute("hash", await window.tiny.sha256hex(uploaded()));212+ element.setAttribute("type", "text/plain");213+ element.setAttribute("size", String(uploaded().length));214+ document.append(element); element.connectedCallback();187215 const shareForm = element.querySelector("[data-share]");188216 shareForm.querySelector("input").value = "b".repeat(64);189217 globalThis.nostr = {signEvent: async value => value};@@ -191,13 +219,13 @@ test("sharing a newly uploaded file uses its current key, hash and ciphertext si
191219 globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }};192220 await element.share(shareForm);193221 assert.ok(input, element.output.textContent);194- assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded));195- assert.equal(input.size, uploaded.length);222+ assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded()));223+ assert.equal(input.size, uploaded().length);196224 assert.equal(input.plaintextHash, await window.tiny.sha256hex(new TextEncoder().encode("new secret")));197225 assert.match(input.key, /^[0-9a-f]{64}$/);198226 assert.match(input.nonce, /^[0-9a-f]{24}$/);199227 const key = await webcrypto.subtle.importKey("raw", Buffer.from(input.key, "hex"), "AES-GCM", false, ["decrypt"]);200- const plain = await webcrypto.subtle.decrypt({name: "AES-GCM", iv: Buffer.from(input.nonce, "hex")}, key, uploaded);228+ const plain = await webcrypto.subtle.decrypt({name: "AES-GCM", iv: Buffer.from(input.nonce, "hex")}, key, uploaded());201229 assert.equal(new TextDecoder().decode(plain), "new secret");202230 assert.equal(new URL(input.fileURL).hash, "");203231 });@@ -213,32 +241,30 @@ test("HTTP mirror input never submits its secret fragment", async () => {
213241 });214242 215243 test("local downloads verify integrity, decrypt and release their temporary URL", async () => {216- const {document, window, FileTools} = load();217- window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");218- window.tiny.localPath = path => path;219- let uploaded;220- window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; };221- const element = new FileTools(); document.append(element); element.connectedCallback();222- const form = element.querySelector("form:not([data-share])");223- form.querySelector("input[type=file]").files = [new File(["round trip"], "round.txt", {type: "text/plain"})];224- await element.encryptUpload(form);244+ const {document, window, FileTools, FileUpload} = load(true);245+ const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["round trip"], "round.txt", {type: "text/plain"}));246+ window.location.hash = new URL(link).hash;247+ const element = new FileTools();248+ element.setAttribute("hash", await window.tiny.sha256hex(uploaded()));249+ document.append(element);225250 const originalFetch = globalThis.fetch;226251 try {227- globalThis.fetch = async () => new Response(uploaded);252+ globalThis.fetch = async () => new Response(uploaded());253+ element.connectedCallback();228254 await element.decryptFromFragment();229255 assert.equal(element.output.textContent, "Decrypted locally.");230256 assert.equal(await (await originalFetch(element.downloadURL)).text(), "round trip");231257 const temporaryURL = element.downloadURL;232258 element.disconnectedCallback();233259 await assert.rejects(originalFetch(temporaryURL));234- uploaded[0] ^= 1;260+ uploaded()[0] ^= 1;235261 await element.decryptFromFragment();236262 assert.match(element.output.textContent, /ciphertext hash mismatch/);237263 } finally { globalThis.fetch = originalFetch; }238264 });239265 240266 test("retry keeps the original ciphertext and key after interrupted encrypted upload", async () => {241- const {document, window, FileTools} = load();267+ const {document, window, FileUpload} = load(true);242268 window.tiny.localPath = path => path;243269 window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex");244270 const sent = [];@@ -248,14 +274,15 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up
248274 if (sent.length === 1) throw Error("connection interrupted");249275 return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}};250276 }};251- const fileTools = new FileTools(); document.append(fileTools); fileTools.connectedCallback();252- const form = fileTools.querySelector("form:not([data-share])");277+ const upload = new FileUpload(); document.append(upload); upload.connectedCallback();278+ const form = upload.querySelector("form");253279 form.elements.namedItem("file").files = [new File(["keep the same key"], "private.txt")];254- await fileTools.encryptUpload(form);255- const originalFragment = fileTools.pendingUpload.fragment;256- assert.equal(fileTools.querySelector("[data-upload-retry]").disabled, false);257- await fileTools.retryUpload();280+ form.elements.namedItem("encrypt").checked = true;281+ await assert.rejects(upload.run(form), /connection interrupted/);282+ const originalFragment = upload.pending.fragment;283+ assert.equal(upload.querySelector("[data-retry]").disabled, false);284+ const link = await upload.run();258285 assert.deepEqual(sent[1], sent[0]);259- assert.equal(fileTools.fileFragment, originalFragment);260- assert.equal(fileTools.pendingUpload, null);286+ assert.equal(new URL(link).hash.slice(1), originalFragment);287+ assert.equal(upload.pending, null);261288 });