| 1 | diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md |
| 2 | index 19b2066..0ac5321 100644 |
| 3 | --- a/docs/files-and-private-repositories.md |
| 4 | +++ b/docs/files-and-private-repositories.md |
| 5 | @@ -2,23 +2,25 @@ |
| 6 | |
| 7 | 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. |
| 8 | |
| 9 | -## Encrypted files |
| 10 | +## Uploading |
| 11 | + |
| 12 | +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. |
| 13 | |
| 14 | -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. |
| 15 | +## Encrypted files |
| 16 | |
| 17 | -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. |
| 18 | +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. |
| 19 | |
| 20 | 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. |
| 21 | |
| 22 | -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. |
| 23 | +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. |
| 24 | |
| 25 | ## Folders and large files |
| 26 | |
| 27 | -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. |
| 28 | +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. |
| 29 | |
| 30 | 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. |
| 31 | |
| 32 | -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. |
| 33 | +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. |
| 34 | |
| 35 | 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. |
| 36 | |
| 37 | @@ -69,7 +71,7 @@ These formats may change before adoption. |
| 38 | | --- | --- | --- | |
| 39 | | [BUD-13](https://github.com/hzrd149/blossom/pull/100) | Server | Uploads to `/<sha256>` and imports from remote `url` sources | |
| 40 | | [BUD-14](https://github.com/hzrd149/blossom/pull/102) | Server and browser | Resumable uploads with multipart `PATCH` | |
| 41 | -| [BUD-15](https://github.com/hzrd149/blossom/pull/104) | Browser | Optional deduplicated encryption | |
| 42 | +| [BUD-15](https://github.com/hzrd149/blossom/pull/104) | Browser | Deduplicated encryption for folders and large files | |
| 43 | | [BUD-16](https://github.com/hzrd149/blossom/pull/105) | Browser | Encrypted folder manifests and browsing | |
| 44 | | [BUD-17](https://github.com/hzrd149/blossom/pull/106) | Browser | Chunked files and nested directory manifests | |
| 45 | | [BUD-18](https://github.com/hzrd149/blossom/pull/107) | Not yet | Addressing files within immutable or named trees | |
| 46 | diff --git a/internal/webui/browse.html b/internal/webui/browse.html |
| 47 | index eaecf22..8dfc3dd 100644 |
| 48 | --- a/internal/webui/browse.html |
| 49 | +++ b/internal/webui/browse.html |
| 50 | @@ -15,7 +15,6 @@ |
| 51 | {{define "files"}}<section> |
| 52 | <h1>Files</h1> |
| 53 | <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> |
| 54 | -{{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}} |
| 55 | {{if .Error}}{{template "alert" .Error}} |
| 56 | {{else if .Feed}}<table id="rows"> |
| 57 | <thead><tr><th>hash</th><th>type</th><th>uploaded</th></tr></thead> |
| 58 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 59 | index b9e8a0f..a951050 100644 |
| 60 | --- a/internal/webui/components.js |
| 61 | +++ b/internal/webui/components.js |
| 62 | @@ -20,13 +20,13 @@ |
| 63 | // Shortened key with the full hex as title; click copies it. |
| 64 | // <file-mirror action="/mirror"> |
| 65 | // Signed BUD-04 mirror of a URL or Blossom URI into this relay. |
| 66 | -// <file-tools [hash="…"] [type="…"]> |
| 67 | -// Copies Blossom links, encrypts and uploads a file, decrypts a share |
| 68 | -// link fragment and sends a random-key file as a NIP-17 message. |
| 69 | +// <file-tools hash="…" type="…" size="…"> |
| 70 | +// Shares one stored file: copies its Blossom URI or share link, decrypts |
| 71 | +// a share link fragment and sends a random-key file as a NIP-17 message. |
| 72 | // <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]> |
| 73 | // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and |
| 74 | // posts it to the relay. |
| 75 | -// <file-workspace> and <file-workspace-root> live in file-workspace.js; |
| 76 | +// <file-upload> and <file-workspace-root> live in file-workspace.js; |
| 77 | // <private-services> lives in private-services.js. |
| 78 | (() => { |
| 79 | "use strict"; |
| 80 | @@ -202,54 +202,45 @@ |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | - const b64url = bytes => { |
| 85 | - let binary = ""; |
| 86 | - bytes.forEach(byte => { binary += String.fromCharCode(byte); }); |
| 87 | - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 88 | - }; |
| 89 | - const fromB64url = value => { |
| 90 | - const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); |
| 91 | - return Uint8Array.from(atob(text), char => char.charCodeAt(0)); |
| 92 | - }; |
| 93 | + const {fromB64url} = tiny.util; |
| 94 | |
| 95 | + // FileTools shares one stored file: copy its Blossom URI or share link, |
| 96 | + // send a random-key encrypted file as a NIP-17 message and decrypt a share |
| 97 | + // link whose key travels in the URL fragment. |
| 98 | class FileTools extends HTMLElement { |
| 99 | disconnectedCallback() { |
| 100 | if (this.downloadURL) URL.revokeObjectURL(this.downloadURL); |
| 101 | - this.uploadTask?.cancel?.(); |
| 102 | - this.uploadController?.abort(); |
| 103 | - this.uploadCanceled = true; |
| 104 | } |
| 105 | |
| 106 | connectedCallback() { |
| 107 | if (this.bound) return; |
| 108 | this.bound = true; |
| 109 | - 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>'; |
| 110 | + 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>'; |
| 111 | this.output = this.querySelector("output"); |
| 112 | this.querySelector("[data-copy]").addEventListener("click", () => this.copy(this.blossomURI())); |
| 113 | this.querySelector("[data-link]").addEventListener("click", () => this.copy(this.shareLink())); |
| 114 | - this.querySelector("form:not([data-share])").addEventListener("submit", event => { event.preventDefault(); this.encryptUpload(event.currentTarget); }); |
| 115 | - this.querySelector("[data-upload-cancel]").addEventListener("click", () => { this.uploadCanceled = true; this.uploadTask?.cancel?.(); this.uploadController?.abort(); }); |
| 116 | - this.querySelector("[data-upload-retry]").addEventListener("click", () => this.retryUpload()); |
| 117 | this.querySelector("[data-share]").addEventListener("submit", event => { event.preventDefault(); this.share(event.currentTarget); }); |
| 118 | + // Private delivery needs the random key from the share link fragment. |
| 119 | + const params = new URLSearchParams(location.hash.slice(1)); |
| 120 | + this.querySelector("[data-share]").hidden = !(params.get("key") && params.get("iv")); |
| 121 | this.updateFileControls(); |
| 122 | this.decryptFromFragment(); |
| 123 | } |
| 124 | |
| 125 | - hash() { return this.fileHash || this.getAttribute("hash") || ""; } |
| 126 | + hash() { return this.getAttribute("hash") || ""; } |
| 127 | type() { return this.getAttribute("type") || "application/octet-stream"; } |
| 128 | updateFileControls() { |
| 129 | const available = Boolean(this.hash()); |
| 130 | this.querySelectorAll("[data-copy], [data-link], [data-share] button").forEach(button => { button.disabled = !available; }); |
| 131 | } |
| 132 | blossomURI() { |
| 133 | - const params = new URLSearchParams(this.fileFragment || location.hash.slice(1)); |
| 134 | + const params = new URLSearchParams(location.hash.slice(1)); |
| 135 | const extension = params.has("manifest") ? (["2", "3"].includes(params.get("node") || "2") ? "bdir" : params.get("node") === "1" ? "bfile" : "bin") : "bin"; |
| 136 | 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(); |
| 137 | return tiny.root ? uri : uri + (uri.includes("?") ? "&" : "?") + "xs=" + encodeURIComponent(new URL(location.href).host); |
| 138 | } |
| 139 | shareLink() { |
| 140 | - if (this.fileShareLink) return this.fileShareLink; |
| 141 | - const fragment = this.fileFragment || location.hash.slice(1); |
| 142 | + const fragment = location.hash.slice(1); |
| 143 | const params = new URLSearchParams(fragment); |
| 144 | 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; |
| 145 | return location.href.split("#", 1)[0] + "#blossom=" + encodeURIComponent(this.hash()); |
| 146 | @@ -260,100 +251,8 @@ |
| 147 | } |
| 148 | say(value, error) { this.output.textContent = value; if (error) this.output.dataset.error = ""; else delete this.output.dataset.error; } |
| 149 | |
| 150 | - uploadBusy(value) { |
| 151 | - this.busy = value; |
| 152 | - this.querySelector("[data-upload-cancel]").disabled = !value; |
| 153 | - this.querySelector("[data-upload-retry]").disabled = value || !this.pendingUpload; |
| 154 | - } |
| 155 | - |
| 156 | - async encryptUpload(form) { |
| 157 | - if (this.busy) return; |
| 158 | - const file = form.elements.namedItem("file").files[0]; |
| 159 | - if (!file) { this.say("Choose a file first.", true); return; } |
| 160 | - this.uploadBusy(true); |
| 161 | - this.uploadCanceled = false; |
| 162 | - try { |
| 163 | - if (file.size > 256 * 1024 * 1024) throw Error("Encrypted browser uploads are limited to 256 MiB."); |
| 164 | - this.say("Encrypting and signing…"); |
| 165 | - const scheme = form.elements.namedItem("scheme").value; |
| 166 | - const plaintext = new Uint8Array(await file.arrayBuffer()); |
| 167 | - const plaintextHash = await tiny.sha256hex(plaintext); |
| 168 | - let ciphertext, fragment; |
| 169 | - if (scheme === "chk") { |
| 170 | - if (!tiny.blossom.encryption) throw Error("CHK encryption is unavailable. Reload this page."); |
| 171 | - const encrypted = await tiny.blossom.encryption.encryptCHK(plaintext); |
| 172 | - ciphertext = encrypted.ciphertext; |
| 173 | - fragment = "enc=chk-v1&key=" + encrypted.key; |
| 174 | - } else { |
| 175 | - const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]); |
| 176 | - const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 177 | - ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext)); |
| 178 | - const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); |
| 179 | - fragment = "key=" + b64url(rawKey) + "&iv=" + b64url(iv); |
| 180 | - } |
| 181 | - fragment += "&name=" + encodeURIComponent(file.name) + "&type=" + encodeURIComponent(file.type || "application/octet-stream") + "&ox=" + plaintextHash; |
| 182 | - this.pendingUpload = {ciphertext, fragment, file, state: new Map()}; |
| 183 | - if (this.uploadCanceled) throw Error("Upload canceled."); |
| 184 | - await this.storePreparedUpload(); |
| 185 | - } catch (error) { this.say("Upload stopped: " + error.message + (this.pendingUpload ? " Retry while this page stays open." : ""), true); } |
| 186 | - finally { this.uploadBusy(false); } |
| 187 | - } |
| 188 | - |
| 189 | - async retryUpload() { |
| 190 | - if (this.busy || !this.pendingUpload) return; |
| 191 | - this.uploadBusy(true); |
| 192 | - this.uploadCanceled = false; |
| 193 | - try { await this.storePreparedUpload(); } |
| 194 | - catch (error) { this.say("Upload stopped: " + error.message + " Retry while this page stays open.", true); } |
| 195 | - finally { this.uploadBusy(false); } |
| 196 | - } |
| 197 | - |
| 198 | - async storePreparedUpload() { |
| 199 | - const {ciphertext, fragment, file, state} = this.pendingUpload; |
| 200 | - const actualHash = await tiny.sha256hex(ciphertext); |
| 201 | - this.uploadController = new AbortController(); |
| 202 | - let descriptor; |
| 203 | - if (tiny.blossom.upload?.upload) { |
| 204 | - const task = tiny.blossom.upload.upload(ciphertext, { |
| 205 | - url: new URL(tiny.localPath("/"), location.href).href, hash: actualHash, |
| 206 | - type: "application/octet-stream", state, signal: this.uploadController.signal, |
| 207 | - authorize: (url, verb, body) => tiny.authorization(url, verb, body), |
| 208 | - onProgress: progress => this.say("Uploading " + Math.round(progress.fraction * 100) + "%…") |
| 209 | - }); |
| 210 | - this.uploadTask = task; |
| 211 | - descriptor = (await task).descriptor; |
| 212 | - } else { |
| 213 | - const response = await tiny.signedFetch("/upload", "PUT", ciphertext, {contentType: "application/octet-stream", signal: this.uploadController.signal}); |
| 214 | - descriptor = await response.json(); |
| 215 | - } |
| 216 | - if (descriptor.sha256 !== actualHash) throw Error("relay returned an unexpected hash"); |
| 217 | - this.fileHash = actualHash; |
| 218 | - this.fileFragment = fragment; |
| 219 | - this.setAttribute("hash", actualHash); |
| 220 | - this.setAttribute("type", file.type || "application/octet-stream"); |
| 221 | - this.setAttribute("size", String(ciphertext.length)); |
| 222 | - this.updateFileControls(); |
| 223 | - const link = new URL(location.href); |
| 224 | - link.pathname = location.pathname.replace(/\/files?$/, "/file"); |
| 225 | - link.search = "?hash=" + actualHash; |
| 226 | - link.hash = fragment; |
| 227 | - this.fileShareLink = link.href; |
| 228 | - const share = this.querySelector("[data-share-url]"); |
| 229 | - if (share) share.value = link.href; |
| 230 | - this.pendingUpload = null; |
| 231 | - this.uploadTask = null; |
| 232 | - try { |
| 233 | - if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable"); |
| 234 | - await navigator.clipboard.writeText(link.href); |
| 235 | - this.say("Encrypted file stored. Share link copied; the key is only in its fragment."); |
| 236 | - } catch { |
| 237 | - if (share) share.select?.(); |
| 238 | - this.say("Encrypted file stored. Copy the share link above; the key is only in its fragment."); |
| 239 | - } |
| 240 | - } |
| 241 | - |
| 242 | async decryptFromFragment() { |
| 243 | - const params = new URLSearchParams(this.fileFragment || location.hash.slice(1)); |
| 244 | + const params = new URLSearchParams(location.hash.slice(1)); |
| 245 | if ((!params.get("key") || !params.get("iv")) && !(params.get("enc") === "chk-v1" && params.get("key"))) return; |
| 246 | try { |
| 247 | this.say("Decrypting…"); |
| 248 | @@ -377,7 +276,7 @@ |
| 249 | if (this.sharing) return; |
| 250 | const recipient = form.elements.namedItem("recipient").value.trim(); |
| 251 | if (!recipient) { this.say("Enter a public key or npub.", true); return; } |
| 252 | - const params = new URLSearchParams(this.fileFragment || location.hash.slice(1)); |
| 253 | + const params = new URLSearchParams(location.hash.slice(1)); |
| 254 | const signer = tiny.signer(); |
| 255 | 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; } |
| 256 | if (!params.get("key") || !params.get("iv")) { this.say("NIP-17 sharing currently requires a random AES-GCM encrypted file.", true); return; } |
| 257 | diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js |
| 258 | index 8c10803..2f7723e 100644 |
| 259 | --- a/internal/webui/file-workspace.js |
| 260 | +++ b/internal/webui/file-workspace.js |
| 261 | @@ -140,22 +140,31 @@ |
| 262 | return url.href; |
| 263 | }; |
| 264 | |
| 265 | - class Workspace extends HTMLElement { |
| 266 | + // FileUpload is the one upload control. Plain uploads store each chosen |
| 267 | + // file as it is. Encrypt keeps the contents and key in the browser: a single |
| 268 | + // file gets a fresh random key, a folder or a large file becomes encrypted |
| 269 | + // manifests, and the share link carries the key only in its fragment. |
| 270 | + const LARGE_FILE_BYTES = 64 * 1024 * 1024; |
| 271 | + const {b64url} = globalThis.tiny.util; |
| 272 | + class FileUpload extends HTMLElement { |
| 273 | connectedCallback() { |
| 274 | if (this.bound) return; |
| 275 | this.bound = true; |
| 276 | this.innerHTML = |
| 277 | - '<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>'; |
| 278 | + '<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>'; |
| 279 | this.out = this.querySelector("output"); |
| 280 | - for (const form of this.querySelectorAll("form")) |
| 281 | - form.addEventListener("submit", event => { |
| 282 | - event.preventDefault(); |
| 283 | - this.run(form).catch(error => this.say(error.message, true)); |
| 284 | - }); |
| 285 | + const form = this.querySelector("form"); |
| 286 | + const input = form.elements.namedItem("file"); |
| 287 | + form.elements.namedItem("folder").addEventListener("change", event => { |
| 288 | + if (event.currentTarget.checked) input.setAttribute("webkitdirectory", ""); |
| 289 | + else input.removeAttribute("webkitdirectory"); |
| 290 | + }); |
| 291 | + form.addEventListener("submit", event => { |
| 292 | + event.preventDefault(); |
| 293 | + this.run(form).catch(() => {}); |
| 294 | + }); |
| 295 | this.querySelector("[data-cancel]").addEventListener("click", () => this.controller?.abort()); |
| 296 | - this.querySelector("[data-retry]").addEventListener("click", () => |
| 297 | - this.run().catch(error => this.say(error.message, true)) |
| 298 | - ); |
| 299 | + this.querySelector("[data-retry]").addEventListener("click", () => this.run().catch(() => {})); |
| 300 | } |
| 301 | disconnectedCallback() { |
| 302 | this.controller?.abort(); |
| 303 | @@ -165,53 +174,153 @@ |
| 304 | if (error) this.out.dataset.error = ""; |
| 305 | else delete this.out.dataset.error; |
| 306 | } |
| 307 | + controls(busy, completed) { |
| 308 | + const retryable = Boolean(this.selection) && !completed; |
| 309 | + this.querySelector("[data-controls]").hidden = !busy && !retryable; |
| 310 | + this.querySelector("[data-cancel]").disabled = !busy; |
| 311 | + this.querySelector("[data-retry]").disabled = busy || !retryable; |
| 312 | + } |
| 313 | async run(form) { |
| 314 | if (this.busy) return; |
| 315 | if (form) { |
| 316 | - const folder = form.elements.namedItem("folder"); |
| 317 | - const input = folder || form.elements.namedItem("file"); |
| 318 | - this.selection = {files: [...input.files], folder: Boolean(folder)}; |
| 319 | + const files = [...form.elements.namedItem("file").files]; |
| 320 | + this.selection = { |
| 321 | + files, |
| 322 | + folder: Boolean(form.elements.namedItem("folder").checked), |
| 323 | + encrypt: Boolean(form.elements.namedItem("encrypt").checked) |
| 324 | + }; |
| 325 | + this.pending = null; |
| 326 | } |
| 327 | if (!this.selection?.files.length) throw Error("Choose a file or folder first."); |
| 328 | this.busy = true; |
| 329 | this.controller = new AbortController(); |
| 330 | - this.querySelector("[data-cancel]").disabled = false; |
| 331 | - this.querySelector("[data-retry]").disabled = true; |
| 332 | + this.controls(true, false); |
| 333 | let completed = false; |
| 334 | try { |
| 335 | - let reference, name, type; |
| 336 | - let sent = 0; |
| 337 | - const progress = size => { |
| 338 | - sent += size; |
| 339 | - this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…"); |
| 340 | - }; |
| 341 | - this.say("Encrypting selected content…"); |
| 342 | - if (this.selection.folder) { |
| 343 | - const selected = selectionTree(this.selection.files); |
| 344 | - name = selected.name; |
| 345 | - reference = await storeDirectory(selected.root, this.controller.signal, progress); |
| 346 | + const {files, folder, encrypt} = this.selection; |
| 347 | + let link; |
| 348 | + if (!encrypt) await this.storePlain(files); |
| 349 | + else if (folder || files.length > 1) link = await this.storeManifest(files, true); |
| 350 | + else if (files[0].size > LARGE_FILE_BYTES) link = await this.storeManifest(files, false); |
| 351 | + else link = await this.storeSealed(files[0]); |
| 352 | + completed = true; |
| 353 | + if (link) { |
| 354 | + await this.showLink(link); |
| 355 | } else { |
| 356 | - const file = this.selection.files[0]; |
| 357 | - name = file.name; |
| 358 | - type = file.type; |
| 359 | - reference = await storeFile(file, this.controller.signal, progress); |
| 360 | + this.say("Stored " + files.length + (files.length === 1 ? " file." : " files.")); |
| 361 | + await tiny.navigate?.(location.href); |
| 362 | } |
| 363 | - checkAbort(this.controller.signal); |
| 364 | - const url = shareURL(reference, name, type); |
| 365 | - this.querySelector("[data-tree-share]").value = url; |
| 366 | - const open = this.querySelector("[data-open]"); |
| 367 | - open.href = url; |
| 368 | - open.hidden = false; |
| 369 | - this.say("Stored. Keep the complete share link to decrypt and browse this content."); |
| 370 | - completed = true; |
| 371 | - return url; |
| 372 | + return link; |
| 373 | + } catch (error) { |
| 374 | + const canceled = this.controller.signal.aborted; |
| 375 | + this.say( |
| 376 | + (canceled ? "Upload canceled." : "Upload stopped: " + error.message) + " Retry while this page stays open.", |
| 377 | + true |
| 378 | + ); |
| 379 | + throw error; |
| 380 | } finally { |
| 381 | this.busy = false; |
| 382 | - this.querySelector("[data-cancel]").disabled = true; |
| 383 | - this.querySelector("[data-retry]").disabled = completed; |
| 384 | + this.controls(false, completed); |
| 385 | if (completed) this.selection = null; |
| 386 | } |
| 387 | } |
| 388 | + async storePlain(files) { |
| 389 | + for (const [index, file] of files.entries()) { |
| 390 | + checkAbort(this.controller.signal); |
| 391 | + this.say("Uploading " + file.name + " (" + (index + 1) + " of " + files.length + ")…"); |
| 392 | + await tiny.signedFetch("/upload", "PUT", await file.arrayBuffer(), { |
| 393 | + contentType: file.type || "application/octet-stream", |
| 394 | + signal: this.controller.signal |
| 395 | + }); |
| 396 | + } |
| 397 | + } |
| 398 | + // storeSealed encrypts one file with a fresh AES-GCM key. The ciphertext |
| 399 | + // and key stay in memory so a retry resumes without re-encrypting. |
| 400 | + async storeSealed(file) { |
| 401 | + if (!this.pending) { |
| 402 | + if (file.size > MAX_FILE_BYTES) throw Error("Encrypted browser uploads are limited to 256 MiB."); |
| 403 | + this.say("Encrypting…"); |
| 404 | + const plaintext = new Uint8Array(await file.arrayBuffer()); |
| 405 | + const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, ["encrypt", "decrypt"]); |
| 406 | + const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 407 | + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, plaintext)); |
| 408 | + const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key)); |
| 409 | + const fragment = |
| 410 | + "key=" + |
| 411 | + b64url(rawKey) + |
| 412 | + "&iv=" + |
| 413 | + b64url(iv) + |
| 414 | + "&name=" + |
| 415 | + encodeURIComponent(file.name) + |
| 416 | + "&type=" + |
| 417 | + encodeURIComponent(file.type || "application/octet-stream") + |
| 418 | + "&ox=" + |
| 419 | + (await tiny.sha256hex(plaintext)); |
| 420 | + this.pending = {ciphertext, fragment, state: new Map()}; |
| 421 | + } |
| 422 | + checkAbort(this.controller.signal); |
| 423 | + const {ciphertext, fragment, state} = this.pending; |
| 424 | + const hash = await tiny.sha256hex(ciphertext); |
| 425 | + let descriptor; |
| 426 | + if (upload()?.upload) { |
| 427 | + const task = upload().upload(ciphertext, { |
| 428 | + url: new URL(tiny.localPath("/"), location.href).href, |
| 429 | + hash, |
| 430 | + type: "application/octet-stream", |
| 431 | + state, |
| 432 | + signal: this.controller.signal, |
| 433 | + authorize: (target, method, body) => tiny.authorization(target, method, body), |
| 434 | + onProgress: progress => this.say("Uploading " + Math.round(progress.fraction * 100) + "%…") |
| 435 | + }); |
| 436 | + descriptor = (await task).descriptor; |
| 437 | + } else { |
| 438 | + const response = await tiny.signedFetch("/upload", "PUT", ciphertext, { |
| 439 | + contentType: "application/octet-stream", |
| 440 | + signal: this.controller.signal |
| 441 | + }); |
| 442 | + descriptor = await response.json(); |
| 443 | + } |
| 444 | + if (descriptor.sha256 !== hash) throw Error("Relay returned an unexpected hash."); |
| 445 | + this.pending = null; |
| 446 | + const link = new URL(tiny.localPath("/file"), location.href); |
| 447 | + link.search = "?hash=" + hash; |
| 448 | + link.hash = fragment; |
| 449 | + return link.href; |
| 450 | + } |
| 451 | + async storeManifest(files, folder) { |
| 452 | + let sent = 0; |
| 453 | + const progress = size => { |
| 454 | + sent += size; |
| 455 | + this.say("Stored " + sent.toLocaleString() + " bytes. Encrypting the remaining content…"); |
| 456 | + }; |
| 457 | + this.say("Encrypting selected content…"); |
| 458 | + let reference, name, type; |
| 459 | + if (folder) { |
| 460 | + const selected = selectionTree(files); |
| 461 | + name = selected.name || "files"; |
| 462 | + reference = await storeDirectory(selected.root, this.controller.signal, progress); |
| 463 | + } else { |
| 464 | + const file = files[0]; |
| 465 | + name = file.name; |
| 466 | + type = file.type; |
| 467 | + reference = await storeFile(file, this.controller.signal, progress); |
| 468 | + } |
| 469 | + checkAbort(this.controller.signal); |
| 470 | + return shareURL(reference, name, type); |
| 471 | + } |
| 472 | + async showLink(link) { |
| 473 | + const share = this.querySelector("[data-share-url]"); |
| 474 | + share.value = link; |
| 475 | + this.querySelector("[data-share]").hidden = false; |
| 476 | + try { |
| 477 | + if (!navigator.clipboard?.writeText) throw Error("Clipboard unavailable"); |
| 478 | + await navigator.clipboard.writeText(link); |
| 479 | + this.say("Stored. Share link copied; the key is only in its fragment."); |
| 480 | + } catch { |
| 481 | + share.select?.(); |
| 482 | + this.say("Stored. Copy the share link above; the key is only in its fragment."); |
| 483 | + } |
| 484 | + } |
| 485 | } |
| 486 | |
| 487 | async function fetchBlob(hash, signal) { |
| 488 | @@ -379,6 +488,6 @@ |
| 489 | link.click(); |
| 490 | } |
| 491 | } |
| 492 | - customElements.define("file-workspace", Workspace); |
| 493 | + customElements.define("file-upload", FileUpload); |
| 494 | customElements.define("file-workspace-root", Root); |
| 495 | })(); |
| 496 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 497 | index 1e9ba60..e818619 100644 |
| 498 | --- a/internal/webui/page.html |
| 499 | +++ b/internal/webui/page.html |
| 500 | @@ -105,7 +105,7 @@ |
| 501 | <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}} |
| 502 | |
| 503 | {{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> |
| 504 | -{{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}} |
| 505 | +{{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}} |
| 506 | |
| 507 | {{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> |
| 508 | {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}} |
| 509 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 510 | index c5024e1..e5a838b 100644 |
| 511 | --- a/internal/webui/style.css |
| 512 | +++ b/internal/webui/style.css |
| 513 | @@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere} |
| 514 | img{max-width:100%} |
| 515 | |
| 516 | /* Selection: chrome is not selectable, content and values are. */ |
| 517 | -#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} |
| 518 | +#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} |
| 519 | |
| 520 | /* Shell: a table with three columns; each column scrolls on its own. */ |
| 521 | #topbar{display:none} |
| 522 | @@ -107,15 +107,18 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem} |
| 523 | #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px} |
| 524 | |
| 525 | /* rpc-form, signed-form, publish-list: one signed call each. */ |
| 526 | -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} |
| 527 | -nostr-compose,file-tools,file-workspace,file-workspace-root{max-width:720px} |
| 528 | -file-tools h3,file-workspace h3{margin:0 0 4px}file-tools p,file-workspace p{margin:6px 0} |
| 529 | -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)} |
| 530 | -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} |
| 531 | -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%} |
| 532 | +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} |
| 533 | +nostr-compose,file-tools,file-upload,file-workspace-root{max-width:720px} |
| 534 | +file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6px 0} |
| 535 | +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)} |
| 536 | +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} |
| 537 | +file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%} |
| 538 | +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} |
| 539 | +#panel file-upload [hidden],file-upload [hidden],file-tools [hidden]{display:none} |
| 540 | +#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} |
| 541 | rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block} |
| 542 | -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} |
| 543 | -rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)} |
| 544 | +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} |
| 545 | +rpc-form output[data-error],file-tools output[data-error],file-upload output[data-error]{color:var(--alert)} |
| 546 | 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} |
| 547 | file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left} |
| 548 | #content>section>h3{border-bottom:0;margin-top:22px;font-size:15px} |
| 549 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 550 | index 755e3e8..a7f7100 100644 |
| 551 | --- a/internal/webui/tiny.js |
| 552 | +++ b/internal/webui/tiny.js |
| 553 | @@ -45,7 +45,18 @@ |
| 554 | if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, ""); |
| 555 | return url.toString().replace(/\/$/, ""); |
| 556 | }; |
| 557 | - tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL}); |
| 558 | + const b64url = value => { |
| 559 | + let binary = ""; |
| 560 | + bytes(value).forEach(byte => { |
| 561 | + binary += String.fromCharCode(byte); |
| 562 | + }); |
| 563 | + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 564 | + }; |
| 565 | + const fromB64url = value => { |
| 566 | + const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); |
| 567 | + return Uint8Array.from(atob(text), char => char.charCodeAt(0)); |
| 568 | + }; |
| 569 | + tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url}); |
| 570 | // signer returns the active signer: a resumed remote signer first, then a |
| 571 | // NIP-07 extension. |
| 572 | tiny.signer = () => globalThis.tinySigner || globalThis.nostr; |
| 573 | diff --git a/scripts/file-workspace.test.mjs b/scripts/file-workspace.test.mjs |
| 574 | index 9f5a1c0..a29ef18 100644 |
| 575 | --- a/scripts/file-workspace.test.mjs |
| 576 | +++ b/scripts/file-workspace.test.mjs |
| 577 | @@ -59,7 +59,7 @@ const load = (workspace = false) => { |
| 578 | vm.runInThisContext(fs.readFileSync("internal/webui/tiny.js", "utf8")); |
| 579 | 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; } |
| 580 | vm.runInThisContext(fs.readFileSync("internal/webui/components.js", "utf8")); |
| 581 | - return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"]}; |
| 582 | + return {document, window, FileTools: customElements.registry["file-tools"], FileMirror: customElements.registry["file-mirror"], FileUpload: customElements.registry["file-upload"]}; |
| 583 | }; |
| 584 | |
| 585 | const workspaceServer = t => { |
| 586 | @@ -104,14 +104,31 @@ const workspaceServer = t => { |
| 587 | return root; |
| 588 | }; |
| 589 | const store = async files => { |
| 590 | - const workspace = new customElements.registry["file-workspace"](); |
| 591 | - workspace.connectedCallback(); |
| 592 | - const form = workspace.querySelector("[data-folder]"); |
| 593 | - form.elements.namedItem("folder").files = files; |
| 594 | - return workspace.run(form); |
| 595 | + const upload = new customElements.registry["file-upload"](); |
| 596 | + upload.connectedCallback(); |
| 597 | + const form = upload.querySelector("form"); |
| 598 | + form.elements.namedItem("file").files = files; |
| 599 | + form.elements.namedItem("folder").checked = true; |
| 600 | + form.elements.namedItem("encrypt").checked = true; |
| 601 | + return upload.run(form); |
| 602 | }; |
| 603 | return {...loaded, blobs, requests, store, open, originalFetch}; |
| 604 | }; |
| 605 | +// sealedUpload stores one file through the upload control with encrypt checked |
| 606 | +// and the plain signed PUT path, returning the share link and ciphertext. |
| 607 | +const sealedUpload = async (window, FileUpload, file) => { |
| 608 | + let uploaded; |
| 609 | + delete window.tiny.blossom.upload; |
| 610 | + window.tiny.localPath = path => path; |
| 611 | + window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex"); |
| 612 | + window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; }; |
| 613 | + const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback(); |
| 614 | + const form = upload.querySelector("form"); |
| 615 | + form.elements.namedItem("file").files = [file]; |
| 616 | + form.elements.namedItem("encrypt").checked = true; |
| 617 | + const link = await upload.run(form); |
| 618 | + return {upload, link, uploaded: () => uploaded}; |
| 619 | +}; |
| 620 | const folderFile = (contents, path) => { |
| 621 | const file = new File([contents], path.split("/").at(-1), {type: "text/plain"}); |
| 622 | Object.defineProperty(file, "webkitRelativePath", {value: path}); |
| 623 | @@ -154,16 +171,28 @@ test("folder upload rejects unexpected server hashes", async t => { |
| 624 | }); |
| 625 | |
| 626 | test("encrypted upload keeps ciphertext and link when clipboard fails", async () => { |
| 627 | - const {document, window, FileTools} = load(); const requests = []; |
| 628 | - window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex"); |
| 629 | - window.tiny.signedFetch = async (path, method, body) => { const bytes = new Uint8Array(body); requests.push(bytes); return {json: async () => ({sha256: await window.tiny.sha256hex(bytes)})}; }; |
| 630 | - const tools = new FileTools(); document.append(tools); tools.connectedCallback(); |
| 631 | - const form = tools.querySelector("form:not([data-share])"); |
| 632 | - form.querySelector("input[type=file]").files = [new File(["secret plaintext"], "secret.txt", {type: "text/plain"})]; |
| 633 | - await tools.encryptUpload(form); |
| 634 | - assert.equal(requests.length, 1); assert.notEqual(new TextDecoder().decode(requests[0]), "secret plaintext"); |
| 635 | - assert.match(tools.querySelector("[data-share-url]").value, /#key=.*&iv=/); assert.match(tools.output.textContent, /Copy the share link above/); |
| 636 | - assert.equal(tools.shareLink(), tools.querySelector("[data-share-url]").value); |
| 637 | + const {window, FileUpload} = load(true); |
| 638 | + const {upload, link, uploaded} = await sealedUpload(window, FileUpload, new File(["secret plaintext"], "secret.txt", {type: "text/plain"})); |
| 639 | + assert.notEqual(new TextDecoder().decode(uploaded()), "secret plaintext"); |
| 640 | + assert.match(link, /\/file\?hash=[0-9a-f]{64}#key=.*&iv=/); |
| 641 | + assert.equal(upload.querySelector("[data-share-url]").value, link); |
| 642 | + assert.equal(upload.querySelector("[data-share]").hidden, false); |
| 643 | + assert.match(upload.out.textContent, /Copy the share link above/); |
| 644 | +}); |
| 645 | + |
| 646 | +test("plain uploads store every chosen file and refresh the listing", async () => { |
| 647 | + const {window, FileUpload} = load(true); |
| 648 | + const sent = []; |
| 649 | + let refreshed = 0; |
| 650 | + window.tiny.signedFetch = async (path, method, body, options) => { sent.push({path, method, size: body.byteLength, type: options.contentType}); return {}; }; |
| 651 | + window.tiny.navigate = async () => { refreshed++; }; |
| 652 | + const upload = new FileUpload(); window.document.append(upload); upload.connectedCallback(); |
| 653 | + const form = upload.querySelector("form"); |
| 654 | + form.elements.namedItem("file").files = [new File(["one"], "a.txt", {type: "text/plain"}), new File(["two"], "b.bin")]; |
| 655 | + assert.equal(await upload.run(form), undefined); |
| 656 | + assert.deepEqual(sent, [{path: "/upload", method: "PUT", size: 3, type: "text/plain"}, {path: "/upload", method: "PUT", size: 3, type: "application/octet-stream"}]); |
| 657 | + assert.equal(refreshed, 1); |
| 658 | + assert.equal(upload.out.textContent, "Stored 2 files."); |
| 659 | }); |
| 660 | |
| 661 | test("BUD15 key is rejected before mirror request", async () => { |
| 662 | @@ -175,15 +204,14 @@ test("BUD15 key is rejected before mirror request", async () => { |
| 663 | }); |
| 664 | |
| 665 | test("sharing a newly uploaded file uses its current key, hash and ciphertext size", async () => { |
| 666 | - const {document, window, FileTools} = load(); |
| 667 | - window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex"); |
| 668 | - window.tiny.localPath = path => path; |
| 669 | - let uploaded; |
| 670 | - window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; }; |
| 671 | - const element = new FileTools(); document.append(element); element.connectedCallback(); |
| 672 | - const form = element.querySelector("form:not([data-share])"); |
| 673 | - form.querySelector("input[type=file]").files = [new File(["new secret"], "private.txt", {type: "text/plain"})]; |
| 674 | - await element.encryptUpload(form); |
| 675 | + const {document, window, FileTools, FileUpload} = load(true); |
| 676 | + const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["new secret"], "private.txt", {type: "text/plain"})); |
| 677 | + window.location.hash = new URL(link).hash; |
| 678 | + const element = new FileTools(); |
| 679 | + element.setAttribute("hash", await window.tiny.sha256hex(uploaded())); |
| 680 | + element.setAttribute("type", "text/plain"); |
| 681 | + element.setAttribute("size", String(uploaded().length)); |
| 682 | + document.append(element); element.connectedCallback(); |
| 683 | const shareForm = element.querySelector("[data-share]"); |
| 684 | shareForm.querySelector("input").value = "b".repeat(64); |
| 685 | globalThis.nostr = {signEvent: async value => value}; |
| 686 | @@ -191,13 +219,13 @@ test("sharing a newly uploaded file uses its current key, hash and ciphertext si |
| 687 | globalThis.tiny.files.messages = {share: async (value, options) => { input = value; assert.equal(options.signer, globalThis.nostr); return {}; }}; |
| 688 | await element.share(shareForm); |
| 689 | assert.ok(input, element.output.textContent); |
| 690 | - assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded)); |
| 691 | - assert.equal(input.size, uploaded.length); |
| 692 | + assert.equal(input.ciphertextHash, await window.tiny.sha256hex(uploaded())); |
| 693 | + assert.equal(input.size, uploaded().length); |
| 694 | assert.equal(input.plaintextHash, await window.tiny.sha256hex(new TextEncoder().encode("new secret"))); |
| 695 | assert.match(input.key, /^[0-9a-f]{64}$/); |
| 696 | assert.match(input.nonce, /^[0-9a-f]{24}$/); |
| 697 | const key = await webcrypto.subtle.importKey("raw", Buffer.from(input.key, "hex"), "AES-GCM", false, ["decrypt"]); |
| 698 | - const plain = await webcrypto.subtle.decrypt({name: "AES-GCM", iv: Buffer.from(input.nonce, "hex")}, key, uploaded); |
| 699 | + const plain = await webcrypto.subtle.decrypt({name: "AES-GCM", iv: Buffer.from(input.nonce, "hex")}, key, uploaded()); |
| 700 | assert.equal(new TextDecoder().decode(plain), "new secret"); |
| 701 | assert.equal(new URL(input.fileURL).hash, ""); |
| 702 | }); |
| 703 | @@ -213,32 +241,30 @@ test("HTTP mirror input never submits its secret fragment", async () => { |
| 704 | }); |
| 705 | |
| 706 | test("local downloads verify integrity, decrypt and release their temporary URL", async () => { |
| 707 | - const {document, window, FileTools} = load(); |
| 708 | - window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex"); |
| 709 | - window.tiny.localPath = path => path; |
| 710 | - let uploaded; |
| 711 | - window.tiny.signedFetch = async (_path, _method, body) => { uploaded = new Uint8Array(body); return {json: async () => ({sha256: await window.tiny.sha256hex(body)})}; }; |
| 712 | - const element = new FileTools(); document.append(element); element.connectedCallback(); |
| 713 | - const form = element.querySelector("form:not([data-share])"); |
| 714 | - form.querySelector("input[type=file]").files = [new File(["round trip"], "round.txt", {type: "text/plain"})]; |
| 715 | - await element.encryptUpload(form); |
| 716 | + const {document, window, FileTools, FileUpload} = load(true); |
| 717 | + const {link, uploaded} = await sealedUpload(window, FileUpload, new File(["round trip"], "round.txt", {type: "text/plain"})); |
| 718 | + window.location.hash = new URL(link).hash; |
| 719 | + const element = new FileTools(); |
| 720 | + element.setAttribute("hash", await window.tiny.sha256hex(uploaded())); |
| 721 | + document.append(element); |
| 722 | const originalFetch = globalThis.fetch; |
| 723 | try { |
| 724 | - globalThis.fetch = async () => new Response(uploaded); |
| 725 | + globalThis.fetch = async () => new Response(uploaded()); |
| 726 | + element.connectedCallback(); |
| 727 | await element.decryptFromFragment(); |
| 728 | assert.equal(element.output.textContent, "Decrypted locally."); |
| 729 | assert.equal(await (await originalFetch(element.downloadURL)).text(), "round trip"); |
| 730 | const temporaryURL = element.downloadURL; |
| 731 | element.disconnectedCallback(); |
| 732 | await assert.rejects(originalFetch(temporaryURL)); |
| 733 | - uploaded[0] ^= 1; |
| 734 | + uploaded()[0] ^= 1; |
| 735 | await element.decryptFromFragment(); |
| 736 | assert.match(element.output.textContent, /ciphertext hash mismatch/); |
| 737 | } finally { globalThis.fetch = originalFetch; } |
| 738 | }); |
| 739 | |
| 740 | test("retry keeps the original ciphertext and key after interrupted encrypted upload", async () => { |
| 741 | - const {document, window, FileTools} = load(); |
| 742 | + const {document, window, FileUpload} = load(true); |
| 743 | window.tiny.localPath = path => path; |
| 744 | window.tiny.sha256hex = async bytes => Buffer.from(await webcrypto.subtle.digest("SHA-256", bytes)).toString("hex"); |
| 745 | const sent = []; |
| 746 | @@ -248,14 +274,15 @@ test("retry keeps the original ciphertext and key after interrupted encrypted up |
| 747 | if (sent.length === 1) throw Error("connection interrupted"); |
| 748 | return {descriptor: {sha256: await window.tiny.sha256hex(bytes), size: bytes.length}}; |
| 749 | }}; |
| 750 | - const fileTools = new FileTools(); document.append(fileTools); fileTools.connectedCallback(); |
| 751 | - const form = fileTools.querySelector("form:not([data-share])"); |
| 752 | + const upload = new FileUpload(); document.append(upload); upload.connectedCallback(); |
| 753 | + const form = upload.querySelector("form"); |
| 754 | form.elements.namedItem("file").files = [new File(["keep the same key"], "private.txt")]; |
| 755 | - await fileTools.encryptUpload(form); |
| 756 | - const originalFragment = fileTools.pendingUpload.fragment; |
| 757 | - assert.equal(fileTools.querySelector("[data-upload-retry]").disabled, false); |
| 758 | - await fileTools.retryUpload(); |
| 759 | + form.elements.namedItem("encrypt").checked = true; |
| 760 | + await assert.rejects(upload.run(form), /connection interrupted/); |
| 761 | + const originalFragment = upload.pending.fragment; |
| 762 | + assert.equal(upload.querySelector("[data-retry]").disabled, false); |
| 763 | + const link = await upload.run(); |
| 764 | assert.deepEqual(sent[1], sent[0]); |
| 765 | - assert.equal(fileTools.fileFragment, originalFragment); |
| 766 | - assert.equal(fileTools.pendingUpload, null); |
| 767 | + assert.equal(new URL(link).hash.slice(1), originalFragment); |
| 768 | + assert.equal(upload.pending, null); |
| 769 | }); |