refactor: virtual file extensions for subtree zip + MD conversion

Replace `?zip=1` / `?convert=docx|html|pdf` query forms with path-suffix
URLs that look like ordinary files. `<dir>.zip` and `<file>.docx` /
`.html` / `.pdf` are virtual files served by the dispatcher when stat
fails at the requested path AND the corresponding base resource exists:

  GET /Project-1/archive.zip          ← if archive/ is a real directory
  GET /Project-1/notes.docx           ← if notes.md exists

Real on-disk files always win — a genuine archive.zip in the tree
serves its bytes normally. The virtual forms only fire when nothing
real is there.

Why: the URL form lets clients emit plain <a href> without query-
string handling; `curl -O` writes a sensible filename; mirror tools
pick up the path through normal recursion; the protocol surface
becomes "every URL is a file". Bash + filesystem mental model.

Server:
- New helpers handler.RecognizeVirtualSubtreeZip /
  RecognizeVirtualConvert (in subtreezip.go and converthandler.go).
- Dispatcher's stat-fails branch checks them between IsDefaultMdlSpec
  and MatchAppHTML. ACL is enforced on the base resource (the source
  directory for zip, the .md source for convert).
- Three legacy query-form branches removed from main.go.

Client:
- browse/js/download.js: `dir + '.zip'` instead of `dir + '/?zip=1'`.
- browse/js/preview-markdown.js: convert anchor hrefs become
  `<mdUrl-minus-.md>.<fmt>` instead of `<mdUrl>?convert=<fmt>`.
- shared/zddc-source.js downloadConverted: same transform.

Tests: subtreezip_test.go test URLs cosmetically updated to the new
shape (the handler is exercised directly, so the URL is metadata only,
but the test reads better).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-14 12:23:37 -05:00
parent 050902fa9e
commit 167a56dc07
8 changed files with 174 additions and 59 deletions

View file

@ -6,11 +6,12 @@
// reads bytes through the file handle and blob-downloads.
//
// downloadFolder: an arbitrary directory node as a .zip. Server
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
// streams an ACL-filtered archive without buffering on the client.
// FS-API mode walks the picked handle in two passes — metadata
// first, then bytes — so we can warn before loading a very large
// tree into memory.
// mode points an <a download> at the virtual "<node-path>.zip"
// URL — zddc-server recognises the suffix and streams an ACL-
// filtered archive without buffering on the client. FS-API mode
// walks the picked handle in two passes — metadata first, then
// bytes — so we can warn before loading a very large tree into
// memory.
(function () {
'use strict';
@ -142,10 +143,10 @@
}
}
// Download an arbitrary folder node as a .zip — same dispatch as
// downloadCurrentSubtree but scoped to the picked node instead of
// state.currentPath / state.rootHandle. Server mode hits
// "<node-path>/?zip=1"; FS mode walks the directory handle.
// Download an arbitrary folder node as a .zip. Server mode points
// an <a download> at the virtual "<node-path>.zip" URL (the
// dispatcher recognises the suffix and streams the subtree). FS
// mode walks the directory handle.
async function downloadFolder(node) {
if (busy) return;
if (!node || !node.isDir) {
@ -158,7 +159,7 @@
var tree = window.app.modules.tree;
var dir = tree.pathFor(node).replace(/\/$/, '');
events().statusInfo('Preparing ' + node.name + '.zip…');
downloadUrl(node.name + '.zip', dir + '/?zip=1');
downloadUrl(node.name + '.zip', dir + '.zip');
setTimeout(function () { events().statusClear(); }, 2500);
} else if (state.source === 'fs' && node.handle
&& node.handle.kind === 'directory') {

View file

@ -457,10 +457,16 @@
node.url && /\.md$/i.test(node.name);
var convertBtns = [];
if (serverModeMd) {
// Virtual-extension URLs: <file>.md → <file>.docx etc.
// The dispatcher recognises the sibling-extension pattern
// and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link
// gives a sensible "Save as <file>.docx" prompt.
var mdUrlBase = node.url.replace(/\.md$/i, '');
['docx', 'html', 'pdf'].forEach(function (fmt) {
var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download';
a.href = node.url + '?convert=' + encodeURIComponent(fmt);
a.href = mdUrlBase + '.' + fmt;
// target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's

View file

@ -395,9 +395,14 @@
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';

View file

@ -1027,12 +1027,44 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
}
// File doesn't exist at this path. If the URL matches one of
// the canonical app HTML names AND the request directory is
// one where that app is available (working/staging/incoming
// for classifier, staging for transmittal, anywhere for
// archive + browse, root only for landing), resolve via the
// apps subsystem.
// File doesn't exist at this path. Before falling through to
// app-HTML routing or 404, check the two virtual-file-extension
// shapes that ZDDC exposes through the listing convention:
//
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
// <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
// (replaces `<file>.md?convert=<fmt>`)
//
// Both fire ONLY when stat failed at the requested URL — a
// real file always wins. The path-suffix form lets clients
// emit a plain <a href> + lets `curl -O` produce the right
// filename, no query-string handling required.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeSubtreeZip(cfg, w, r, absDir)
return
}
if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeConverted(cfg, w, r, mdAbs, format, chain)
return
}
}
// If the URL matches one of the canonical app HTML names AND
// the request directory is one where that app is available
// (working/staging/incoming for classifier, staging for
// transmittal, anywhere for archive + browse, root only for
// landing), resolve via the apps subsystem.
if appsSrv != nil {
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
@ -1098,12 +1130,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
zddc.IsDeclaredPath(cfg.Root, absPath) {
if r.URL.Query().Has("zip") {
// Subtree download of a cascade-declared dir that
// doesn't exist on disk yet → an empty zip.
handler.ServeSubtreeZip(cfg, w, r, absPath)
return
}
// (Empty-subtree zip for cascade-declared paths is now
// handled by RecognizeVirtualSubtreeZip at the top of
// this branch — same handler, path-suffix grammar.)
if strings.HasSuffix(urlPath, "/") {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
@ -1135,15 +1164,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
}
// Subtree download: GET /dir/?zip=1 streams an application/zip of
// every readable file under this directory, ACL-filtered. Checked
// before the slash/no-slash routing so it works on both /dir and
// /dir/. Writes (PUT/DELETE/POST) never reach here — they're
// intercepted by the file API earlier — so this is GET/HEAD only.
if r.URL.Query().Has("zip") {
handler.ServeSubtreeZip(cfg, w, r, absPath)
return
}
// (Subtree download `GET /dir/?zip=1` retired in favour of
// `GET /dir.zip` — see RecognizeVirtualSubtreeZip handling at
// the top of the stat-fails branch above. Real directories
// stat-succeed here, so the virtual zip URL stat-fails at
// /dir.zip and matches there.)
// Slash/no-slash routing convention: trailing slash → the
// directory view (handler.ServeDirectory → DirTool, which
// resolves to browse by default; JSON requests always get the
@ -1190,15 +1216,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
// source file's read policy (already gated above), so no separate
// ACL verb. Only .md sources are convertible; everything else falls
// through to the regular file serve.
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
strings.HasSuffix(strings.ToLower(absPath), ".md") {
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
return
}
// (MD→{docx,html,pdf} on-demand conversion now lives at
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
// see RecognizeVirtualConvert). The .md source serves
// normally here.)
handler.ServeFile(w, r, absPath)
}

View file

@ -18,13 +18,19 @@ import (
// On-demand MD→{docx,html,pdf} conversion endpoint.
//
// GET /<path>/foo.md?convert=docx|html|pdf
// GET /<path>/foo.docx (or .html / .pdf)
//
// The source file's read policy (already enforced by the dispatcher
// before this handler runs) gates the response. The converted bytes
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
// source — so a fast-path GET that finds a fresh cache hit serves the
// disk file via http.ServeContent without invoking pandoc at all.
// The URL is the rendered form of a sibling `foo.md` source. The
// dispatcher recognises the pattern via RecognizeVirtualConvert when
// a stat on `foo.docx` (etc.) fails AND `foo.md` exists; only then is
// ServeConverted invoked. A real on-disk `foo.docx` wins precedence
// and serves its bytes normally.
//
// The source file's read policy (enforced by the dispatcher before this
// handler runs) gates the response. The converted bytes are cached at
// <dir>/.converted/<base>.<ext>, with mtime synced to the source — so a
// fast-path GET that finds a fresh cache hit serves the disk file via
// http.ServeContent without invoking pandoc at all.
//
// When the cache is stale (or absent) the handler:
// 1. Reads source bytes.
@ -42,6 +48,40 @@ var convertSF singleflightGroup
// runner itself enforces a finer-grained timeout on the container.
const convertTimeout = 90 * time.Second
// RecognizeVirtualConvert reports whether urlPath names a virtual
// "<file>.<format>" — a rendered form of a sibling markdown source.
// Returns (mdAbsPath, format, true) when <file>.md exists on disk and
// the requested extension is one of docx / html / pdf. The caller
// (the dispatcher) only invokes this when a stat on the requested
// path itself fails — a real on-disk file always wins.
//
// The path-suffix grammar replaces the legacy `<file>.md?convert=docx`
// query form. A virtual file URL means `<a href="…/foo.docx">` works
// without any query-string handling, and a script's `curl -O …/foo.pdf`
// writes the expected filename.
func RecognizeVirtualConvert(fsRoot, urlPath string) (mdAbs, format string, ok bool) {
lower := strings.ToLower(urlPath)
for _, ext := range []string{".docx", ".html", ".pdf"} {
if !strings.HasSuffix(lower, ext) {
continue
}
base := urlPath[:len(urlPath)-len(ext)]
if base == "" || strings.HasSuffix(base, "/") {
continue
}
rel := strings.Trim(base, "/") + ".md"
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
continue
}
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
return abs, ext[1:], true
}
}
return "", "", false
}
// ServeConverted is the entry point. format is the requested target
// extension; chain is the already-resolved ACL chain (re-used here
// only to extract the convert: cascade metadata).

View file

@ -35,9 +35,46 @@ func zipMethodFor(name string) uint16 {
return zip.Deflate
}
// RecognizeVirtualSubtreeZip reports whether urlPath names a virtual
// "<dir>.zip" — a download endpoint that streams a directory's
// subtree as a zip. Returns the directory's absolute path when the
// URL strips to a real directory under fsRoot, or to a cascade-
// declared path that the listing pipeline would render as empty.
//
// The path-suffix grammar replaces the legacy `<dir>/?zip=1` query
// form. A virtual file living next to its source means clients can
// emit a plain `<a href>` without query-string handling; mirror
// tools pick it up via normal recursion; `curl -O` writes a sensible
// filename without a `--remote-header-name` hint. Real `.zip` files
// in the tree always win — stat is checked before this helper, so a
// genuine archive at `<path>.zip` serves its bytes normally.
func RecognizeVirtualSubtreeZip(fsRoot, urlPath string) (absDir string, ok bool) {
if !strings.HasSuffix(urlPath, ".zip") {
return "", false
}
base := strings.TrimSuffix(urlPath, ".zip")
if base == "" || base == "/" {
return "", false
}
rel := strings.Trim(base, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", false
}
if info, err := os.Stat(abs); err == nil && info.IsDir() {
return abs, true
}
if zddc.IsDeclaredPath(fsRoot, abs) {
return abs, true
}
return "", false
}
// ServeSubtreeZip streams an application/zip download of every readable
// file under absDir (recursively), ACL-filtered against the requester.
// It's the handler behind `GET /some/dir/?zip=1`.
// Invoked from the dispatcher when RecognizeVirtualSubtreeZip matches
// the request URL.
//
// Permissions: each file is gated by the .zddc chain of its containing
// directory (cached per directory), exactly like serveArchiveListing.

View file

@ -74,7 +74,7 @@ func TestServeSubtreeZip(t *testing.T) {
cfg := config.Config{Root: root}
t.Run("headers + ACL-filtered contents", func(t *testing.T) {
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "bob@x")
req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "bob@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, sub)
@ -123,7 +123,7 @@ func TestServeSubtreeZip(t *testing.T) {
})
t.Run("owner sees the locked subdir too", func(t *testing.T) {
req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "owner@x")
req := subtreeReq(t, http.MethodGet, "/Proj/sub.zip", "owner@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, sub)
got := readZipResponse(t, rec)
@ -136,7 +136,7 @@ func TestServeSubtreeZip(t *testing.T) {
})
t.Run("HEAD sets headers, no body", func(t *testing.T) {
req := subtreeReq(t, http.MethodHead, "/Proj/sub/?zip=1", "bob@x")
req := subtreeReq(t, http.MethodHead, "/Proj/sub.zip", "bob@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, sub)
if rec.Code != http.StatusOK {
@ -151,7 +151,7 @@ func TestServeSubtreeZip(t *testing.T) {
})
t.Run("method not allowed", func(t *testing.T) {
req := subtreeReq(t, http.MethodPost, "/Proj/sub/?zip=1", "bob@x")
req := subtreeReq(t, http.MethodPost, "/Proj/sub.zip", "bob@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, sub)
if rec.Code != http.StatusMethodNotAllowed {
@ -169,7 +169,7 @@ func TestServeSubtreeZip_AllDenied(t *testing.T) {
mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")
cfg := config.Config{Root: root}
req := subtreeReq(t, http.MethodGet, "/Proj/locked/?zip=1", "bob@x")
req := subtreeReq(t, http.MethodGet, "/Proj/locked.zip", "bob@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, locked)
if rec.Code != http.StatusOK {
@ -186,7 +186,7 @@ func TestServeSubtreeZip_Nonexistent(t *testing.T) {
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
cfg := config.Config{Root: root}
missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk
req := subtreeReq(t, http.MethodGet, "/Proj/working/?zip=1", "bob@x")
req := subtreeReq(t, http.MethodGet, "/Proj/working.zip", "bob@x")
rec := httptest.NewRecorder()
ServeSubtreeZip(cfg, rec, req, missing)
if rec.Code != http.StatusOK {

View file

@ -1375,7 +1375,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-14 17:07:00 · a62960b-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-14 17:23:02 · 050902f-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -2322,9 +2322,14 @@ body.help-open .app-header {
// srcUrl points at the .md source on the server. fmt is one of
// "docx" | "html" | "pdf". The server response status maps to a
// friendly error message for the caller to surface (toast / status).
//
// URL grammar: srcUrl is the `<file>.md` source; the converted
// form lives at `<file>.<fmt>` (virtual file extension recognised
// by zddc-server's dispatcher). Replaces the older `?convert=`
// query form.
async function downloadConverted(srcUrl, fileName, fmt) {
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
{ credentials: 'same-origin' });
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
if (!resp.ok) {
var msg;
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';