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. 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 — PUT-saving its bytes back to the // same URL (through the file API) 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, PUT, DELETE") http.Error(w, "Method Not Allowed — this URL serves the .zddc bytes for "+ "GET/HEAD. Writes go through the file API at the same "+ "URL (PUT to overwrite, DELETE to remove); for an editor, "+ "open /?file=.zddc to land on the YAML/CodeMirror view.\n", http.StatusMethodNotAllowed) return } 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 → 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. Edit + save\n") fmt.Fprintf(&b, "# (PUT) through the YAML editor in browse (admin-only)\n") fmt.Fprintf(&b, "# to override at this level — the save materialises a\n") fmt.Fprintf(&b, "# 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() }