diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 12:19:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 12:19:22 +0200 |
| commit | 8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (patch) | |
| tree | 0ebd406d893b49ebdf9290ea1a4ac3474d01b7bd /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | 0503682c0e2add135b88c2a1fadfe07455680a71 (diff) | |
| download | meshbay-8cd7e467ebec987f66c4fe93a8d87dfbc57304d2.tar.gz | |
feat(files): download a folder as a zip, and remove an empty one
Two things a Files panel needs and did not have.
**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.
**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.
zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.
Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.
One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 147 |
1 files changed, 141 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3ef0da5..995e7c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -3,6 +3,7 @@ import { createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1158,6 +1159,107 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } }, [groupId, token, descDraft, onGroupUpdated]); + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + let writable = null; + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ + suggestedName: suggested, + types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], + }); + writable = await handle.createWritable(); + } else if (!confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + + setDlState({ name: suggested, progress: 0, total: totalBytes }); + const parts = writable ? null : []; + let written = 0; + + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { + written += bytes; + setDlState(prev => prev && { ...prev, progress: written }); + }, + // pipelinedDownload writes in order, which is what the archive needs. + { write: (plaintext) => zip.write(plaintext) }); + await zip.end(); + } + await zip.finish(); + + if (writable) { + await writable.close(); + } else { + const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' })); + const a = document.createElement('a'); + a.href = url; + a.download = suggested; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + setDlState(null); + } catch (err) { + setDlState(null); + if (writable) await writable.abort().catch(() => {}); + if (err.name === 'AbortError') return; + setError(t('group.dl_failed', { err: err.message })); + } + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + const deleteFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -1383,17 +1485,50 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </tr> </thead> <tbody> - ${subdirs.map(d => html` - <tr class="file-row dir-row" onClick=${() => - setCurrentPath(currentPath ? currentPath + '/' + d : d)}> + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + <tr class="file-row dir-row" key=${full} onClick=${() => + setCurrentPath(full)}> <td>\u{1F4C1}</td> <td>${d}/</td> - <td></td> + <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> <td class="td-type"></td> <td class="td-date"></td> - <td></td> + <td class="file-actions-cell"> + <button class="file-menu-btn" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full); + }}>${'\u{22EE}'}</button> + ${menuOpen === 'dir:' + full && html` + <div class="file-menu"> + ${inside.length > 0 && html` + <button onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + downloadDirectory(full); + }}> + <span class="fmi">${'\u{2B07}'}</span> + ${t('group.download_zip', { n: inside.length })} + </button> + `} + ${operatorPaired && status === 'connected' && html` + <button class="danger" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + if (inside.length) { setError(t('group.rmdir_not_empty')); return; } + if (confirm(t('group.rmdir_confirm', { name: d }))) { + deleteDirectory(full); + } + }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button> + `} + </div> + `} + </td> </tr> - `)} + `; })} ${sorted.map(e => { const canPreview = ['image', 'video', 'document'].includes(e.type) || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i); |