diff --git a/browse/js/download.js b/browse/js/download.js index 5dc4171..24be58d 100644 --- a/browse/js/download.js +++ b/browse/js/download.js @@ -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 at "/?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 at the virtual ".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 - // "/?zip=1"; FS mode walks the directory handle. + // Download an arbitrary folder node as a .zip. Server mode points + // an at the virtual ".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') { diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 81cf7fb..fc4a996 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -457,10 +457,16 @@ node.url && /\.md$/i.test(node.name); var convertBtns = []; if (serverModeMd) { + // Virtual-extension URLs: .md → .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 .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 diff --git a/shared/zddc-source.js b/shared/zddc-source.js index c550452..8b90f3c 100644 --- a/shared/zddc-source.js +++ b/shared/zddc-source.js @@ -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 `.md` source; the converted + // form lives at `.` (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.'; diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 985c86b..8b586ae 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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: + // + // .zip — subtree download (replaces `/?zip=1`) + // .docx|html|pdf — MD-source conversion of sibling .md + // (replaces `.md?convert=`) + // + // Both fire ONLY when stat failed at the requested URL — a + // real file always wins. The path-suffix form lets clients + // emit a plain + 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 //.{docx,html,pdf}` (virtual file URL, + // see RecognizeVirtualConvert). The .md source serves + // normally here.) handler.ServeFile(w, r, absPath) } diff --git a/zddc/internal/handler/converthandler.go b/zddc/internal/handler/converthandler.go index 95ae18b..69b6baa 100644 --- a/zddc/internal/handler/converthandler.go +++ b/zddc/internal/handler/converthandler.go @@ -18,13 +18,19 @@ import ( // On-demand MD→{docx,html,pdf} conversion endpoint. // -// GET //foo.md?convert=docx|html|pdf +// GET //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 /.converted/., 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 +// /.converted/., 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 +// "." — a rendered form of a sibling markdown source. +// Returns (mdAbsPath, format, true) when .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 `.md?convert=docx` +// query form. A virtual file URL means `` 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). diff --git a/zddc/internal/handler/subtreezip.go b/zddc/internal/handler/subtreezip.go index 9560aa1..6a058a2 100644 --- a/zddc/internal/handler/subtreezip.go +++ b/zddc/internal/handler/subtreezip.go @@ -35,9 +35,46 @@ func zipMethodFor(name string) uint16 { return zip.Deflate } +// RecognizeVirtualSubtreeZip reports whether urlPath names a virtual +// ".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 `/?zip=1` query +// form. A virtual file living next to its source means clients can +// emit a plain `` 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 `.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. diff --git a/zddc/internal/handler/subtreezip_test.go b/zddc/internal/handler/subtreezip_test.go index a638065..8defd38 100644 --- a/zddc/internal/handler/subtreezip_test.go +++ b/zddc/internal/handler/subtreezip_test.go @@ -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 { diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 96aa3be..7b79155 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1375,7 +1375,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-14 17:07:00 · a62960b-dirty + v0.0.17-alpha · 2026-05-14 17:23:02 · 050902f-dirty
@@ -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 `.md` source; the converted + // form lives at `.` (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.';