feat(handler): per-directory <dir>/.zddc.html editor URL
Add a virtual-URL alias so the existing form-based .zddc editor is reachable at the natural directory location (<dir>/.zddc.html) in addition to the legacy /.profile/zddc/edit?path=<dir> entry. Both flow through the same renderZddcEditor body — same template, same gate, same form-posts-to-/.profile/zddc semantics. Wiring: - IsZddcEditorRequest(urlPath) reports whether the URL ends with the .zddc.html leaf (case-fold not needed; .zddc is itself case- sensitive on disk). - ServeZddcEditorAtPath strips the leaf, resolves the parent dir, asserts the dir exists, gates on hasAnyAdminScope, calls the shared renderer. - The dispatcher routes IsZddcEditorRequest URLs BEFORE the dot- prefix segment guard (which would otherwise 404 the .zddc.html leaf). The route is method-gated GET-only; mutations still go through PUT/POST/DELETE on <dir>/.zddc via the file API. Permission model unchanged from the /.profile entry: hasAnyAdminScope gates visibility of the editor itself; CanEditZddc decides whether the form is interactive or read-only at the requested directory. Subtree admins can still inspect ancestor cascade ACLs (intended since the cascade is what determines their authority). Test (TestDispatchZddcEditorAtPath): root admin opens project / working/ / deployment-root editors; non-admin and anonymous both 404; missing directory 404; trailing-segment-after-leaf 404. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7958d7b22
commit
41dff23127
3 changed files with 169 additions and 1 deletions
|
|
@ -447,6 +447,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// Split path into segments
|
// Split path into segments
|
||||||
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
|
|
||||||
|
// Per-directory .zddc editor: <dir>/.zddc.html is a virtual URL
|
||||||
|
// served by the existing form-based editor (same handler that
|
||||||
|
// powers /.profile/zddc/edit?path=<dir>). 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
|
// Reserve dot-prefixed path segments. The listing pipeline already hides
|
||||||
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
|
// hidden entries (internal/listing/listing.go:17, projectshandler.go:40),
|
||||||
// but direct URL access would still serve them. 404 here so hidden trees
|
// but direct URL access would still serve them. 404 here so hidden trees
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -584,3 +585,87 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"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)
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
email := EmailFromContext(r)
|
|
||||||
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
renderZddcEditor(cfg, w, r, abs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeZddcEditorAtPath is the per-directory entry to the editor. The
|
||||||
|
// dispatcher routes <dir>/.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 <dir>/.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 <dir>/.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"))
|
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue