diff --git a/AGENTS.md b/AGENTS.md index c02c28f..85a4d06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -493,6 +493,8 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \ **`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip. +**`GET /dir/?zip=1` — subtree download.** Streams an `application/zip` of every readable file under `/dir/` (recursively), `Content-Disposition: attachment; filename=".zip"`, `X-ZDDC-Source: subtree-zip`. ACL-filtered per file's containing-dir `.zddc` chain (per-dir decision cache, same as `serveArchiveListing`); skips `.`/`_`-prefixed entries (`.zddc`, `_template`, `_app`); adds a `.zip` *file* it meets as opaque bytes (does not recurse). Streamed, so an empty/fully-denied subtree is a valid empty zip, not a 403. The query check is in `dispatch`'s `info.IsDir()` branch right after the directory ACL gate (so it works on `/dir` and `/dir/`); code: `handler.ServeSubtreeZip`. The browse tool's toolbar "Download (zip)" button uses it in server mode; offline it bundles the picked folder with JSZip (`confirm()` above ~2000 files / ~500 MB). + ### Client mode (proxy / cache / mirror) When `--upstream ` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 534f1c6..77041f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -713,6 +713,8 @@ The schema keys that drive built-in behavior: **Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`. +**Subtree download.** `GET /some/dir/?zip=1` (the query form works on both `/dir` and `/dir/`) streams an `application/zip` of every readable file under that directory, recursively — `Content-Disposition: attachment; filename=".zip"`. It's `handler.ServeSubtreeZip`: a `filepath.WalkDir` that ACL-gates each file by the `.zddc` chain of its containing directory (the same per-directory decision cache `serveArchiveListing` uses), skips hidden entries (`.`/`_`-prefixed: `.zddc`, `_template`, `_app`), and adds any `.zip` *file* it meets as opaque bytes (it does **not** recurse into it — that's the navigable-surface above, a different feature). The response is streamed straight onto the `ResponseWriter` (`zip.Store` for already-compressed extensions, `zip.Deflate` otherwise), so a fully-ACL-denied or empty subtree yields a valid empty zip rather than a 403 (a stream can't change status after the headers go out). The browse tool's toolbar **Download (zip)** button hits this for the directory in view in server mode; offline (file://) it walks the picked folder itself with JSZip (with a `confirm()` above ~2000 files / ~500 MB, since the whole tree is buffered in browser memory). + **WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive//{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change. **Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working//` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule). diff --git a/browse/build.sh b/browse/build.sh index b53377b..65243b8 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -56,6 +56,7 @@ concat_files \ "js/preview-markdown.js" \ "js/grid.js" \ "js/upload.js" \ + "js/download.js" \ "js/events.js" \ "js/app.js" \ > "$js_raw" diff --git a/browse/js/download.js b/browse/js/download.js new file mode 100644 index 0000000..ed3334d --- /dev/null +++ b/browse/js/download.js @@ -0,0 +1,141 @@ +// download.js — "Download (zip)" for the currently-viewed directory. +// +// Server mode: just point an at "?zip=1" — +// zddc-server streams an ACL-filtered .zip of the subtree, so nothing +// is held in the browser. +// +// FS-API (offline) mode: there's no server, so we walk the picked +// folder ourselves, bundle every file with JSZip, and download the +// blob. A two-pass walk (metadata first, then bytes) lets us warn +// before loading a very large tree into memory. +(function () { + 'use strict'; + + var state = window.app.state; + + // Soft thresholds for the offline bundle: above either, confirm() + // before loading everything into memory. + var WARN_FILE_COUNT = 2000; + var WARN_TOTAL_BYTES = 500 * 1024 * 1024; + + function events() { return window.app.modules.events; } + + function isHiddenName(name) { + return name.length === 0 || name[0] === '.' || name[0] === '_'; + } + + function fmtMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } + + // Trigger a browser download of a Blob (revokes the object URL after). + function downloadBlob(filename, blob) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(function () { + URL.revokeObjectURL(a.href); + a.remove(); + }, 0); + } + + // Trigger a download from a same-origin server URL via Content-Disposition. + function downloadUrl(filename, url) { + var a = document.createElement('a'); + a.href = url; + a.download = filename; // hint; the server's Content-Disposition wins + document.body.appendChild(a); + a.click(); + setTimeout(function () { a.remove(); }, 0); + } + + // Recursively collect every (non-hidden) file under dirHandle into + // `out` as { relPath, handle, size }, accumulating into `tally`. + // relPrefix is the slash-terminated path within the picked root + // ("" at the root). + async function collectFiles(dirHandle, relPrefix, out, tally) { + for await (var pair of dirHandle.entries()) { + var name = pair[0]; + var handle = pair[1]; + if (isHiddenName(name)) continue; + if (handle.kind === 'directory') { + await collectFiles(handle, relPrefix + name + '/', out, tally); + } else { + var size = 0; + try { + var f = await handle.getFile(); + size = f.size || 0; + } catch (_e) { /* permission lost — count it as 0 */ } + out.push({ relPath: relPrefix + name, handle: handle, size: size }); + tally.count++; + tally.bytes += size; + } + } + } + + async function downloadFsSubtree(rootHandle) { + var ev = events(); + ev.statusInfo('Scanning ' + rootHandle.name + '…'); + var files = []; + var tally = { count: 0, bytes: 0 }; + await collectFiles(rootHandle, '', files, tally); + if (files.length === 0) { + ev.statusInfo(rootHandle.name + ' is empty — nothing to download.'); + return; + } + if (tally.count > WARN_FILE_COUNT || tally.bytes > WARN_TOTAL_BYTES) { + var ok = window.confirm( + 'This folder has ' + tally.count + ' files (~' + fmtMB(tally.bytes) + ').\n\n' + + 'Building the zip loads them all into memory — it may be slow or crash the tab.\n\n' + + 'Continue?'); + if (!ok) { ev.statusClear(); return; } + } + var zip = new window.JSZip(); + for (var i = 0; i < files.length; i++) { + ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')'); + var f = await files[i].handle.getFile(); + var buf = await f.arrayBuffer(); + zip.file(rootHandle.name + '/' + files[i].relPath, buf); + } + ev.statusInfo('Generating ' + rootHandle.name + '.zip…'); + var blob = await zip.generateAsync({ type: 'blob' }); + downloadBlob(rootHandle.name + '.zip', blob); + ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)'); + } + + function downloadServerSubtree() { + var dir = (state.currentPath || '/').replace(/\/$/, ''); + var name = (dir.split('/').filter(Boolean).pop()) || 'download'; + events().statusInfo('Preparing ' + name + '.zip…'); + downloadUrl(name + '.zip', dir + '/?zip=1'); + // The browser owns the download from here; clear the hint shortly. + setTimeout(function () { events().statusClear(); }, 2500); + } + + var busy = false; + + async function downloadCurrentSubtree() { + if (busy) return; + var btn = document.getElementById('downloadZipBtn'); + busy = true; + if (btn) btn.disabled = true; + try { + if (state.source === 'server') { + downloadServerSubtree(); + } else if (state.source === 'fs' && state.rootHandle) { + await downloadFsSubtree(state.rootHandle); + } else { + events().statusError('Nothing to download — open a directory first.'); + } + } catch (e) { + events().statusError('Download failed: ' + (e && e.message ? e.message : e)); + } finally { + busy = false; + if (btn) btn.disabled = false; + } + } + + window.app.modules.download = { + downloadCurrentSubtree: downloadCurrentSubtree + }; +})(); diff --git a/browse/js/events.js b/browse/js/events.js index af93d20..02a0c61 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -69,6 +69,7 @@ function applySourceUI() { var add = document.getElementById('addDirectoryBtn'); var refresh = document.getElementById('refreshHeaderBtn'); + var dlZip = document.getElementById('downloadZipBtn'); if (add) { if (state.source === 'server') { add.classList.remove('btn-primary'); @@ -85,6 +86,15 @@ refresh.classList.add('hidden'); } } + // "Download (zip)" is meaningful once a directory is loaded + // (server or local); it zips the directory currently in view. + if (dlZip) { + if (state.source) { + dlZip.classList.remove('hidden'); + } else { + dlZip.classList.add('hidden'); + } + } } async function refreshListing() { @@ -122,6 +132,12 @@ var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); + var dlZip = document.getElementById('downloadZipBtn'); + if (dlZip) dlZip.addEventListener('click', function () { + var d = window.app.modules.download; + if (d) d.downloadCurrentSubtree(); + }); + // Sort dropdown — change → tree re-renders with the new sort. // Format of option value: ":". Defaults match // state.sort initial values (name:asc). diff --git a/browse/template.html b/browse/template.html index 27f49a3..148665d 100644 --- a/browse/template.html +++ b/browse/template.html @@ -55,6 +55,9 @@
+