ZDDC/zddc/internal/handler/zddchandler.go
ZDDC 7b764956bd feat(zddc-server): apps section in .zddc editor
Extends the form-based .zddc editor at /.profile/zddc/edit?path=<dir>
with an Apps section between Admins and the Effective chain.

The section is a six-row table — default plus the five canonical apps —
with one text input per row. Each row's right column shows a server-
rendered "Resolves to" preview computed by walking the cascade through
this directory and applying default + per-app composition. The preview
displays the final URL, "embedded (build-time default)", or "local file:
<path>" so operators see exactly what will be served.

Help text covers the full spec syntax (channel/version/URL/path forms,
:channel shorthand, default key) plus the ?v= per-request override and
its cache-only security constraint.

Permission gating is unchanged: existing CanEditZddc() strict-ancestor
rule applies — subtree admins cannot edit the file that grants their
own authority. Field-level errors land inline next to the input, just
like the existing ACL/admins fields.

POST handler (internal/handler/zddchandler.go) accepts a new Apps map
in the JSON write request, validates via the existing zddc.ValidateFile
flow (which now enforces apps.<name> spec syntax), and writes
atomically through the unchanged zddc.WriteFile path.

Three new tests: round-trip apps including the default key, per-field
validation error returns, and editor renders the apps section with
existing .zddc values pre-filled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:25:42 -05:00

350 lines
10 KiB
Go

package handler
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ZddcProfilePathPrefix is the URL prefix for the .zddc editor (both API and
// HTML page). All routes under this prefix require either super-admin
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
// receive 404 to keep editor existence hidden, matching the /.profile gate.
const ZddcProfilePathPrefix = ProfilePathPrefix + "/zddc"
// ServeZddc dispatches all /.profile/zddc/* routes. ServeProfile already
// trimmed the /.profile prefix; this handler is reachable for any admin
// (super or subtree), so it re-checks authorization itself rather than
// inheriting one from the caller.
//
// Sub-routes:
//
// GET /.profile/zddc?path=<dir> → JSON: parsed file + chain
// POST /.profile/zddc?path=<dir> → write (JSON body)
// DELETE /.profile/zddc?path=<dir> → remove file
// GET /.profile/zddc/tree → JSON: list of editable dirs
// GET /.profile/zddc/edit?path=<dir> → server-rendered editor page
func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
// Hide existence from anyone who has no admin authority anywhere.
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
// r.URL.Path is the full URL path; sub-route is everything after
// /.profile/zddc.
sub := strings.TrimPrefix(r.URL.Path, ZddcProfilePathPrefix)
switch {
case sub == "" || sub == "/":
serveZddcAPI(cfg, w, r)
case sub == "/tree":
serveZddcTree(cfg, w, r)
case sub == "/edit":
serveZddcEditor(cfg, w, r)
case strings.HasPrefix(sub, "/assets/"):
serveZddcAssets(cfg, w, r)
default:
http.NotFound(w, r)
}
}
// hasAnyAdminScope reports whether email has admin authority anywhere in
// the tree (root super-admin OR a subtree-admin grant on any .zddc).
// This is the gate for surfacing the editor at all.
func hasAnyAdminScope(fsRoot, email string) bool {
if email == "" {
return false
}
if zddc.IsAdmin(fsRoot, email) {
return true
}
dirs, _ := zddc.ScanZddcFiles(fsRoot)
for _, d := range dirs {
if zddc.IsSubtreeAdmin(fsRoot, d, email) {
return true
}
}
return false
}
// resolvePath translates a URL `path=` query (relative to fsRoot, with
// '/' separator and leading '/') into an absolute filesystem path. It
// rejects path traversal and any segment beginning with '.' or '_' so
// reserved namespaces (e.g. .devshell) are not editable through this
// API. Returns the cleaned absolute path or an error suitable for a 404.
func resolvePath(fsRoot, urlPath string) (string, error) {
urlPath = strings.TrimSpace(urlPath)
if urlPath == "" {
urlPath = "/"
}
if !strings.HasPrefix(urlPath, "/") {
return "", errors.New("path must be absolute (start with /)")
}
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
// Reject reserved-prefix segments so the editor cannot create
// .foo/.zddc or _bar/.zddc through the API.
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
if seg == "" {
continue
}
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
return "", errors.New("reserved-prefix path segment")
}
}
rel := strings.TrimPrefix(cleanURL, "/")
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
abs = filepath.Clean(abs)
// Path containment.
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
return "", errors.New("path escapes root")
}
return abs, nil
}
// urlPathOf produces the URL form of an absolute filesystem path under
// fsRoot. The result is "/" for fsRoot itself, otherwise "/<rel>".
func urlPathOf(fsRoot, abs string) string {
if abs == fsRoot {
return "/"
}
rel, err := filepath.Rel(fsRoot, abs)
if err != nil {
return "/"
}
return "/" + filepath.ToSlash(rel)
}
// chainEntry is one level of the effective-chain in API responses.
type chainEntry struct {
Dir string `json:"dir"`
Exists bool `json:"exists"`
Title string `json:"title,omitempty"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins,omitempty"`
}
type zddcGetResponse struct {
Path string `json:"path"`
Exists bool `json:"exists"`
IsRoot bool `json:"is_root"`
CanEdit bool `json:"can_edit"`
File zddc.ZddcFile `json:"file"`
EffectiveChain []chainEntry `json:"effective_chain"`
}
type zddcWriteRequest struct {
Title string `json:"title"`
ACL zddc.ACLRules `json:"acl"`
Admins []string `json:"admins"`
Apps map[string]string `json:"apps,omitempty"`
}
type writeError struct {
Errors []zddc.FieldError `json:"errors"`
}
// serveZddcAPI handles /.profile/zddc?path=<dir> for GET, POST, DELETE.
func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
if err != nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
serveZddcGet(cfg, abs, email, w, r)
case http.MethodPost, http.MethodPut:
serveZddcWrite(cfg, abs, email, w, r)
case http.MethodDelete:
serveZddcDelete(cfg, abs, email, w, r)
default:
w.Header().Set("Allow", "GET, POST, DELETE")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}
}
func serveZddcGet(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
zf, err := zddc.ParseFile(filepath.Join(abs, ".zddc"))
if err != nil {
http.Error(w, "Bad Request: cannot parse existing .zddc: "+err.Error(), http.StatusBadRequest)
return
}
exists := false
if _, statErr := os.Stat(filepath.Join(abs, ".zddc")); statErr == nil {
exists = true
}
chain, _ := zddc.EffectivePolicy(cfg.Root, abs)
dirs := chainDirs(cfg.Root, abs)
entries := make([]chainEntry, 0, len(chain.Levels))
for i, level := range chain.Levels {
levelDir := dirs[i]
levelExists := false
if _, err := os.Stat(filepath.Join(levelDir, ".zddc")); err == nil {
levelExists = true
}
entries = append(entries, chainEntry{
Dir: urlPathOf(cfg.Root, levelDir),
Exists: levelExists,
Title: level.Title,
ACL: level.ACL,
Admins: level.Admins,
})
}
resp := zddcGetResponse{
Path: urlPathOf(cfg.Root, abs),
Exists: exists,
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
File: zf,
EffectiveChain: entries,
}
writeJSON(w, resp)
}
// chainDirs reproduces EffectivePolicy's directory walk so the chainEntry
// list can carry the directory of each level. Kept local to this file to
// avoid widening the zddc package's API.
func chainDirs(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
dirs := []string{fsRoot}
if dirPath == fsRoot {
return dirs
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || rel == "." {
return dirs
}
current := fsRoot
for _, part := range strings.Split(rel, string(filepath.Separator)) {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
func serveZddcWrite(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := os.MkdirAll(abs, 0o755); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req zddcWriteRequest
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
zf := zddc.ZddcFile{
Title: req.Title,
ACL: req.ACL,
Admins: req.Admins,
Apps: req.Apps,
}
if errs := zddc.ValidateFile(zf); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: errs})
return
}
// Root-only invariant: writer must remain in the post-write Admins
// list. Recovery requires filesystem access we don't have.
if abs == cfg.Root {
stillAdmin := false
for _, p := range zf.Admins {
if zddc.MatchesPattern(p, email) {
stillAdmin = true
break
}
}
if !stillAdmin {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{
Field: "admins",
Message: "you cannot remove yourself from the root admins list",
}}})
return
}
}
if err := zddc.WriteFile(abs, zf); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true, "path": urlPathOf(cfg.Root, abs)})
}
func serveZddcDelete(cfg config.Config, abs, email string, w http.ResponseWriter, r *http.Request) {
if !zddc.CanEditZddc(cfg.Root, abs, email) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Root-only invariant: cannot delete root .zddc, that would leave no
// way to administer the server (and CanEditZddc(root) would return
// false on any subsequent request).
if abs == cfg.Root {
http.Error(w, "Cannot delete root .zddc — edit instead", http.StatusBadRequest)
return
}
if err := zddc.DeleteFile(abs); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
type treeEntry struct {
Path string `json:"path"`
CanEdit bool `json:"can_edit"`
Title string `json:"title,omitempty"`
}
// serveZddcTree returns the list of every directory containing a .zddc
// file, with a per-entry can_edit flag. The list is filtered to entries
// the caller has at least admin visibility for (read or edit).
func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
dirs, _ := zddc.ScanZddcFiles(cfg.Root)
out := make([]treeEntry, 0, len(dirs))
for _, d := range dirs {
if !zddc.IsSubtreeAdmin(cfg.Root, d, email) && !zddc.IsAdmin(cfg.Root, email) {
continue
}
var title string
if zf, err := zddc.ParseFile(filepath.Join(d, ".zddc")); err == nil {
title = zf.Title
}
out = append(out, treeEntry{
Path: urlPathOf(cfg.Root, d),
CanEdit: zddc.CanEditZddc(cfg.Root, d, email),
Title: title,
})
}
writeJSON(w, out)
}