diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 0e470c8..5bb502f 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -447,6 +447,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // Split path into segments segments := strings.Split(strings.Trim(urlPath, "/"), "/") + // Per-directory .zddc editor: /.zddc.html is a virtual URL + // served by the existing form-based editor (same handler that + // powers /.profile/zddc/edit?path=). Routed BEFORE the + // dot-prefix guard so the leaf segment isn't 404'd. The handler + // itself gates on hasAnyAdminScope; non-admins see 404. + if handler.IsZddcEditorRequest(urlPath) { + handler.ServeZddcEditorAtPath(cfg, w, r) + return + } + // Reserve dot-prefixed path segments. The listing pipeline already hides // hidden entries (internal/listing/listing.go:17, projectshandler.go:40), // but direct URL access would still serve them. 404 here so hidden trees diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 5bc9666..20239a0 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/ed25519" "crypto/rand" "net/http" @@ -584,3 +585,87 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) { } }) } + +// TestDispatchZddcEditorAtPath verifies the per-directory /.zddc.html +// virtual URL is recognised by the dispatcher and routed to the editor +// handler (carved out from the dot-prefix guard). Permission gate is +// hasAnyAdminScope; non-admins get 404. +func TestDispatchZddcEditorAtPath(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "admins:\n - root@example.com\n") + mustMkdir(t, filepath.Join(root, "Project", "working")) + mustWrite(t, filepath.Join(root, "Project", ".zddc"), + "title: Demo Project\n") + + 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) + + cases := []struct { + name string + path string + email string + wantStatus int + wantSubstr string + }{ + { + "root admin opens project editor", + "/Project/.zddc.html", "root@example.com", + http.StatusOK, "Demo Project", + }, + { + "root admin opens working/ editor (no .zddc on disk yet)", + "/Project/working/.zddc.html", "root@example.com", + http.StatusOK, ".zddc editor", + }, + { + "root admin opens deployment-root editor", + "/.zddc.html", "root@example.com", + http.StatusOK, ".zddc editor", + }, + { + "non-admin gets 404", + "/Project/.zddc.html", "stranger@example.com", + http.StatusNotFound, "", + }, + { + "anonymous gets 404", + "/Project/.zddc.html", "", + http.StatusNotFound, "", + }, + { + "missing directory gets 404", + "/Project/no-such-dir/.zddc.html", "root@example.com", + http.StatusNotFound, "", + }, + { + "deeper than leaf rejected", + "/Project/.zddc.html/extra", "root@example.com", + http.StatusNotFound, "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email)) + rec := httptest.NewRecorder() + dispatch(cfg, idx, ring, nil, rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("path=%q status=%d, want %d; body=%s", + tc.path, rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) { + t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr) + } + }) + } +} diff --git a/zddc/internal/handler/zddceditor.go b/zddc/internal/handler/zddceditor.go index b040893..c091f25 100644 --- a/zddc/internal/handler/zddceditor.go +++ b/zddc/internal/handler/zddceditor.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/apps" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" @@ -46,13 +47,85 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } - email := EmailFromContext(r) abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path")) if err != nil { http.NotFound(w, r) return } + renderZddcEditor(cfg, w, r, abs) +} +// ServeZddcEditorAtPath is the per-directory entry to the editor. The +// dispatcher routes /.zddc.html requests here; the directory is +// derived from the URL path (parent of the .zddc.html leaf) rather +// than from a query parameter. +// +// Permission gate: the user must have an admin authority somewhere +// in the tree (same gate as the /.profile/zddc namespace). A non- +// admin sees 404 — no leak that an editor would otherwise be +// available. Within the editor, CanEditZddc decides whether the form +// is interactive or read-only at THIS specific .zddc; non-editors +// can still inspect the cascade if they have any admin scope. +func ServeZddcEditorAtPath(cfg config.Config, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + email := EmailFromContext(r) + if !hasAnyAdminScope(cfg.Root, email) { + http.NotFound(w, r) + return + } + + // URL is /.zddc.html (or "/.zddc.html" for the deployment + // root). Strip the leaf to get the directory. + urlPath := strings.TrimSuffix(r.URL.Path, "/") + leafPath := "/" + ZddcEditorBasename + if !strings.HasSuffix(urlPath, leafPath) { + http.NotFound(w, r) + return + } + dirURL := strings.TrimSuffix(urlPath, leafPath) + if dirURL == "" { + dirURL = "/" + } + abs, err := resolvePath(cfg.Root, dirURL) + if err != nil { + http.NotFound(w, r) + return + } + // The directory must exist on disk; the per-path editor URL is a + // view onto an existing tree position, not a way to materialise + // arbitrary new directories. (The /.profile editor accepts a + // missing dir for the legacy path-as-query workflow.) + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + http.NotFound(w, r) + return + } + renderZddcEditor(cfg, w, r, abs) +} + +// ZddcEditorBasename is the URL leaf that the dispatcher recognises as +// a per-directory editor request. The dot-prefix guard carves this one +// segment out so the editor reaches the handler. +const ZddcEditorBasename = ".zddc.html" + +// IsZddcEditorRequest reports whether urlPath ends with the editor's +// virtual basename. Used by the dispatcher to route the request to +// ServeZddcEditorAtPath ahead of the dot-prefix guard. +func IsZddcEditorRequest(urlPath string) bool { + clean := strings.TrimSuffix(urlPath, "/") + return strings.HasSuffix(clean, "/"+ZddcEditorBasename) || + clean == "/"+ZddcEditorBasename +} + +// renderZddcEditor renders the editor template against the .zddc at +// abs (which may not exist on disk yet). Shared between the +// /.profile/zddc/edit?path= entry and the per-directory /.zddc.html +// entry. +func renderZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request, abs string) { + email := EmailFromContext(r) zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc")) if err != nil { http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)