From b3cea9b7a86c1b1db3da13a505403ab184f8c145 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 21:13:10 -0500 Subject: [PATCH] =?UTF-8?q?feat(zddc-server):=20no-slash=20canonical=20fol?= =?UTF-8?q?ders=20=E2=86=92=20default=20tool,=20even=20when=20missing=20on?= =?UTF-8?q?=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the existing IsDir-branch behavior at line 873 (/working → mdedit, /staging → transmittal, /archive → archive) for the case where the folder doesn't exist on disk yet. Without this, GET /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 /archive → archive tool (project-root mode) GET /archive/ → empty browse listing GET /working → mdedit rooted at working/ GET /working/ → empty browse listing (with synthetic / home entry) GET /staging → transmittal rooted at staging/ GET /staging/ → empty browse listing GET /reviewing → 301 to /reviewing/ (no default app) GET /reviewing/ → empty browse listing GET /random → 404 (still — non-canonical) GET /random/ → 404 (still — non-canonical) Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 42 +++++++++++++++++++++++-------- zddc/cmd/zddc-server/main_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 2208797..0bc50cc 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -841,18 +841,38 @@ 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. + // Canonical project-root folder fallback. /{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: + // + // /working → mdedit rooted at working/ + // (matches the existing IsDir branch + // for an existing folder) + // /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 } diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index d17862c..290d12e 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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).