Replaces the super-admin-only /.admin/ surface with a public-by-default /.profile/ page that layers admin tools server-side based on the caller's effective access: - Universal (everyone, anonymous included): identity card, effective access summary, theme picker, localStorage utilities (export / import / clear, landing-presets viewer). - Subtree admins additionally see: editable .zddc files list (linking to the existing form-based editor) and a "Create new project folder" form. - Super-admins additionally see: server config, log viewer, whoami headers (the old /.admin/ JSON endpoints, repointed under /.profile/). Project creation is gated on CanEditZddc(newDir) — the same strict- ancestor rule that already governs .zddc writes — so no new authority concept is introduced. ValidateProjectName mirrors the existing reserved-prefix policy (no leading '.' or '_', no path separators). /.admin/* is hard-cut: no redirect shim. Old URLs fall through to the existing dot-prefix guard and 404. Custom CSS file rename: prefer <root>/.profile.css, fall back to legacy <root>/.admin.css. Per-resource 404 leakage gates preserved on whoami / config / logs / zddc / projects so non-admin callers cannot detect the existence of admin-only sub-resources. Tree-wide gofmt -w applied as a side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
348 lines
10 KiB
Go
348 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"`
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
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)
|
|
}
|