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:
parent
03babd34d2
commit
b3cea9b7a8
2 changed files with 72 additions and 11 deletions
|
|
@ -841,18 +841,38 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Empty-listing fallback for canonical project-root folders.
|
// Canonical project-root folder fallback. <project>/{archive,
|
||||||
// <project>/{archive,working,staging,reviewing}/ that don't
|
// working,staging,reviewing}[/] should land on a usable view
|
||||||
// yet exist on disk should render as a usable empty
|
// (default tool or empty listing) rather than 404, so the
|
||||||
// directory view rather than 404, so the stage-strip nav
|
// stage-strip nav works on a fresh project that hasn't yet
|
||||||
// links land on a real page on a fresh project. The
|
// been written to. Two shapes:
|
||||||
// matching read-side fallback in fs.ListDirectory returns
|
//
|
||||||
// 200 + [] for the same paths; we fall through to
|
// <project>/working → mdedit rooted at working/
|
||||||
// ServeDirectory which goes through that path and applies
|
// (matches the existing IsDir branch
|
||||||
// ACL via the parent project's .zddc cascade.
|
// 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) &&
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
strings.HasSuffix(urlPath, "/") &&
|
zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
|
||||||
zddc.IsProjectRootFolder(strings.TrimPrefix(strings.TrimSuffix(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)
|
handler.ServeDirectory(cfg, w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Non-canonical missing folder still 404s (the fallback is
|
||||||
// scoped to the four canonical names, not a blanket "missing →
|
// scoped to the four canonical names, not a blanket "missing →
|
||||||
// empty" rule).
|
// empty" rule).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue