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= → JSON: parsed file + chain // POST /.profile/zddc?path= → write (JSON body) // DELETE /.profile/zddc?path= → remove file // GET /.profile/zddc/tree → JSON: list of editable dirs // GET /.profile/zddc/edit?path= → 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 "/". 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= 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) }