Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
7.7 KiB
Go
250 lines
7.7 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 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=<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
|
|
}
|
|
}
|