tinyrelaycommit

Commit c49b9072ae7f

browse files at this commit | history

1diff --git a/docs/files-and-private-repositories.md b/docs/files-and-private-repositories.md
2index 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 |
46diff --git a/internal/webui/browse.html b/internal/webui/browse.html
47index 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>
58diff --git a/internal/webui/components.js b/internal/webui/components.js
59index 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; }
257diff --git a/internal/webui/file-workspace.js b/internal/webui/file-workspace.js
258index 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 })();
496diff --git a/internal/webui/page.html b/internal/webui/page.html
497index 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}}
509diff --git a/internal/webui/style.css b/internal/webui/style.css
510index 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}
549diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js
550index 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;
573diff --git a/scripts/file-workspace.test.mjs b/scripts/file-workspace.test.mjs
574index 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 });

About

ownerc01503e8035f
visibilitypublic
headc49b9072ae7f

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs