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)