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 defaults.zddc.yaml'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 } 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 defaults.zddc.yaml's paths: tree declares for this exact // directory, plus any contributions the walker threaded through. The // goal is to expose the embedded defaults' 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 embedded defaults\n") b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n") b.WriteString("# exact path. Edit and save through the YAML editor in\n") b.WriteString("# browse to materialise a real .zddc here carrying your\n") b.WriteString("# changes; the bytes you save become the override\n") b.WriteString("# verbatim (no merge, no synthesis — .zddc files drive\n") b.WriteString("# policy and are 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 } // 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.AppsPubKey == "" && 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.Apps) == 0 && len(zf.Tables) == 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 }