package handler import ( "net/http" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "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. 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 — the dispatcher routes writes // // (PUT/DELETE/POST) directly to ServeFileAPI. // // 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, the body is the cascade's // // leaf-level ZddcFile (what internal/zddc/defaults/'s paths: // tree declares for THIS exact directory, plus any // virtual contributions threaded through by the walker) // marshalled as YAML. A header comment names the source // and points at ?effective=1 for the composed view. The // virtual body is itself valid YAML — PUT-saving it back // (with or without edits) through the file API // materialises a real on-disk override carrying exactly // the bytes the user saved. The response sets // X-ZDDC-Source: virtual:zddc so clients can distinguish. func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) { 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.AllowFromChainP(r.Context(), decider, chain, PrincipalFromContext(r), dirURL); !allowed { http.NotFound(w, r) // hide existence from unauthorised callers return } // ?effective=1 branch: return the composed cascade view as JSON. // Distinct from the .zddc file itself — the YAML body is "what's // defined at this level" (source of truth); this is "what's // effective after merging every ancestor" (inspection only, not // PUT-saveable as a .zddc). if r.URL.Query().Get("effective") == "1" { serveEffectiveZddc(cfg, dirURL, chain, w, r) 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 → render the cascade's leaf level as YAML. // What the user sees is the embedded defaults' declared shape // for this exact path; PUT-saving it back materialises an // on-disk override verbatim. body, err := renderVirtualZddc(chain) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } w.Header().Set("X-ZDDC-Source", "virtual:zddc") if r.Method == http.MethodHead { return } _, _ = w.Write([]byte(body)) } // renderVirtualZddc produces a YAML body for a directory that has no // .zddc on disk. The body is the cascade's leaf-level ZddcFile — // i.e. what the built-in defaults bundle (exportable/overridable as a // root .zddc.zip via `zddc-server show-defaults`) declares for this // exact directory, plus any contributions the walker threaded through. // The goal is to expose the baseline's source of truth: a new // user opening the virtual .zddc here sees, in the same yaml shape // they would write themselves, what behavior is currently declared // at this level. A header comment names the source and points at // ?effective=1 for the composed view across the chain. // // PUT-saving these bytes back through the file API materialises a // real on-disk override carrying exactly the saved content — the // virtual body is a template, not a contract; the operator can // trim / extend / overwrite freely. func renderVirtualZddc(chain zddc.PolicyChain) (string, error) { var leaf zddc.ZddcFile if n := len(chain.Levels); n > 0 { leaf = chain.Levels[n-1] } var b strings.Builder b.WriteString("# Virtual .zddc — no file on disk at this directory.\n") b.WriteString("# The content below is what the policy baseline declares\n") b.WriteString("# for this exact path: the built-in defaults bundle — the\n") b.WriteString("# same one you can export, and override, as a root\n") b.WriteString("# .zddc.zip (`zddc-server show-defaults`) — with any\n") b.WriteString("# on-disk ancestor .zddc overrides already threaded in.\n") b.WriteString("# Edit and save through the YAML editor in browse to\n") b.WriteString("# materialise a real .zddc here carrying your changes;\n") b.WriteString("# the bytes you save become the override verbatim (no\n") b.WriteString("# merge, no synthesis — .zddc files drive policy and are\n") b.WriteString("# the single source of truth).\n") b.WriteString("#\n") b.WriteString("# For the COMPOSED effective config across the whole\n") b.WriteString("# cascade (all ancestors merged), query:\n") b.WriteString("# GET ?effective=1 (JSON, not a .zddc).\n") if isZeroZddcFile(leaf) { b.WriteString("#\n") b.WriteString("# No rules declared at this exact level — every rule\n") b.WriteString("# currently in effect here is inherited from ancestors.\n") b.WriteString("{}\n") return b.String(), nil } body, err := yaml.Marshal(&leaf) if err != nil { return "", err } b.WriteByte('\n') b.Write(body) return b.String(), nil } // effectiveSourceView is the wire shape for one entry in the // `sources` array of the ?effective=1 response. Level matches // zddc.SourceEntry.Level (-1 = embedded baseline, 0+ = chain index); // URL is the directory URL of that level (or "" for the // baseline); Contributed lists the top-level fields the level // declared. type effectiveSourceView struct { Level int `json:"level"` URL string `json:"url"` Contributed []string `json:"contributed,omitempty"` } // effectiveZddcView is the wire shape for the ?effective=1 response. // Merged is the composed cascade as a ZddcFile (same struct shape the // editor consumes for an on-disk .zddc; client-side renderers can // reuse the same parser). Sources lists per-level contributions so // the user can trace any value back to its origin without re-walking // the cascade by hand. type effectiveZddcView struct { URLPath string `json:"url_path"` Merged zddc.ZddcFile `json:"merged"` Sources []effectiveSourceView `json:"sources"` } // serveEffectiveZddc writes the JSON composed-cascade view for the // .zddc URL. Same ACL as the YAML view (already enforced by the // caller). func serveEffectiveZddc(cfg config.Config, dirURL string, chain zddc.PolicyChain, w http.ResponseWriter, r *http.Request) { merged, sources := zddc.EffectiveZddc(chain) levelURLs := levelURLsFor(cfg.Root, dirURL, len(chain.Levels)) view := effectiveZddcView{ URLPath: dirURL, Merged: merged, Sources: make([]effectiveSourceView, 0, len(sources)), } for _, s := range sources { entry := effectiveSourceView{Level: s.Level, Contributed: s.Contributed} if s.Level < 0 { entry.URL = "" } else if s.Level < len(levelURLs) { entry.URL = levelURLs[s.Level] + ".zddc" } view.Sources = append(view.Sources, entry) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-ZDDC-Source", "virtual:effective") if r.Method == http.MethodHead { return } writeJSON(w, view) } // levelURLsFor maps each chain level index to its directory URL. The // chain walks dirs root→leaf so levelURLs[0] = "/", levelURLs[1] is // the first segment, etc. Length must equal len(chain.Levels). // // Used by serveEffectiveZddc to populate SourceEntry.URL — clients // receive concrete .zddc URLs they can navigate to rather than bare // integer indices. func levelURLsFor(_, dirURL string, n int) []string { dirURL = strings.TrimSuffix(dirURL, "/") out := make([]string, n) out[0] = "/" if dirURL == "" || n == 1 { return out } segs := strings.Split(strings.TrimPrefix(dirURL, "/"), "/") cur := "" for i, seg := range segs { if i+1 >= n { break } cur += "/" + seg out[i+1] = cur + "/" } return out } // isZeroZddcFile reports whether zf carries no declarations a user // would want to see — every field is its zero value. Used to switch // the virtual body between the rich path (marshal the leaf) and the // empty-placeholder path (just say "nothing declared here"). // // The ACL substruct's Inherit pointer being nil is part of "zero" // here; an explicit inherit: false is itself a declaration worth // surfacing. func isZeroZddcFile(zf zddc.ZddcFile) bool { return zf.Title == "" && zf.CreatedBy == "" && zf.DefaultTool == "" && zf.DirTool == "" && zf.ReceivedPath == "" && zf.PlannedReviewDate == "" && zf.PlannedResponseDate == "" && zf.ACL.Inherit == nil && zf.AutoOwn == nil && zf.AutoOwnFenced == nil && zf.Virtual == nil && zf.DropTarget == nil && zf.Convert == nil && len(zf.ACL.Permissions) == 0 && len(zf.Admins) == 0 && len(zf.Tables) == 0 && len(zf.Views) == 0 && len(zf.Display) == 0 && len(zf.Roles) == 0 && len(zf.FieldCodes) == 0 && len(zf.Records) == 0 && len(zf.AvailableTools) == 0 && len(zf.Worm) == 0 && len(zf.Paths) == 0 }