diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 8f2669c..bb5829d 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -646,6 +646,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // Raw .zddc YAML view: /.zddc is reachable at every depth + // and returns the on-disk file's bytes (Content-Type: application/yaml) + // or — when no file exists — a synthetic placeholder body with a + // cascade summary so the user can see what's effective here. + // GET/HEAD only; writes go through the admin-gated .zddc.html + // form. Also carved out of the dot-prefix guard. + if handler.IsZddcFileRequest(urlPath) { + handler.ServeZddcFile(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 @@ -874,23 +885,28 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps } } // Default-MDL virtual directory at archive//mdl[/]. - // The rows-dir doesn't have to exist on disk — - // RecognizeTableRequest's default-MDL fallback handles a - // fully-missing path so a fresh party with no entries yet - // still lands on a usable table view (rather than 404). - // Both slash and no-slash forms serve the tables app - // directly; the slash form is the canonical URL the MDL - // card on the project landing page links to. + // Shape rule mirrors the other canonical folders: + // - no slash → tables app (default tool for mdl/) + // - slash → browse (ServeDirectory → empty listing for + // the not-yet-materialised folder) + // The dispatcher works without the on-disk dir existing + // thanks to fs.ListDirectory's empty-listing fallback + + // RecognizeTableRequest's default-MDL fallback. if r.Method == http.MethodGet || r.Method == http.MethodHead { - base := strings.TrimSuffix(urlPath, "/") - synth := base + "/table.html" - if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { - 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) + if !strings.HasSuffix(urlPath, "/") { + synth := urlPath + "/table.html" + if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { + 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 + } + handler.ServeTable(cfg, tr, w, r) return } - handler.ServeTable(cfg, tr, w, r) + } else if zddc.IsArchivePartyMdlDir( + strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) { + handler.ServeDirectory(cfg, appsSrv, w, r) return } } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index aef6c2a..a0bcf62 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -56,7 +56,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, // Returning [] makes the click land on a usable empty view; the // virtualUserHomeEntry below still fires for working/ so the // user sees their own home placeholder. - if os.IsNotExist(err) && zddc.IsProjectRootFolder(dirPath) { + if os.IsNotExist(err) && + (zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyMdlDir(dirPath)) { entries = nil } else { return nil, err diff --git a/zddc/internal/handler/directory_test.go b/zddc/internal/handler/directory_test.go index a5d53c4..8ff2d81 100644 --- a/zddc/internal/handler/directory_test.go +++ b/zddc/internal/handler/directory_test.go @@ -204,10 +204,14 @@ func TestServeDirectoryRedirectsTableRowsDir(t *testing.T) { }) } -// TestServeDirectoryRedirectsDefaultMdl covers the default-MDL fallback: -// archive//mdl/ with no on-disk table.yaml still redirects -// to mdl/table.html (the table handler serves embedded defaults). -func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) { +// TestServeDirectoryDefaultMdlNoRedirect covers the default-MDL case: +// when no on-disk table.yaml exists, archive//mdl/ (slash form) +// no longer redirects to mdl/table.html. Per the slash/no-slash +// convention, the slash form is the browse view; the no-slash form +// /Project/archive/Acme/mdl serves the tables app via the dispatcher. +// User-declared tables with a real table.yaml on disk DO still +// redirect (see TestServeDirectoryRedirectsRealTable). +func TestServeDirectoryDefaultMdlNoRedirect(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("acl:\n permissions:\n \"*@example.com\": rwcda\n"), 0o644); err != nil { @@ -227,10 +231,9 @@ func TestServeDirectoryRedirectsDefaultMdl(t *testing.T) { rec := httptest.NewRecorder() ServeDirectory(cfg, nil, rec, req) - if rec.Code != http.StatusFound { - t.Fatalf("status = %d, want 302; body = %s", rec.Code, rec.Body.String()) - } - if got, want := rec.Header().Get("Location"), "/Project/archive/Acme/mdl/table.html"; got != want { - t.Errorf("Location = %q, want %q", got, want) + // Should NOT redirect to /table.html — slash form is browse, not tables. + if rec.Code == http.StatusFound { + t.Fatalf("got 302 redirect to %q, want browse view (200)", + rec.Header().Get("Location")) } } diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index fc7111b..c85c77e 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -165,7 +165,16 @@ func tableRowsRedirect(fsRoot, urlPath string) string { urlPath += "/" } synthesized := urlPath + "table.html" - if RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) == nil { + tr := RecognizeTableRequest(fsRoot, http.MethodGet, synthesized) + if tr == nil { + return "" + } + // Default-MDL case (no on-disk table.yaml): follow the slash/no- + // slash convention — slash form serves browse, no-slash serves + // tables (handled by the dispatcher). Redirecting here would + // override the convention and force the user into the table view + // from any //mdl/ click. + if !fileExists(tr.SpecPath) { return "" } return synthesized diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 6be8403..8a1c268 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 17:29:36 · b1479c5-dirty + v0.0.17-alpha · 2026-05-11 17:41:46 · d052e9f-dirty
diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go new file mode 100644 index 0000000..4f21f2f --- /dev/null +++ b/zddc/internal/handler/zddcfile.go @@ -0,0 +1,203 @@ +package handler + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/policy" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +// ZddcFileBasename is the leaf the dispatcher recognises as a raw +// .zddc YAML view request. Carved out of the dot-prefix guard so any +// directory's .zddc is reachable at /.zddc — without it, the +// dispatcher 404s anything beginning with a dot. +const ZddcFileBasename = ".zddc" + +// IsZddcFileRequest reports whether urlPath ends with the raw .zddc +// leaf. Used by the dispatcher to route a GET/HEAD to ServeZddcFile +// before the dot-prefix guard rejects it. +// +// Excludes the `.zddc.html` editor leaf, which is handled by +// IsZddcEditorRequest / ServeZddcEditorAtPath. +func IsZddcFileRequest(urlPath string) bool { + clean := strings.TrimSuffix(urlPath, "/") + return strings.HasSuffix(clean, "/"+ZddcFileBasename) || + clean == "/"+ZddcFileBasename +} + +// ServeZddcFile serves a directory's .zddc as a plain YAML view. +// +// Method: GET / HEAD only; everything else → 405 with the existing +// /.profile/zddc editor pointed to in the body. +// ACL: the parent directory's read permission gates access. A +// user who can read the directory can read its .zddc. +// On-disk: if /.zddc exists, its bytes are returned verbatim +// with Content-Type: application/yaml. +// Virtual: if it does not exist, a synthetic body is returned with a +// cascade summary so the operator can see what rules are +// effective at this depth. The synthetic body is clearly +// marked with comments — saving it via the editor (`/.zddc.html`) +// materialises a real file. The virtual response sets +// X-ZDDC-Source: virtual so the client can distinguish. +func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, + "Method Not Allowed — .zddc is read-only via this URL.\n"+ + "To edit, open /.zddc.html (form-based editor, admin-only).\n", + http.StatusMethodNotAllowed) + return + } + email := EmailFromContext(r) + decider := DeciderFromContext(r) + + // URL is /.zddc. Strip the leaf to get the directory. + urlPath := r.URL.Path + leaf := "/" + ZddcFileBasename + if !strings.HasSuffix(urlPath, leaf) { + http.NotFound(w, r) + return + } + dirURL := strings.TrimSuffix(urlPath, leaf) + if dirURL == "" { + dirURL = "/" + } + + // Translate the URL into an absolute filesystem path. The parent + // directory must exist on disk (with one exception: the root + // itself, which always exists). We do NOT require the directory + // to exist if it's a canonical virtual folder — the cascade is + // still defined for those paths via the ancestors. + rel := strings.Trim(dirURL, "/") + abs := cfg.Root + if rel != "" { + abs = filepath.Join(cfg.Root, filepath.FromSlash(rel)) + if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) { + http.NotFound(w, r) + return + } + } + + // ACL gate: read permission on the parent directory. We resolve + // against the directory's effective policy chain, not the .zddc + // file's own permissions (the file isn't a separate ACL target — + // it's the source of the rules themselves). + chain, err := zddc.EffectivePolicy(cfg.Root, abs) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if allowed, _ := policy.AllowFromChain(r.Context(), decider, chain, email, dirURL); !allowed { + http.NotFound(w, r) // hide existence from unauthorised callers + return + } + + zddcPath := filepath.Join(abs, ".zddc") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/yaml; charset=utf-8") + + // On-disk file: serve bytes verbatim. + if data, err := os.ReadFile(zddcPath); err == nil { + w.Header().Set("X-ZDDC-Source", "file:"+filepath.ToSlash(strings.TrimPrefix(zddcPath, cfg.Root))) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(data) + return + } else if !os.IsNotExist(err) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // No file on disk → synthetic placeholder body with a cascade + // summary so the user can see what's actually effective here. + body := renderVirtualZddc(cfg.Root, abs, chain) + w.Header().Set("X-ZDDC-Source", "virtual:zddc") + if r.Method == http.MethodHead { + return + } + _, _ = w.Write([]byte(body)) +} + +// renderVirtualZddc produces a self-describing YAML placeholder for a +// directory that has no .zddc on disk. The body is valid YAML (parses +// to an empty document) so a downstream YAML tool isn't fazed; the +// commentary lives in comments. Each ancestor's contribution is +// summarised so the reader sees exactly what's effective at this +// depth. +func renderVirtualZddc(fsRoot, dirAbs string, chain zddc.PolicyChain) string { + var b strings.Builder + fmt.Fprintf(&b, "# Virtual .zddc — no file on disk at this directory yet.\n") + fmt.Fprintf(&b, "# Rules below are inherited from ancestors. To override at\n") + fmt.Fprintf(&b, "# this level, edit via the form editor at /.zddc.html\n") + fmt.Fprintf(&b, "# (admin-only). Saving creates a real file here.\n") + fmt.Fprintf(&b, "#\n") + fmt.Fprintf(&b, "# Effective cascade at %s:\n", urlPathOf(fsRoot, dirAbs)) + + // Walk the levels from root down. Each ZddcFile in chain.Levels + // corresponds to one ancestor (root, .../, ..., dirAbs). Show only + // the levels that contributed something non-empty. + dirs := chainDirs(fsRoot, dirAbs) + any := false + for i, lvl := range chain.Levels { + var levelDir string + if i < len(dirs) { + levelDir = dirs[i] + } else { + levelDir = fsRoot + } + entry := summariseLevel(lvl) + if entry == "" { + continue + } + any = true + fmt.Fprintf(&b, "#\n# from %s/.zddc:\n%s", + urlPathOf(fsRoot, levelDir), entry) + } + if !any { + fmt.Fprintf(&b, "# (no ancestor .zddc contributes any rule)\n") + } + fmt.Fprintf(&b, "\n# --- placeholder body (empty) ---\n") + fmt.Fprintf(&b, "{}\n") + return b.String() +} + +// summariseLevel produces a comment block describing one .zddc level's +// non-empty contributions (title, acl, admins, apps, tables). Empty +// levels return "" so the caller can skip them. +func summariseLevel(lvl zddc.ZddcFile) string { + var b strings.Builder + if lvl.Title != "" { + fmt.Fprintf(&b, "# title: %q\n", lvl.Title) + } + if len(lvl.ACL.Allow) > 0 { + fmt.Fprintf(&b, "# acl.allow: %v\n", lvl.ACL.Allow) + } + if len(lvl.ACL.Deny) > 0 { + fmt.Fprintf(&b, "# acl.deny: %v\n", lvl.ACL.Deny) + } + if len(lvl.ACL.Permissions) > 0 { + fmt.Fprintf(&b, "# acl.permissions: %v\n", lvl.ACL.Permissions) + } + if len(lvl.Admins) > 0 { + fmt.Fprintf(&b, "# admins: %v\n", lvl.Admins) + } + if len(lvl.Apps) > 0 { + fmt.Fprintf(&b, "# apps:\n") + for k, v := range lvl.Apps { + fmt.Fprintf(&b, "# %s: %s\n", k, v) + } + } + if len(lvl.Tables) > 0 { + fmt.Fprintf(&b, "# tables:\n") + for k, v := range lvl.Tables { + fmt.Fprintf(&b, "# %s: %s\n", k, v) + } + } + return b.String() +} diff --git a/zddc/internal/handler/zddcfile_test.go b/zddc/internal/handler/zddcfile_test.go new file mode 100644 index 0000000..206587b --- /dev/null +++ b/zddc/internal/handler/zddcfile_test.go @@ -0,0 +1,124 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +func TestIsZddcFileRequest(t *testing.T) { + cases := []struct { + url string + want bool + }{ + {"/.zddc", true}, + {"/.zddc/", true}, + {"/Project/.zddc", true}, + {"/Project/archive/PartyA/mdl/.zddc", true}, + {"/.zddc.html", false}, // editor leaf, handled separately + {"/Project/.zddc.html", false}, + {"/Project/.zddc/", true}, + {"/Project/", false}, + {"/", false}, + } + for _, tc := range cases { + if got := IsZddcFileRequest(tc.url); got != tc.want { + t.Errorf("IsZddcFileRequest(%q) = %v, want %v", tc.url, got, tc.want) + } + } +} + +func TestServeZddcFile_ExistingFile(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "title: root\nacl:\n permissions:\n \"*\": rwcda\n") + subDir := filepath.Join(root, "Project") + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(subDir, ".zddc"), + "title: project-level\n") + + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Project/.zddc", nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "title: project-level") { + t.Errorf("body missing on-disk content: %q", rec.Body.String()) + } + if got := rec.Header().Get("X-ZDDC-Source"); !strings.HasPrefix(got, "file:") { + t.Errorf("X-ZDDC-Source = %q, want file:* prefix", got) + } + if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/yaml") { + t.Errorf("Content-Type = %q, want application/yaml*", got) + } +} + +func TestServeZddcFile_VirtualDefault(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "title: bootstrap\nacl:\n permissions:\n \"*\": rwcda\n") + // Directory exists but has no .zddc. + subDir := filepath.Join(root, "Project") + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodGet, "/Project/.zddc", nil) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("X-ZDDC-Source"); got != "virtual:zddc" { + t.Errorf("X-ZDDC-Source = %q, want virtual:zddc", got) + } + body := rec.Body.String() + if !strings.Contains(body, "Virtual .zddc") { + t.Errorf("body missing virtual marker: %q", body) + } + // Should show the root's title from the cascade. + if !strings.Contains(body, "bootstrap") { + t.Errorf("body missing root cascade summary: %q", body) + } + // Should parse as valid YAML (empty document or {} at the end). + if !strings.Contains(body, "{}") { + t.Errorf("body missing placeholder body: %q", body) + } +} + +func TestServeZddcFile_NonGetRejected(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, ".zddc"), + "acl:\n permissions:\n \"*\": rwcda\n") + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + req := httptest.NewRequest(http.MethodPut, "/.zddc", + strings.NewReader("title: hacked\n")) + req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) + rec := httptest.NewRecorder() + ServeZddcFile(cfg, rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } +} diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index ad29d1e..8e7a535 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -49,6 +49,29 @@ var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} // MkdirAll for it. var VirtualOnlyCanonicalNames = []string{"reviewing"} +// IsArchivePartyMdlDir reports whether dirPath (relative, forward- +// slash-separated) names the default-MDL pattern at exactly depth 4: +// /archive//mdl. Match is case-insensitive on the +// "archive" and "mdl" segments; the party name is verbatim. +// +// Used by listing + dispatch fallbacks so a fresh party that hasn't +// yet had an MDL written still lands on a usable empty browse / table +// view rather than 404. The companion handler helper +// isAtArchivePartyMdlDir (in internal/handler/tablehandler.go) takes +// absolute paths; this one is the relative-path equivalent for fs. +func IsArchivePartyMdlDir(dirPath string) bool { + clean := strings.Trim(filepath.ToSlash(dirPath), "/") + if clean == "" { + return false + } + parts := strings.Split(clean, "/") + if len(parts) != 4 { + return false + } + return strings.EqualFold(parts[1], "archive") && + strings.EqualFold(parts[3], "mdl") +} + // IsProjectRootFolder reports whether dirPath (relative to fsRoot, // forward-slash-separated, no leading slash) names one of the canonical // project-root folders at exactly depth 2: /.