From 41e65761118c128d2e129b4b86d1dacc9b6909a3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 20:59:30 -0500 Subject: [PATCH] fix(zddc-server): canonical-folder fallback also at the dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix in fs.ListDirectory was insufficient — main.go's dispatcher calls os.Stat(absPath) before reaching ServeDirectory, and 404s on the missing path before the listing code ever runs. Symptom: GET /working/ on a fresh project still returned "Not Found" despite the read-side fallback being committed. Add the same fallback at the dispatcher level: when os.Stat returns NotExist AND the URL ends with "/" AND the path matches IsProjectRootFolder, fall through to ServeDirectory rather than 404. ServeDirectory's ACL check + ListDirectory's empty-listing behavior take it from there. Separately, fs.ListDirectory now initializes its result slice to make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`, so the JSON encoder emits "[]" rather than "null" for empty listings — clients (browse, archive) expect an array and choke on null. New test TestDispatchEmptyCanonicalProjectFolders covers the four canonical names (archive/working/staging/reviewing) on a project where none of them exist on disk yet, plus the negative case (a non-canonical missing path still 404s). Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 15 +++++++ zddc/cmd/zddc-server/main_test.go | 65 +++++++++++++++++++++++++++++++ zddc/internal/fs/tree.go | 4 +- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 51d0d87..2208797 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -841,6 +841,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } } + // Empty-listing fallback for canonical project-root folders. + // /{archive,working,staging,reviewing}/ that don't + // yet exist on disk should render as a usable empty + // directory view rather than 404, so the stage-strip nav + // links land on a real page on a fresh project. The + // matching read-side fallback in fs.ListDirectory returns + // 200 + [] for the same paths; we fall through to + // ServeDirectory which goes through that path and applies + // ACL via the parent project's .zddc cascade. + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && + strings.HasSuffix(urlPath, "/") && + zddc.IsProjectRootFolder(strings.TrimPrefix(strings.TrimSuffix(urlPath, "/"), "/")) { + handler.ServeDirectory(cfg, w, r) + return + } http.Error(w, "Not Found", http.StatusNotFound) } else { http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 4fed5e6..d17862c 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -466,6 +466,71 @@ func TestDispatchSlashRouting(t *testing.T) { } } +// Canonical project-root folders (archive/working/staging/reviewing) +// that don't yet exist on disk must render as 200 + empty listing +// rather than 404, so the stage-strip nav lands on a usable view on a +// fresh project. Mirror of fs.ListDirectory's read-side fallback at +// the dispatcher level — without this, os.Stat 404s before +// ServeDirectory ever runs. +func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"*\": rwcda\n") + // Project exists, but NO subdirs (no archive/, working/, staging/, + // reviewing/). Fresh-project state. + mustMkdir(t, filepath.Join(root, "Project")) + + idx, err := archive.BuildIndex(root) + if err != nil { + t.Fatalf("BuildIndex: %v", err) + } + cfg := config.Config{ + Root: root, + IndexPath: ".archive", + EmailHeader: "X-Auth-Request-Email", + } + ring := handler.NewLogRing(10) + appsSrv, err := setupApps(cfg) + if err != nil { + t.Fatalf("setupApps: %v", err) + } + + // JSON request → 200 + JSON array (not null, not 404). + // Virtual user-home injection at /working/ depends on a + // context-bound email; this test calls dispatch directly without + // the ACL middleware that sets that context, so email is "" here + // and working/ also returns []. virtualUserHomeEntry's email + // branch is covered separately by tests in internal/fs/tree_test.go. + for _, stage := range []string{"archive", "working", "staging", "reviewing"} { + t.Run("json/"+stage, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil) + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := strings.TrimSpace(rec.Body.String()) + if body != "[]" { + t.Errorf("%s/ body=%q, want %q", stage, body, "[]") + } + }) + } + + // Non-canonical missing folder still 404s (the fallback is + // scoped to the four canonical names, not a blanket "missing → + // empty" rule). + t.Run("non-canonical missing → 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil) + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, appsSrv, nil, rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status=%d, want 404", rec.Code) + } + }) +} + // TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on // any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file // API's write path, so a write to an archive URL never silently mutates diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index c658e03..aef6c2a 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -63,7 +63,9 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, } } - var result []listing.FileInfo + // Empty (not nil) so the JSON encoder emits [] rather than null + // when no entries match — clients (browse, archive) expect an array. + result := make([]listing.FileInfo, 0, len(entries)) for _, entry := range entries { name := entry.Name()