aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js147
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/zipstream.js241
4 files changed, 411 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);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index a6281e2..14a6c78 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -278,6 +278,15 @@ const en = {
+ 'you normally talk. It works once, and it never passes through the hub.',
'members.invite_code_hint': 'They enter it the first time they open this group. '
+ 'You do not need to be online then.',
+ 'group.download_zip': 'Download as zip ({n} files)',
+ 'group.zip_empty': 'That folder has nothing in it to download.',
+ 'group.zip_no_stream': 'This browser cannot write a download straight to disk, '
+ + 'so {name} ({size}) has to be built in memory first. On a large archive '
+ + 'that may fail. Continue?',
+ 'group.rmdir': 'Delete folder',
+ 'group.rmdir_confirm': 'Delete the folder "{name}"? It must be empty.',
+ 'group.rmdir_not_empty': 'That folder is not empty. Delete what is in it first — '
+ + 'nothing here removes files you cannot see.',
'group.desc_edit': 'Edit description',
'group.desc_add': 'Add a description',
'group.desc_save': 'Save',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index e93c601..326aa8e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -493,6 +493,26 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * Remove an empty directory. Operator only, and the node checks that — this
+ * signs with the identity it pinned for us, exactly like deleting a file.
+ */
+ async deleteDirectory(dir, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'dir_delete',
+ v: '0.1',
+ dir,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // `dir`, not msg.subject: comparing the node's answer against itself is
+ // no check at all, and the point of this one is that we know what we
+ // asked for without being told.
+ return this._authorizeAdminOp(msg, 'dir_delete', dir, signFn);
+ }
+ return msg;
+ }
+
requestStream(fileId) {
this._send({ type: 'stream_req', v: '0.1', file_id: fileId });
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js b/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js
new file mode 100644
index 0000000..bf2230a
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/zipstream.js
@@ -0,0 +1,241 @@
+/**
+ * Streaming ZIP writer — store only, no compression.
+ *
+ * Written for downloading a whole directory out of a group. The archive can be
+ * tens of gigabytes, so nothing is buffered: bytes go to the sink as they
+ * arrive, and the only thing kept in memory is one small record per file for
+ * the central directory at the end.
+ *
+ * Store-only is deliberate. What people put in a group is video, images and
+ * archives — already compressed — so deflate would cost CPU on every byte to
+ * save nothing, and it would have to run in the same thread that is decrypting
+ * chunks. An uncompressed zip is also the one a stalled download leaves in a
+ * recoverable state.
+ *
+ * Sizes are written after the data, in a data descriptor (general purpose bit
+ * 3), because a stream cannot seek back to patch the header — the CRC is not
+ * known until the last byte has gone past. Zip64 is used per entry when a file
+ * is 4 GiB or larger, or when it starts past the 4 GiB mark, and for the
+ * archive itself when it ends past that mark or holds more than 65535 files.
+ *
+ * No browser globals: this module is exercised under Node by
+ * packages/meshbay-hub/tests/test_zipstream.py, which reads what it produces
+ * with Python's zipfile and compares it byte for byte.
+ */
+
+const LOCAL_SIG = 0x04034b50;
+const DESC_SIG = 0x08074b50;
+const CENTRAL_SIG = 0x02014b50;
+const EOCD64_SIG = 0x06064b50;
+const LOC64_SIG = 0x07064b50;
+const EOCD_SIG = 0x06054b50;
+
+const U32_MAX = 0xffffffff;
+const ZIP64_THRESHOLD = 0xffffffff;
+
+// Bit 3: sizes and CRC follow the data. Bit 11: the name is UTF-8.
+const FLAG_DATA_DESCRIPTOR = 0x0008;
+const FLAG_UTF8 = 0x0800;
+
+const CRC_TABLE = (() => {
+ const table = new Int32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ table[i] = c;
+ }
+ return table;
+})();
+
+export function crc32(bytes, seed = 0) {
+ let c = ~seed;
+ for (let i = 0; i < bytes.length; i++) {
+ c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ }
+ return (~c) >>> 0;
+}
+
+/** MS-DOS time and date, which is what a zip entry carries. */
+function dosDateTime(date) {
+ const d = date instanceof Date ? date : new Date(date);
+ const year = Math.max(1980, d.getFullYear());
+ return {
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
+ };
+}
+
+class Writer {
+ constructor(size) {
+ this.buf = new Uint8Array(size);
+ this.view = new DataView(this.buf.buffer);
+ this.off = 0;
+ }
+ u16(v) { this.view.setUint16(this.off, v, true); this.off += 2; return this; }
+ u32(v) { this.view.setUint32(this.off, v >>> 0, true); this.off += 4; return this; }
+ u64(v) {
+ this.view.setBigUint64(this.off, BigInt(v), true);
+ this.off += 8;
+ return this;
+ }
+ bytes(b) { this.buf.set(b, this.off); this.off += b.length; return this; }
+}
+
+export class ZipStream {
+ /**
+ * @param {(bytes: Uint8Array) => Promise<void>|void} sink where the archive goes
+ */
+ constructor(sink) {
+ this._sink = sink;
+ this._offset = 0; // bytes written so far — every entry's offset
+ this._entries = [];
+ this._current = null;
+ }
+
+ async _write(bytes) {
+ await this._sink(bytes);
+ this._offset += bytes.length;
+ }
+
+ /**
+ * Start a file. `size` is what the index says it will be; it decides whether
+ * this entry needs zip64, and nothing is trusted about it afterwards — the
+ * length actually written is what the archive records.
+ */
+ async begin(name, size = 0, mtime = new Date(), { forceZip64 = false } = {}) {
+ if (this._current) throw new Error('A file is already open in this archive');
+
+ const nameBytes = new TextEncoder().encode(name.replace(/\\/g, '/'));
+ const zip64 = forceZip64
+ || size >= ZIP64_THRESHOLD
+ || this._offset >= ZIP64_THRESHOLD;
+ const { time, date } = dosDateTime(mtime);
+
+ this._current = {
+ name: nameBytes, zip64, time, date,
+ offset: this._offset, crc: 0, size: 0,
+ };
+
+ const extraLen = zip64 ? 20 : 0;
+ const w = new Writer(30 + nameBytes.length + extraLen);
+ w.u32(LOCAL_SIG)
+ .u16(zip64 ? 45 : 20)
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
+ .u16(0) // stored
+ .u16(time).u16(date)
+ .u32(0) // crc — in the descriptor
+ .u32(zip64 ? U32_MAX : 0) // compressed size
+ .u32(zip64 ? U32_MAX : 0) // uncompressed size
+ .u16(nameBytes.length)
+ .u16(extraLen)
+ .bytes(nameBytes);
+ if (zip64) {
+ // Placeholders: the real values go in the descriptor. The field has to be
+ // here all the same, or a reader has no way to know the descriptor's
+ // sizes are 8 bytes wide.
+ w.u16(0x0001).u16(16).u64(0).u64(0);
+ }
+ await this._write(w.buf);
+ }
+
+ /** Feed the open file. Call as often as you like; nothing accumulates. */
+ async write(bytes) {
+ if (!this._current) throw new Error('No file is open in this archive');
+ if (!bytes.length) return;
+ this._current.crc = crc32(bytes, this._current.crc);
+ this._current.size += bytes.length;
+ await this._write(bytes);
+ }
+
+ /** Close the open file, writing what is now known about it. */
+ async end() {
+ if (!this._current) throw new Error('No file is open in this archive');
+ const e = this._current;
+ // A file that grew past 4 GiB after being announced smaller still has to be
+ // described correctly, and its local header said otherwise. Recording it as
+ // zip64 in the central directory is what readers go by.
+ if (e.size >= ZIP64_THRESHOLD) e.zip64 = true;
+
+ const w = new Writer(e.zip64 ? 24 : 16);
+ w.u32(DESC_SIG).u32(e.crc);
+ if (e.zip64) w.u64(e.size).u64(e.size);
+ else w.u32(e.size).u32(e.size);
+ await this._write(w.buf);
+
+ this._entries.push(e);
+ this._current = null;
+ }
+
+ /** Write the central directory and the end records. The archive is complete. */
+ async finish() {
+ if (this._current) throw new Error('A file is still open in this archive');
+
+ const centralStart = this._offset;
+ for (const e of this._entries) {
+ const needsZip64 = e.zip64 || e.offset >= ZIP64_THRESHOLD;
+ const extraLen = needsZip64 ? 32 : 0;
+ const w = new Writer(46 + e.name.length + extraLen);
+ w.u32(CENTRAL_SIG)
+ .u16(0x031e) // made by: UNIX, spec 3.0
+ .u16(needsZip64 ? 45 : 20)
+ .u16(FLAG_DATA_DESCRIPTOR | FLAG_UTF8)
+ .u16(0)
+ .u16(e.time).u16(e.date)
+ .u32(e.crc)
+ .u32(needsZip64 ? U32_MAX : e.size)
+ .u32(needsZip64 ? U32_MAX : e.size)
+ .u16(e.name.length)
+ .u16(extraLen)
+ .u16(0) // comment
+ .u16(0) // disk
+ .u16(0) // internal attrs
+ .u32(0o644 << 16) // external attrs: rw-r--r--
+ .u32(needsZip64 ? U32_MAX : e.offset)
+ .bytes(e.name);
+ if (needsZip64) w.u16(0x0001).u16(28).u64(e.size).u64(e.size).u64(e.offset).u32(0);
+ await this._write(w.buf);
+ }
+ const centralSize = this._offset - centralStart;
+
+ const archiveZip64 = centralStart >= ZIP64_THRESHOLD
+ || this._offset >= ZIP64_THRESHOLD
+ || this._entries.length > 0xffff;
+
+ if (archiveZip64) {
+ const z = new Writer(56 + 20);
+ z.u32(EOCD64_SIG).u64(44) // size of this record, less 12
+ .u16(0x031e).u16(45)
+ .u32(0).u32(0)
+ .u64(this._entries.length).u64(this._entries.length)
+ .u64(centralSize).u64(centralStart)
+ .u32(LOC64_SIG).u32(0).u64(centralStart + centralSize).u32(1);
+ await this._write(z.buf);
+ }
+
+ const count = Math.min(this._entries.length, 0xffff);
+ const w = new Writer(22);
+ w.u32(EOCD_SIG).u16(0).u16(0).u16(count).u16(count)
+ .u32(archiveZip64 ? U32_MAX : centralSize)
+ .u32(archiveZip64 ? U32_MAX : centralStart)
+ .u16(0);
+ await this._write(w.buf);
+ return this._offset;
+ }
+}
+
+/**
+ * Everything at or under `dir`, with the paths the archive should carry.
+ *
+ * `dir` is stripped from the front so an archive of "Holidays/2026" opens as
+ * "2026/…" rather than as a chain of empty parents.
+ */
+export function entriesUnder(entries, dir) {
+ const prefix = dir ? dir + '/' : '';
+ return entries
+ .filter(e => (e.path || '') === dir || (e.path || '').startsWith(prefix))
+ .map(e => {
+ const rest = (e.path || '').slice(dir.length).replace(/^\//, '');
+ const base = dir.split('/').pop() || 'files';
+ return { entry: e, name: [base, rest, e.name].filter(Boolean).join('/') };
+ });
+}