ZDDC/zddc/internal/handler/profilehandler.go
ZDDC cb46c2ef8c feat(zddc-server): user profile page replaces /.admin/
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>
2026-04-29 16:32:02 -05:00

241 lines
7.2 KiB
Go

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 renders in its top section and
// /.profile/access serves as JSON. It is derived from cfg + the caller's
// email; everything reuses existing helpers in package zddc.
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"`
}
// enumerateAccess builds an AccessView for the given caller. Callable by
// both the HTML page (server-render) and the JSON endpoint without
// duplicating the access-walk logic.
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
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=<RFC3339>.
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
}
}