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:
parent
050902fa9e
commit
167a56dc07
8 changed files with 174 additions and 59 deletions
|
|
@ -6,11 +6,12 @@
|
||||||
// reads bytes through the file handle and blob-downloads.
|
// reads bytes through the file handle and blob-downloads.
|
||||||
//
|
//
|
||||||
// downloadFolder: an arbitrary directory node as a .zip. Server
|
// downloadFolder: an arbitrary directory node as a .zip. Server
|
||||||
// mode points an <a download> at "<node-path>/?zip=1" so zddc-server
|
// mode points an <a download> at the virtual "<node-path>.zip"
|
||||||
// streams an ACL-filtered archive without buffering on the client.
|
// URL — zddc-server recognises the suffix and streams an ACL-
|
||||||
// FS-API mode walks the picked handle in two passes — metadata
|
// filtered archive without buffering on the client. FS-API mode
|
||||||
// first, then bytes — so we can warn before loading a very large
|
// walks the picked handle in two passes — metadata first, then
|
||||||
// tree into memory.
|
// bytes — so we can warn before loading a very large tree into
|
||||||
|
// memory.
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -142,10 +143,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download an arbitrary folder node as a .zip — same dispatch as
|
// Download an arbitrary folder node as a .zip. Server mode points
|
||||||
// downloadCurrentSubtree but scoped to the picked node instead of
|
// an <a download> at the virtual "<node-path>.zip" URL (the
|
||||||
// state.currentPath / state.rootHandle. Server mode hits
|
// dispatcher recognises the suffix and streams the subtree). FS
|
||||||
// "<node-path>/?zip=1"; FS mode walks the directory handle.
|
// mode walks the directory handle.
|
||||||
async function downloadFolder(node) {
|
async function downloadFolder(node) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
if (!node || !node.isDir) {
|
if (!node || !node.isDir) {
|
||||||
|
|
@ -158,7 +159,7 @@
|
||||||
var tree = window.app.modules.tree;
|
var tree = window.app.modules.tree;
|
||||||
var dir = tree.pathFor(node).replace(/\/$/, '');
|
var dir = tree.pathFor(node).replace(/\/$/, '');
|
||||||
events().statusInfo('Preparing ' + node.name + '.zip…');
|
events().statusInfo('Preparing ' + node.name + '.zip…');
|
||||||
downloadUrl(node.name + '.zip', dir + '/?zip=1');
|
downloadUrl(node.name + '.zip', dir + '.zip');
|
||||||
setTimeout(function () { events().statusClear(); }, 2500);
|
setTimeout(function () { events().statusClear(); }, 2500);
|
||||||
} else if (state.source === 'fs' && node.handle
|
} else if (state.source === 'fs' && node.handle
|
||||||
&& node.handle.kind === 'directory') {
|
&& node.handle.kind === 'directory') {
|
||||||
|
|
|
||||||
|
|
@ -457,10 +457,16 @@
|
||||||
node.url && /\.md$/i.test(node.name);
|
node.url && /\.md$/i.test(node.name);
|
||||||
var convertBtns = [];
|
var convertBtns = [];
|
||||||
if (serverModeMd) {
|
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) {
|
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.className = 'btn btn-sm btn-secondary md-shell__download';
|
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
|
// target=_blank: clicks open in a new tab. The server
|
||||||
// sends Content-Disposition: inline, so the new tab
|
// sends Content-Disposition: inline, so the new tab
|
||||||
// either renders (HTML → web page; PDF → browser's
|
// either renders (HTML → web page; PDF → browser's
|
||||||
|
|
|
||||||
|
|
@ -395,9 +395,14 @@
|
||||||
// srcUrl points at the .md source on the server. fmt is one of
|
// srcUrl points at the .md source on the server. fmt is one of
|
||||||
// "docx" | "html" | "pdf". The server response status maps to a
|
// "docx" | "html" | "pdf". The server response status maps to a
|
||||||
// friendly error message for the caller to surface (toast / status).
|
// 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) {
|
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
var msg;
|
var msg;
|
||||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||||
|
|
|
||||||
|
|
@ -1027,12 +1027,44 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// File doesn't exist at this path. If the URL matches one of
|
// File doesn't exist at this path. Before falling through to
|
||||||
// the canonical app HTML names AND the request directory is
|
// app-HTML routing or 404, check the two virtual-file-extension
|
||||||
// one where that app is available (working/staging/incoming
|
// shapes that ZDDC exposes through the listing convention:
|
||||||
// for classifier, staging for transmittal, anywhere for
|
//
|
||||||
// archive + browse, root only for landing), resolve via the
|
// <dir>.zip — subtree download (replaces `<dir>/?zip=1`)
|
||||||
// apps subsystem.
|
// <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 appsSrv != nil {
|
||||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
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) &&
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
|
(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
|
||||||
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
zddc.IsDeclaredPath(cfg.Root, absPath) {
|
||||||
if r.URL.Query().Has("zip") {
|
// (Empty-subtree zip for cascade-declared paths is now
|
||||||
// Subtree download of a cascade-declared dir that
|
// handled by RecognizeVirtualSubtreeZip at the top of
|
||||||
// doesn't exist on disk yet → an empty zip.
|
// this branch — same handler, path-suffix grammar.)
|
||||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(urlPath, "/") {
|
if strings.HasSuffix(urlPath, "/") {
|
||||||
handler.ServeDirectory(cfg, appsSrv, w, r)
|
handler.ServeDirectory(cfg, appsSrv, w, r)
|
||||||
return
|
return
|
||||||
|
|
@ -1135,15 +1164,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Subtree download: GET /dir/?zip=1 streams an application/zip of
|
// (Subtree download `GET /dir/?zip=1` retired in favour of
|
||||||
// every readable file under this directory, ACL-filtered. Checked
|
// `GET /dir.zip` — see RecognizeVirtualSubtreeZip handling at
|
||||||
// before the slash/no-slash routing so it works on both /dir and
|
// the top of the stat-fails branch above. Real directories
|
||||||
// /dir/. Writes (PUT/DELETE/POST) never reach here — they're
|
// stat-succeed here, so the virtual zip URL stat-fails at
|
||||||
// intercepted by the file API earlier — so this is GET/HEAD only.
|
// /dir.zip and matches there.)
|
||||||
if r.URL.Query().Has("zip") {
|
|
||||||
handler.ServeSubtreeZip(cfg, w, r, absPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Slash/no-slash routing convention: trailing slash → the
|
// Slash/no-slash routing convention: trailing slash → the
|
||||||
// directory view (handler.ServeDirectory → DirTool, which
|
// directory view (handler.ServeDirectory → DirTool, which
|
||||||
// resolves to browse by default; JSON requests always get the
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
|
// (MD→{docx,html,pdf} on-demand conversion now lives at
|
||||||
// source file's read policy (already gated above), so no separate
|
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
|
||||||
// ACL verb. Only .md sources are convertible; everything else falls
|
// see RecognizeVirtualConvert). The .md source serves
|
||||||
// through to the regular file serve.
|
// normally here.)
|
||||||
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
|
|
||||||
strings.HasSuffix(strings.ToLower(absPath), ".md") {
|
|
||||||
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.ServeFile(w, r, absPath)
|
handler.ServeFile(w, r, absPath)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,19 @@ import (
|
||||||
|
|
||||||
// On-demand MD→{docx,html,pdf} conversion endpoint.
|
// 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
|
// The URL is the rendered form of a sibling `foo.md` source. The
|
||||||
// before this handler runs) gates the response. The converted bytes
|
// dispatcher recognises the pattern via RecognizeVirtualConvert when
|
||||||
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
|
// a stat on `foo.docx` (etc.) fails AND `foo.md` exists; only then is
|
||||||
// source — so a fast-path GET that finds a fresh cache hit serves the
|
// ServeConverted invoked. A real on-disk `foo.docx` wins precedence
|
||||||
// disk file via http.ServeContent without invoking pandoc at all.
|
// 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:
|
// When the cache is stale (or absent) the handler:
|
||||||
// 1. Reads source bytes.
|
// 1. Reads source bytes.
|
||||||
|
|
@ -42,6 +48,40 @@ var convertSF singleflightGroup
|
||||||
// runner itself enforces a finer-grained timeout on the container.
|
// runner itself enforces a finer-grained timeout on the container.
|
||||||
const convertTimeout = 90 * time.Second
|
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
|
// ServeConverted is the entry point. format is the requested target
|
||||||
// extension; chain is the already-resolved ACL chain (re-used here
|
// extension; chain is the already-resolved ACL chain (re-used here
|
||||||
// only to extract the convert: cascade metadata).
|
// only to extract the convert: cascade metadata).
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,46 @@ func zipMethodFor(name string) uint16 {
|
||||||
return zip.Deflate
|
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
|
// ServeSubtreeZip streams an application/zip download of every readable
|
||||||
// file under absDir (recursively), ACL-filtered against the requester.
|
// 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
|
// Permissions: each file is gated by the .zddc chain of its containing
|
||||||
// directory (cached per directory), exactly like serveArchiveListing.
|
// directory (cached per directory), exactly like serveArchiveListing.
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
||||||
cfg := config.Config{Root: root}
|
cfg := config.Config{Root: root}
|
||||||
|
|
||||||
t.Run("headers + ACL-filtered contents", func(t *testing.T) {
|
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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, sub)
|
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) {
|
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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, sub)
|
ServeSubtreeZip(cfg, rec, req, sub)
|
||||||
got := readZipResponse(t, rec)
|
got := readZipResponse(t, rec)
|
||||||
|
|
@ -136,7 +136,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("HEAD sets headers, no body", func(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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, sub)
|
ServeSubtreeZip(cfg, rec, req, sub)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -151,7 +151,7 @@ func TestServeSubtreeZip(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("method not allowed", func(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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, sub)
|
ServeSubtreeZip(cfg, rec, req, sub)
|
||||||
if rec.Code != http.StatusMethodNotAllowed {
|
if rec.Code != http.StatusMethodNotAllowed {
|
||||||
|
|
@ -169,7 +169,7 @@ func TestServeSubtreeZip_AllDenied(t *testing.T) {
|
||||||
mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")
|
mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y")
|
||||||
|
|
||||||
cfg := config.Config{Root: root}
|
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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, locked)
|
ServeSubtreeZip(cfg, rec, req, locked)
|
||||||
if rec.Code != http.StatusOK {
|
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")
|
mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n")
|
||||||
cfg := config.Config{Root: root}
|
cfg := config.Config{Root: root}
|
||||||
missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk
|
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()
|
rec := httptest.NewRecorder()
|
||||||
ServeSubtreeZip(cfg, rec, req, missing)
|
ServeSubtreeZip(cfg, rec, req, missing)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
|
||||||
|
|
@ -1375,7 +1375,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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>
|
</div>
|
||||||
<div class="header-right">
|
<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
|
// srcUrl points at the .md source on the server. fmt is one of
|
||||||
// "docx" | "html" | "pdf". The server response status maps to a
|
// "docx" | "html" | "pdf". The server response status maps to a
|
||||||
// friendly error message for the caller to surface (toast / status).
|
// 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) {
|
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
var convertUrl = srcUrl.replace(/\.md$/i, '') + '.' + fmt;
|
||||||
{ credentials: 'same-origin' });
|
var resp = await fetch(convertUrl, { credentials: 'same-origin' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
var msg;
|
var msg;
|
||||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue