feat(zddc-server): no-slash canonical folders → default tool, even

when missing on disk

Mirror of the existing IsDir-branch behavior at line 873
(<project>/working → mdedit, <project>/staging → transmittal,
<project>/archive → archive) for the case where the folder doesn't
exist on disk yet. Without this, GET <project>/working on a fresh
project 404s instead of opening mdedit rooted at the (virtual)
working directory.

Behavior matrix for canonical project-root folders that don't yet
exist on disk:

  GET <project>/archive    → archive tool (project-root mode)
  GET <project>/archive/   → empty browse listing
  GET <project>/working    → mdedit rooted at working/
  GET <project>/working/   → empty browse listing (with synthetic
                             <viewer-email>/ home entry)
  GET <project>/staging    → transmittal rooted at staging/
  GET <project>/staging/   → empty browse listing
  GET <project>/reviewing  → 301 to /reviewing/ (no default app)
  GET <project>/reviewing/ → empty browse listing
  GET <project>/random     → 404 (still — non-canonical)
  GET <project>/random/    → 404 (still — non-canonical)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-09 21:13:10 -05:00
parent 03babd34d2
commit b3cea9b7a8
2 changed files with 72 additions and 11 deletions

View file

@ -841,18 +841,38 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
}
}
}
// Empty-listing fallback for canonical project-root folders.
// <project>/{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.
// Canonical project-root folder fallback. <project>/{archive,
// working,staging,reviewing}[/] should land on a usable view
// (default tool or empty listing) rather than 404, so the
// stage-strip nav works on a fresh project that hasn't yet
// been written to. Two shapes:
//
// <project>/working → mdedit rooted at working/
// (matches the existing IsDir branch
// for an existing folder)
// <project>/working/ → ServeDirectory → fs.ListDirectory
// returns 200 + [] for the empty case
//
// reviewing/ has no default app, so the no-slash form 301s
// to the slash form.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
strings.HasSuffix(urlPath, "/") &&
zddc.IsProjectRootFolder(strings.TrimPrefix(strings.TrimSuffix(urlPath, "/"), "/")) {
zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
if !strings.HasSuffix(urlPath, "/") {
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
// No default app (reviewing/) — redirect to slash form.
http.Redirect(w, r, urlPath+"/", http.StatusMovedPermanently)
return
}
handler.ServeDirectory(cfg, w, r)
return
}

View file

@ -517,6 +517,47 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
})
}
// No-trailing-slash form on a canonical folder → default app
// (mdedit for working/, transmittal for staging/, archive for
// archive/). Mirror of the existing "no-slash → default app"
// behavior at the IsDir branch, extended to cover the case where
// the folder doesn't exist on disk yet.
noSlashDefaultApp := []struct {
stage string
expect string // substring that should appear in the response body
}{
{"working", "ZDDC Markdown"},
{"staging", "ZDDC Transmittal"},
{"archive", "ZDDC Archive"},
}
for _, tc := range noSlashDefaultApp {
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/"+tc.stage, nil)
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())
}
if !strings.Contains(rec.Body.String(), tc.expect) {
t.Errorf("%s/ body missing %q", tc.stage, tc.expect)
}
})
}
// reviewing/ has no default tool — no-slash form should 301 to
// the slash form (which then renders the empty listing).
t.Run("no-slash/reviewing → 301 to slash", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/Project/reviewing", nil)
rec := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Errorf("status=%d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/Project/reviewing/" {
t.Errorf("Location=%q, want %q", loc, "/Project/reviewing/")
}
})
// Non-canonical missing folder still 404s (the fallback is
// scoped to the four canonical names, not a blanket "missing →
// empty" rule).