diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
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('/') }; + }); +} |