package handler import ( "encoding/json" "net/http" "path/filepath" "sort" "strings" "time" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // ProfilePathPrefix is the URL prefix at which the user-profile page is // served. The dot-prefix keeps the namespace out of project-name space // (resolvePath rejects dot-prefixed user paths) and matches the `.zddc` // / `.archive` reserved-prefix convention. const ProfilePathPrefix = "/.profile" // ServeProfile is the entry point for /.profile/* routes. The top-level // page and the access-summary JSON are reachable to anyone (anonymous // included); admin-only sub-resources (whoami / config / logs / projects / // the .zddc editor) keep their existing per-resource 404 leakage gates. func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) { sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix) if sub == "" { sub = "/" } // Delegated to ServeZddc; that handler has its own hasAnyAdminScope gate. if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") { ServeZddc(cfg, w, r) return } email := EmailFromContext(r) switch sub { case "/", "": serveProfilePage(cfg, w, r) case "/access": writeJSON(w, enumerateAccess(cfg, email)) case "/projects": serveProfileProjectsCreate(cfg, w, r) case "/whoami": if !zddc.IsAdmin(cfg.Root, email) { http.NotFound(w, r) return } serveProfileWhoami(cfg, email, w, r) case "/config": if !zddc.IsAdmin(cfg.Root, email) { http.NotFound(w, r) return } serveProfileConfig(cfg, w, r) case "/logs": if !zddc.IsAdmin(cfg.Root, email) { http.NotFound(w, r) return } serveProfileLogs(ring, w, r) default: http.NotFound(w, r) } } // AccessView is the data the profile page lazy-loads from /.profile/access // after first paint. The HTML shell renders only Email/EmailHeader/ // IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come // in via JS. EditableParentChoices is what the create-project form's // parent-selector renders — derived from AdminSubtrees on the client. type AccessView struct { Email string `json:"email"` EmailHeader string `json:"email_header"` IsSuperAdmin bool `json:"is_super_admin"` HasAnyAdminScope bool `json:"has_any_admin_scope"` Projects []ProjectInfo `json:"projects"` AdminSubtrees []treeEntry `json:"admin_subtrees"` EditableParentChoices []treeEntry `json:"editable_parent_choices"` } // enumerateAccess builds an AccessView for the given caller. Used by the // JSON endpoint at /.profile/access; the HTML page no longer calls this on // the request hot path — it ships a shell first and the client fetches the // view after first paint. func enumerateAccess(cfg config.Config, email string) AccessView { view := AccessView{ Email: email, EmailHeader: cfg.EmailHeader, IsSuperAdmin: zddc.IsAdmin(cfg.Root, email), } view.Projects, _ = EnumerateProjects(cfg, email) view.AdminSubtrees = enumerateAdminSubtrees(cfg, email) view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0 for _, t := range view.AdminSubtrees { if t.CanEdit { view.EditableParentChoices = append(view.EditableParentChoices, t) } } return view } // enumerateAdminSubtrees lists every directory containing a .zddc that the // caller can see as an admin (super-admin or subtree-admin). Each entry // carries can_edit so the page can label read-only entries (the file that // grants the user's own authority). func enumerateAdminSubtrees(cfg config.Config, email string) []treeEntry { 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, }) } return out } // writeJSON writes v as indented JSON. Sets Content-Type and disables caching // (profile views are always live). func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(v) } // serveProfileWhoami returns the data needed to debug header-passthrough // problems: which header the server is configured to read, what value (if // any) arrived under that header, the resolved email, and a dump of every // header on the request. func serveProfileWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) { keys := make([]string, 0, len(r.Header)) for k := range r.Header { keys = append(keys, k) } sort.Strings(keys) headers := make(map[string][]string, len(keys)) for _, k := range keys { headers[k] = r.Header.Values(k) } type response struct { ConfiguredEmailHeader string `json:"configured_email_header"` ObservedEmail string `json:"observed_email"` ResolvedEmail string `json:"resolved_email"` RemoteAddr string `json:"remote_addr"` Method string `json:"method"` URL string `json:"url"` Headers map[string][]string `json:"headers"` } writeJSON(w, response{ ConfiguredEmailHeader: cfg.EmailHeader, ObservedEmail: r.Header.Get(cfg.EmailHeader), ResolvedEmail: email, RemoteAddr: r.RemoteAddr, Method: r.Method, URL: r.URL.String(), Headers: headers, }) } // serveProfileConfig dumps the parsed Config. TLS cert/key paths are echoed, // not their file contents; nothing else here is secret. func serveProfileConfig(cfg config.Config, w http.ResponseWriter, r *http.Request) { type response struct { Root string `json:"root"` Addr string `json:"addr"` TLSCert string `json:"tls_cert"` TLSKey string `json:"tls_key"` TLSMode string `json:"tls_mode"` LogLevel string `json:"log_level"` IndexPath string `json:"index_path"` EmailHeader string `json:"email_header"` CORSOrigins []string `json:"cors_origins"` } writeJSON(w, response{ Root: cfg.Root, Addr: cfg.Addr, TLSCert: cfg.TLSCert, TLSKey: cfg.TLSKey, TLSMode: cfg.TLSMode, LogLevel: cfg.LogLevel, IndexPath: cfg.IndexPath, EmailHeader: cfg.EmailHeader, CORSOrigins: cfg.CORSOrigins, }) } // serveProfileLogs returns the ring buffer's current contents. Optional query // params: level=debug|info|warn|error and since=. func serveProfileLogs(ring *LogRing, w http.ResponseWriter, r *http.Request) { if ring == nil { writeJSON(w, []LogEntry{}) return } entries := ring.Snapshot() if levelStr := r.URL.Query().Get("level"); levelStr != "" { min := levelRank(levelStr) out := entries[:0] for _, e := range entries { if levelRank(strings.ToLower(e.Level)) >= min { out = append(out, e) } } entries = out } if sinceStr := r.URL.Query().Get("since"); sinceStr != "" { if since, err := time.Parse(time.RFC3339, sinceStr); err == nil { out := entries[:0] for _, e := range entries { if !e.Time.Before(since) { out = append(out, e) } } entries = out } } writeJSON(w, entries) } func levelRank(s string) int { switch strings.ToLower(s) { case "debug": return 0 case "info": return 1 case "warn", "warning": return 2 case "error": return 3 default: return 1 // unknown → info } }