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>
This commit is contained in:
ZDDC 2026-04-29 16:32:02 -05:00
parent 916e53d873
commit cb46c2ef8c
25 changed files with 1617 additions and 695 deletions

View file

@ -110,11 +110,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
urlPath := r.URL.Path
email := handler.EmailFromContext(r)
// Admin debug routes — gated by IsAdmin allowlist in <root>/.zddc.
// Non-admins receive 404 (not 403) so the existence of the admin page
// is invisible to unauthorized callers.
if urlPath == handler.AdminPathPrefix || strings.HasPrefix(urlPath, handler.AdminPathPrefix+"/") {
handler.ServeAdmin(cfg, ring, w, r)
// Profile routes — the page itself is reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs /
// projects / .zddc editor) keep their existing per-resource 404
// existence-leakage gates inside ServeProfile.
if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
handler.ServeProfile(cfg, ring, w, r)
return
}
@ -135,7 +136,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
// but direct URL access would still serve them. 404 here so hidden trees
// like /srv/.devshell (the in-image dev-shell's persistent home dir on
// the same Azure Files PVC as served data) cannot be fetched. The
// recognized virtual prefixes (.admin handled above, cfg.IndexPath
// recognized virtual prefixes (.profile handled above, cfg.IndexPath
// handled below) are explicitly allowed through.
for _, seg := range segments {
if seg == "" || !strings.HasPrefix(seg, ".") {
@ -209,8 +210,8 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
// setupLogger installs a slog default that fans every record out to stderr
// (the existing TextHandler — user-visible logging is unchanged) AND to an
// in-memory ring buffer that backs the /.admin/logs endpoint. Returns the
// ring so handlers can read it.
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns
// the ring so handlers can read it.
func setupLogger(level string) *handler.LogRing {
var l slog.Level
switch strings.ToLower(level) {

View file

@ -14,7 +14,7 @@ import (
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
// rejects requests whose URL contains a dot-prefixed segment (other than
// the recognized virtual prefixes .archive and /.admin handled separately).
// the recognized virtual prefixes .archive and /.profile handled separately).
//
// The guard exists so the in-image dev-shell can keep persistent state
// (settings, source clones, Go module cache) under /srv/.devshell on the
@ -58,11 +58,13 @@ func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
// through to its own handler (which 404s on missing tracking number,
// but importantly NOT via the dot-prefix guard); .admin is handled
// by an earlier dispatch branch and hits the IsAdmin gate.
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler, status matches
{".admin not blocked by guard", "/.admin/whoami", http.StatusNotFound}, // no admins configured → IsAdmin false → 404 from admin handler
// through to its own handler (which 404s on missing tracking number);
// .profile is handled by ServeProfile and the page itself is public.
// /.admin no longer exists — it is hard-cut and falls through to the
// dot-prefix guard, which 404s.
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
{".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous
{".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound},
// Normal files unaffected.
{"plain file", "/Project-A/doc.txt", http.StatusOK},

View file

@ -18,8 +18,8 @@ type RevisionEntry struct {
// TrackingEntry holds all revision data for one tracking number.
type TrackingEntry struct {
HighestBaseRev string // highest base revision (for trackingNumber.html)
ByRevision map[string]*RevisionEntry // base revision → entry
HighestBaseRev string // highest base revision (for trackingNumber.html)
ByRevision map[string]*RevisionEntry // base revision → entry
}
// Index is the in-memory archive index.

View file

@ -131,11 +131,11 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
// Highest-rev shortcut + each per-rev redirect should be present.
wantNames := []string{
"123.html", // highest of 123 → A
"123_A.html", // explicit A
"123_~A.html", // explicit draft
"456.html", // highest of 456 → 0
"456_0.html", // explicit 0
"123.html", // highest of 123 → A
"123_A.html", // explicit A
"123_~A.html", // explicit draft
"456.html", // highest of 456 → 0
"456_0.html", // explicit 0
}
for _, n := range wantNames {
if _, ok := got[n]; !ok {

View file

@ -9,8 +9,8 @@ import (
"sync"
"time"
"github.com/fsnotify/fsnotify"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/fsnotify/fsnotify"
)
// Watcher watches fsRoot for filesystem changes and updates the archive index

View file

@ -1,277 +0,0 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// AdminPathPrefix is the URL prefix at which the admin debug page is served.
// Hardcoded — see plan: collision with a real archive folder named ".admin"
// is essentially impossible, and we intercept in dispatch() before filesystem
// resolution. If a real conflict ever shows up, make this a config value.
const AdminPathPrefix = "/.admin"
// ServeAdmin is the entry point for /.admin/* routes. The /whoami,
// /config, /logs, and dashboard sub-routes are super-admin-only (gated
// by zddc.IsAdmin against the root .zddc); 404 leaks no information
// about admin endpoint existence.
//
// /.admin/zddc/* — the .zddc editor — is reachable to ANY subtree-admin
// (not just root), so it is dispatched out to ServeZddc before the
// super-admin gate; ServeZddc applies its own broader hasAnyAdminScope
// check internally.
func ServeAdmin(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
// Trim the prefix, keep a leading "/" for sub-route matching.
sub := strings.TrimPrefix(r.URL.Path, AdminPathPrefix)
if sub == "" {
sub = "/"
}
// /.admin/zddc/* — subtree admins reach this; ServeZddc gates itself.
if sub == "/zddc" || strings.HasPrefix(sub, "/zddc/") {
ServeZddc(cfg, w, r)
return
}
email := EmailFromContext(r)
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
switch sub {
case "/", "":
serveAdminDashboard(w, r)
case "/whoami":
serveAdminWhoami(cfg, email, w, r)
case "/config":
serveAdminConfig(cfg, w, r)
case "/logs":
serveAdminLogs(ring, w, r)
default:
http.NotFound(w, r)
}
}
// writeJSON writes v as indented JSON. Sets Content-Type and disables caching
// (admin 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)
}
// serveAdminWhoami 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. This is the actual answer to "is X-Auth-Request-Email
// arriving at the binary?".
func serveAdminWhoami(cfg config.Config, email string, w http.ResponseWriter, r *http.Request) {
// r.Header keys are canonicalized by net/http (e.g. "x-auth-request-email"
// becomes "X-Auth-Request-Email"). Iterate to a stable order.
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,
})
}
// serveAdminConfig dumps the parsed Config. Field semantics:
//
// - TLSCert / TLSKey are reported as the env-var values supplied by the
// operator (typically a file path or the literal "none"). The contents of
// the cert/key files are never read or echoed.
// - All other fields are echoes of operator-supplied env vars or sensible
// defaults — none constitute a secret.
func serveAdminConfig(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,
})
}
// serveAdminLogs returns the ring buffer's current contents. Optional query
// params:
//
// - level=debug|info|warn|error — minimum level to include
// - since=<RFC3339> — drop entries strictly older than this ts
func serveAdminLogs(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
}
}
// adminDashboardHTML is the static dashboard page. Self-contained: all CSS
// and JS inline, no external assets. Three sections that fetch the JSON
// endpoints client-side and render the result.
const adminDashboardHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>zddc-server admin debug</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: #222; max-width: 1100px; }
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .4rem; font-size: 1.05rem; border-bottom: 1px solid #ddd; padding-bottom: .2rem; }
pre { background: #f5f5f5; padding: .75rem; border-radius: 4px; overflow: auto; font-size: 12px; max-height: 28rem; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .25rem; }
button { font: inherit; padding: .25rem .75rem; cursor: pointer; }
.err { color: #b00020; }
.muted { color: #666; font-size: .9em; }
code { background: #eee; padding: 0 .25rem; border-radius: 2px; font-size: 12px; }
</style>
</head>
<body>
<h1>zddc-server admin debug</h1>
<p class="muted">Live state from the running process. Fetched client-side; refresh each section with its button.</p>
<h2>whoami <button data-target="whoami">refresh</button></h2>
<p class="muted">What headers actually arrived. The <code>configured_email_header</code> is the header name the binary is reading; <code>observed_email</code> is the value at that name; <code>headers</code> is everything received.</p>
<pre id="whoami">loading</pre>
<h2>config <button data-target="config">refresh</button></h2>
<p class="muted">Effective config from environment variables. <code>tls_cert</code> / <code>tls_key</code> show the supplied path strings; file contents are not read.</p>
<pre id="config">loading</pre>
<h2>logs <button data-target="logs">refresh</button> <span class="muted">level: <select id="level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span></h2>
<pre id="logs">loading</pre>
<script>
async function load(target, qs) {
const el = document.getElementById(target);
el.textContent = "loading…";
el.classList.remove("err");
try {
const url = "%[1]s/" + target + (qs ? ("?" + qs) : "");
const resp = await fetch(url, { headers: { Accept: "application/json" } });
const text = await resp.text();
if (!resp.ok) {
el.classList.add("err");
el.textContent = "HTTP " + resp.status + "\n\n" + text;
return;
}
try {
el.textContent = JSON.stringify(JSON.parse(text), null, 2);
} catch {
el.textContent = text;
}
} catch (e) {
el.classList.add("err");
el.textContent = String(e);
}
}
document.querySelectorAll("button[data-target]").forEach(b => {
b.addEventListener("click", () => {
const t = b.dataset.target;
const qs = t === "logs" ? ("level=" + (document.getElementById("level").value || "")) : "";
load(t, qs);
});
});
document.getElementById("level").addEventListener("change", () => load("logs", "level=" + (document.getElementById("level").value || "")));
load("whoami");
load("config");
load("logs", "level=info");
</script>
</body>
</html>
`
func serveAdminDashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprintf(w, adminDashboardHTML, AdminPathPrefix)
}

View file

@ -1,232 +0,0 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// adminTestRoot creates a temp dir, writes a .zddc with the given admins
// list, and returns a Config pointing at it.
func adminTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
t.Helper()
root := t.TempDir()
if len(admins) > 0 {
var b strings.Builder
b.WriteString("admins:\n")
for _, a := range admins {
b.WriteString(" - \"")
b.WriteString(a)
b.WriteString("\"\n")
}
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
}
return config.Config{
Root: root,
Addr: ":8443",
EmailHeader: "X-Auth-Request-Email",
}, NewLogRing(50)
}
// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *http.Request {
r := httptest.NewRequest(method, path, nil)
if email != "" {
r.Header.Set("X-Auth-Request-Email", email)
ctx := context.WithValue(r.Context(), EmailKey, email)
r = r.WithContext(ctx)
}
return r
}
func TestServeAdminAuthGate(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
cases := []struct {
name string
path string
email string
wantStatus int
}{
// Anonymous (no email) — every path is hidden.
{"anonymous /.admin/", "/.admin/", "", http.StatusNotFound},
{"anonymous /.admin/whoami", "/.admin/whoami", "", http.StatusNotFound},
{"anonymous /.admin/config", "/.admin/config", "", http.StatusNotFound},
{"anonymous /.admin/logs", "/.admin/logs", "", http.StatusNotFound},
// Logged-in non-admin — 404 (existence not leaked).
{"non-admin /.admin/", "/.admin/", "bob@example.com", http.StatusNotFound},
{"non-admin /.admin/whoami", "/.admin/whoami", "bob@example.com", http.StatusNotFound},
// Admin — every defined path responds 200.
{"admin /.admin/", "/.admin/", "alice@example.com", http.StatusOK},
{"admin /.admin/whoami", "/.admin/whoami", "alice@example.com", http.StatusOK},
{"admin /.admin/config", "/.admin/config", "alice@example.com", http.StatusOK},
{"admin /.admin/logs", "/.admin/logs", "alice@example.com", http.StatusOK},
// Admin hitting an undefined sub-route — 404.
{"admin unknown subroute", "/.admin/nope", "alice@example.com", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeAdminWhoamiPayload(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeAdmin(cfg, ring, rec, r)
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if got["configured_email_header"] != "X-Auth-Request-Email" {
t.Errorf("configured_email_header = %v", got["configured_email_header"])
}
if got["observed_email"] != "alice@example.com" {
t.Errorf("observed_email = %v", got["observed_email"])
}
if got["resolved_email"] != "alice@example.com" {
t.Errorf("resolved_email = %v", got["resolved_email"])
}
headers, _ := got["headers"].(map[string]any)
if _, ok := headers["X-Auth-Request-Email"]; !ok {
t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers)
}
if _, ok := headers["X-Other-Header"]; !ok {
t.Errorf("headers map missing X-Other-Header: %+v", headers)
}
}
func TestServeAdminConfigPayload(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
cfg.LogLevel = "info"
cfg.IndexPath = ".archive"
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} {
if _, ok := got[want]; !ok {
t.Errorf("config payload missing key %q: %+v", want, got)
}
}
if got["email_header"] != "X-Auth-Request-Email" {
t.Errorf("email_header = %v", got["email_header"])
}
}
func TestServeAdminLogsPayload(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Info("first")
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if len(got) != 2 {
t.Fatalf("entries = %d, want 2", len(got))
}
if got[0]["message"] != "first" || got[1]["message"] != "second" {
t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"])
}
}
func TestServeAdminLogsLevelFilter(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Debug("d")
logger.Info("i")
logger.Warn("w")
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec,
requestWithEmail(http.MethodGet, "/.admin/logs?level=warn", "alice@example.com"))
var got []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 1 || got[0]["message"] != "w" {
t.Errorf("level=warn filter failed: %+v", got)
}
}
func TestServeAdminDashboardHTML(t *testing.T) {
cfg, ring := adminTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := rec.Body.String()
for _, want := range []string{"<!DOCTYPE html>", "/.admin/", `data-target="whoami"`, `data-target="config"`, `data-target="logs"`} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
}
func TestServeAdminNoAdminsConfiguredHidesEverything(t *testing.T) {
// .zddc exists but has no admins list — page is invisible to all.
cfg, ring := adminTestRoot(t, nil)
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
rec := httptest.NewRecorder()
ServeAdmin(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.admin/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404 (no admins configured)", rec.Code)
}
}

View file

@ -19,13 +19,13 @@ import (
// archiveTestRoot lays down a two-project tree so listings exercise scope and
// ACL cascading. ACLs are written per-test in the helper that calls this.
//
// <root>/
// ProjectA/
// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
// 2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf
// 2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf
// ProjectB/
// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
// <root>/
// ProjectA/
// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
// 2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf
// 2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf
// ProjectB/
// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
func archiveTestRoot(t *testing.T) (string, *archive.Index) {
t.Helper()
root := t.TempDir()

View file

@ -120,7 +120,7 @@ func (h *RingHandler) WithGroup(name string) slog.Handler {
// MultiHandler fans out each Handle call to every wrapped handler. Used to
// tee slog output to both stderr (the existing TextHandler) and the in-memory
// ring buffer that backs the admin /.admin/logs endpoint.
// ring buffer that backs the /.profile/logs endpoint.
type MultiHandler struct {
handlers []slog.Handler
}

View file

@ -0,0 +1,241 @@
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
}
}

View file

@ -0,0 +1,498 @@
package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// profileTestRoot creates a temp dir, writes a .zddc with the given admins
// list, and returns a Config pointing at it.
func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
t.Helper()
root := t.TempDir()
if len(admins) > 0 {
var b strings.Builder
b.WriteString("admins:\n")
for _, a := range admins {
b.WriteString(" - \"")
b.WriteString(a)
b.WriteString("\"\n")
}
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(root)
}
return config.Config{
Root: root,
Addr: ":8443",
EmailHeader: "X-Auth-Request-Email",
}, NewLogRing(50)
}
// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *http.Request {
r := httptest.NewRequest(method, path, nil)
if email != "" {
r.Header.Set("X-Auth-Request-Email", email)
ctx := context.WithValue(r.Context(), EmailKey, email)
r = r.WithContext(ctx)
}
return r
}
// TestServeProfileGateMatrix checks the authorization decisions for every
// sub-route. The page itself (/.profile/) is reachable to anyone (anonymous
// included); admin-only sub-resources stay 404 for non-eligible callers,
// preserving the existence-leakage policy on a per-resource basis.
func TestServeProfileGateMatrix(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
cases := []struct {
name string
path string
email string
wantStatus int
}{
// /.profile/ itself — public landing for everyone.
{"anonymous /.profile/", "/.profile/", "", http.StatusOK},
{"non-admin /.profile/", "/.profile/", "bob@example.com", http.StatusOK},
{"admin /.profile/", "/.profile/", "alice@example.com", http.StatusOK},
// /.profile/access — JSON, also public.
{"anonymous /.profile/access", "/.profile/access", "", http.StatusOK},
{"admin /.profile/access", "/.profile/access", "alice@example.com", http.StatusOK},
// Admin-only sub-resources — 404 for non-eligible callers.
{"anonymous /.profile/whoami", "/.profile/whoami", "", http.StatusNotFound},
{"anonymous /.profile/config", "/.profile/config", "", http.StatusNotFound},
{"anonymous /.profile/logs", "/.profile/logs", "", http.StatusNotFound},
{"non-admin /.profile/whoami", "/.profile/whoami", "bob@example.com", http.StatusNotFound},
{"non-admin /.profile/config", "/.profile/config", "bob@example.com", http.StatusNotFound},
{"non-admin /.profile/logs", "/.profile/logs", "bob@example.com", http.StatusNotFound},
{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
{"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK},
{"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK},
// Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
})
}
}
func TestServeProfileWhoamiPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, rec, r)
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if got["configured_email_header"] != "X-Auth-Request-Email" {
t.Errorf("configured_email_header = %v", got["configured_email_header"])
}
if got["observed_email"] != "alice@example.com" {
t.Errorf("observed_email = %v", got["observed_email"])
}
if got["resolved_email"] != "alice@example.com" {
t.Errorf("resolved_email = %v", got["resolved_email"])
}
headers, _ := got["headers"].(map[string]any)
if _, ok := headers["X-Auth-Request-Email"]; !ok {
t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers)
}
if _, ok := headers["X-Other-Header"]; !ok {
t.Errorf("headers map missing X-Other-Header: %+v", headers)
}
}
func TestServeProfileConfigPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
cfg.LogLevel = "info"
cfg.IndexPath = ".archive"
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} {
if _, ok := got[want]; !ok {
t.Errorf("config payload missing key %q: %+v", want, got)
}
}
if got["email_header"] != "X-Auth-Request-Email" {
t.Errorf("email_header = %v", got["email_header"])
}
}
func TestServeProfileLogsPayload(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Info("first")
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
}
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
}
if len(got) != 2 {
t.Fatalf("entries = %d, want 2", len(got))
}
if got[0]["message"] != "first" || got[1]["message"] != "second" {
t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"])
}
}
func TestServeProfileLogsLevelFilter(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rh := NewRingHandler(ring, slog.LevelDebug)
logger := slog.New(rh)
logger.Debug("d")
logger.Info("i")
logger.Warn("w")
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec,
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if len(got) != 1 || got[0]["message"] != "w" {
t.Errorf("level=warn filter failed: %+v", got)
}
}
// TestServeProfileHTMLLayered verifies server-side conditional rendering:
// non-admin HTML contains zero admin markup, admin HTML adds the admin
// block, super-admin HTML adds the diagnostics block.
func TestServeProfileHTMLLayered(t *testing.T) {
root := t.TempDir()
zf := "admins:\n - alice@example.com\n"
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - bob@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "projects"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
render := func(email string) string {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("email=%q Content-Type = %q, want text/html", email, ct)
}
return rec.Body.String()
}
anon := render("")
if !strings.Contains(anon, "Not signed in") {
t.Errorf("anonymous body missing 'Not signed in'")
}
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs"} {
if strings.Contains(anon, marker) {
t.Errorf("anonymous body unexpectedly contains admin marker %q", marker)
}
}
nonAdmin := render("carol@example.com")
if !strings.Contains(nonAdmin, "carol@example.com") {
t.Errorf("non-admin body missing email")
}
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config"} {
if strings.Contains(nonAdmin, marker) {
t.Errorf("non-admin body unexpectedly contains admin marker %q", marker)
}
}
subtree := render("bob@example.com")
if !strings.Contains(subtree, "Editable .zddc files") {
t.Errorf("subtree-admin body missing 'Editable .zddc files'")
}
if !strings.Contains(subtree, "Create new project folder") {
t.Errorf("subtree-admin body missing 'Create new project folder'")
}
if strings.Contains(subtree, "Server config") {
t.Errorf("subtree-admin body unexpectedly contains super-admin diagnostics")
}
super := render("alice@example.com")
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs", "diag-whoami"} {
if !strings.Contains(super, marker) {
t.Errorf("super-admin body missing %q", marker)
}
}
}
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode: %v", err)
}
if v.Email != "alice@example.com" || !v.IsSuperAdmin {
t.Errorf("expected super-admin alice; got %+v", v)
}
if v.EmailHeader != "X-Auth-Request-Email" {
t.Errorf("EmailHeader = %q", v.EmailHeader)
}
}
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// .zddc exists but has no admins list — page is still reachable,
// but the admin/super-admin sections are absent.
cfg, ring := profileTestRoot(t, nil)
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n allow: [\"*\"]\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "Server config") {
t.Errorf("alice should not see super-admin section when no admins configured")
}
// Per-resource gates remain.
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
}
}
// TestServeProfileProjectsCreate covers the happy path and the most
// common rejection modes for POST /.profile/projects.
func TestServeProfileProjectsCreate(t *testing.T) {
root := t.TempDir()
zf := "admins:\n - root@example.com\n"
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
post := func(email, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if email != "" {
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req)
return rec
}
// Happy path: super-admin creates /alpha with no .zddc body.
rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
t.Errorf("alpha dir not created on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil {
t.Errorf(".zddc should NOT be auto-written when no fields supplied")
}
// Body with a title also writes a .zddc.
rec = post("root@example.com", `{"parent":"/", "name":"beta", "title":"Beta site"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create-with-title status=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "beta", ".zddc")); err != nil {
t.Errorf(".zddc should be written when title supplied: %v", err)
}
// Conflict on existing dir.
rec = post("root@example.com", `{"parent":"/", "name":"alpha"}`)
if rec.Code != http.StatusConflict {
t.Errorf("duplicate create status=%d, want 409 (body=%s)", rec.Code, rec.Body.String())
}
// Bad name.
rec = post("root@example.com", `{"parent":"/", "name":".hidden"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("bad name status=%d, want 400 (body=%s)", rec.Code, rec.Body.String())
}
rec = post("root@example.com", `{"parent":"/", "name":"a/b"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("path-separator name status=%d, want 400", rec.Code)
}
// Reserved-prefix parent.
rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("reserved-prefix parent status=%d, want 404", rec.Code)
}
// Non-existent parent.
rec = post("root@example.com", `{"parent":"/does-not-exist", "name":"x"}`)
if rec.Code != http.StatusBadRequest {
t.Errorf("missing-parent status=%d, want 400", rec.Code)
}
// Anonymous and non-admin: 404 (no admin scope anywhere).
rec = post("", `{"parent":"/", "name":"gamma"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("anonymous status=%d, want 404", rec.Code)
}
rec = post("mallory@example.com", `{"parent":"/", "name":"gamma"}`)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin status=%d, want 404", rec.Code)
}
// Method other than POST is 405.
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code)
}
}
// TestServeProfileProjectsCreateValidatesZddc covers ACL/admin pattern
// validation: an invalid glob in the body must roll the directory back.
func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "badproject")); err == nil {
t.Errorf("dir should not exist after validation rejection")
}
}
// TestSubtreeAdminCanCreateInScope: a subtree admin (alice on /projects)
// can create /projects/sub but not /other.
func TestSubtreeAdminCanCreateInScope(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write root .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
t.Fatalf("write subtree .zddc: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "other"), 0o755); err != nil {
t.Fatalf("mkdir other: %v", err)
}
zddc.InvalidateCache(root)
zddc.InvalidateCache(filepath.Join(root, "projects"))
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
post := func(parent, name string) int {
body := `{"parent":"` + parent + `", "name":"` + name + `"}`
req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req)
return rec.Code
}
if code := post("/projects", "sub"); code != http.StatusCreated {
t.Errorf("alice creating /projects/sub: status=%d, want 201", code)
}
if code := post("/other", "sub"); code != http.StatusNotFound {
t.Errorf("alice creating /other/sub: status=%d, want 404 (no scope)", code)
}
}
// TestAdminPathHardCut verifies the legacy /.admin prefix is not handled
// by the server — every /.admin/* falls through to the dispatcher's normal
// path resolution which 404s on the dot-prefix guard.
func TestAdminPathHardCut(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodGet, p, "alice@example.com")
// Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.)
ServeProfile(cfg, ring, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code)
}
}
}

View file

@ -0,0 +1,464 @@
package handler
import (
"html/template"
"net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// profileView is the data passed to the profile template.
type profileView struct {
AccessView
ProfilePathPrefix string
AssetsPathPrefix string
HasCustomCSS bool
HasEditableSubtrees bool
EditableParentChoices []treeEntry // AdminSubtrees filtered to CanEdit; used as create-project parents
}
// serveProfilePage renders the universal profile page at GET /.profile/.
// Reachable to anyone (anonymous included); admin / super-admin sections
// are conditionally rendered server-side based on the caller's effective
// access — non-admin HTML contains zero admin markup.
func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
view := profileView{
AccessView: enumerateAccess(cfg, EmailFromContext(r)),
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
}
for _, t := range view.AdminSubtrees {
if t.CanEdit {
view.EditableParentChoices = append(view.EditableParentChoices, t)
}
}
view.HasEditableSubtrees = len(view.EditableParentChoices) > 0
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := profileTemplate.Execute(w, view); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// profileTemplate is the html/template for the profile page. Single page,
// three layered blocks (universal / admin / super-admin), inline styles
// using the same custom-property naming as the editor so a future merge
// with shared/base.css stays trivial. One inline IIFE handles theme,
// localStorage, and the create-project AJAX submit.
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>zddc-server profile</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
--radius: 4px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
}
[data-theme="light"] {
--bg: #fff; --bg-alt: #f7f7f8; --text: #222; --muted: #666;
--border: #d0d4dc; --primary: #2563eb; --primary-bg: #eff6ff;
--danger: #b00020; --warn: #b15c00; --ok: #0a7d2c;
}
[data-theme="dark"] {
--bg: #1a1c1f; --bg-alt: #23262b; --text: #e8e8ea; --muted: #a0a4ad;
--border: #353941; --primary: #60a5fa; --primary-bg: #1e293b;
--danger: #ff7080; --warn: #f5b056; --ok: #4ad27c;
}
body { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 1.5rem; color: var(--text); background: var(--bg); max-width: 980px; }
h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
h2 { margin: 1.5rem 0 .25rem; font-size: 1.05rem; }
.muted { color: var(--muted); }
.breadcrumb { color: var(--muted); margin-bottom: 1rem; }
.breadcrumb a { color: var(--primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.card { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.1rem; margin-bottom: 1rem; }
.card h2 { margin-top: 0; }
.card .help { color: var(--muted); font-size: .9em; margin: .3rem 0 .6rem; }
label { display: block; margin-bottom: .5rem; }
input[type="text"], select { width: 100%; max-width: 32rem; padding: .35rem .5rem; font: inherit; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); box-sizing: border-box; }
input[type="text"]:focus, select:focus { outline: 2px solid var(--primary); outline-offset: -1px; }
.row { display: flex; gap: .5rem; align-items: center; margin-bottom: .35rem; }
.row input[type="text"] { flex: 1; max-width: none; }
.row .err { color: var(--danger); font-size: .85em; margin-left: .5rem; }
button { font: inherit; padding: .35rem .85rem; cursor: pointer; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius); }
button:hover { background: var(--primary-bg); }
button.primary { background: var(--primary); color: white; border-color: var(--primary); }
button.primary:hover { filter: brightness(1.1); }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: rgba(176, 0, 32, 0.06); }
table { width: 100%; border-collapse: collapse; font-size: .95em; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
td.value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; word-break: break-all; }
td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
ul.bare { list-style: none; padding: 0; margin: 0; }
ul.bare li { padding: .2rem 0; }
.badge { display: inline-block; padding: .1rem .5rem; border-radius: 999px; font-size: .8em; border: 1px solid var(--border); background: var(--bg); }
.badge.yes { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); }
.ok-banner { background: var(--primary-bg); border: 1px solid var(--primary); border-radius: var(--radius); padding: .55rem .85rem; margin-bottom: 1rem; color: var(--text); }
pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: .5rem .7rem; margin: .35rem 0; overflow-x: auto; font-size: 12px; max-height: 20rem; }
pre.err { border-color: var(--danger); color: var(--danger); }
code { background: var(--bg); padding: 0 .25rem; border-radius: 2px; font-size: 12px; border: 1px solid var(--border); }
.theme-pick label { display: inline-flex; align-items: center; gap: .25rem; margin-right: 1rem; }
.ls-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .6rem; }
</style>
{{ if .HasCustomCSS }}<link rel="stylesheet" href="{{ .AssetsPathPrefix }}/custom.css">{{ end }}
</head>
<body>
<div class="breadcrumb"><a href="/">&larr; home</a> &nbsp;/&nbsp; <span>profile</span></div>
<h1>Your profile</h1>
<section class="card">
<h2>Identity</h2>
{{ if .Email }}
<p>Signed in as <code>{{ .Email }}</code>.</p>
{{ else }}
<p>Not signed in. The server reads identity from the <code>{{ .EmailHeader }}</code> header. If you expected to be authenticated, your reverse proxy or SSO gateway is not forwarding it.</p>
{{ end }}
<p class="muted">Configured email header: <code>{{ .EmailHeader }}</code></p>
</section>
<section class="card">
<h2>Effective access</h2>
<p>
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
</p>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
{{ if .Projects }}
<ul class="bare">
{{ range .Projects }}<li><a href="{{ .URL }}">{{ if .Title }}{{ .Title }}{{ else }}{{ .Name }}{{ end }}</a> <span class="muted">({{ .URL }})</span></li>{{ end }}
</ul>
{{ else }}
<p class="muted">No projects accessible.</p>
{{ end }}
{{ if .HasAnyAdminScope }}
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
{{ if .AdminSubtrees }}
<ul class="bare">
{{ range .AdminSubtrees }}<li><code>{{ .Path }}</code>{{ if .Title }} {{ .Title }}{{ end }} {{ if .CanEdit }}<span class="muted">(editable)</span>{{ else }}<span class="muted">(read-only you cannot edit the file granting your own authority)</span>{{ end }}</li>{{ end }}
</ul>
{{ else }}
<p class="muted">None.</p>
{{ end }}
{{ end }}
</section>
<section class="card">
<h2>Theme</h2>
<p class="help">Applies to every ZDDC tool you open in this browser. Stored in <code>localStorage["zddc-theme"]</code>.</p>
<div class="theme-pick">
<label><input type="radio" name="theme" value="auto"> auto (system)</label>
<label><input type="radio" name="theme" value="light"> light</label>
<label><input type="radio" name="theme" value="dark"> dark</label>
</div>
</section>
<section class="card">
<h2>Local storage</h2>
<p class="help">Browser-side state used by the ZDDC tools at this origin. The profile page can read and write it for you.</p>
<table id="ls-table"><thead><tr><th>Key</th><th>Value</th><th class="numeric">Bytes</th></tr></thead><tbody></tbody></table>
<div class="ls-actions">
<button type="button" id="ls-export">Export all (JSON)</button>
<button type="button" id="ls-import">Import from JSON</button>
<input type="file" id="ls-import-file" accept="application/json,.json" hidden>
<button type="button" id="ls-clear" class="danger">Clear all</button>
</div>
</section>
{{ if .HasAnyAdminScope }}
<section class="card">
<h2>Editable .zddc files</h2>
<p class="help">Open the form-based editor for any subtree you administer.</p>
{{ if .HasEditableSubtrees }}
<ul class="bare">
{{ range .EditableParentChoices }}<li><a href="{{ $.ProfilePathPrefix }}/zddc/edit?path={{ .Path }}"><code>{{ .Path }}/.zddc</code></a>{{ if .Title }} {{ .Title }}{{ end }}</li>{{ end }}
</ul>
{{ else }}
<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority only an admin from a higher level can.</p>
{{ end }}
</section>
<section class="card">
<h2>Create new project folder</h2>
<p class="help">Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter <code>.zddc</code> is also written; otherwise the directory is empty and inherits ACL from its ancestors.</p>
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
<form id="cp-form" autocomplete="off">
<label>Parent
<select name="parent" id="cp-parent">
{{ if .IsSuperAdmin }}<option value="/">/ (root)</option>{{ end }}
{{ range .AdminSubtrees }}<option value="{{ .Path }}">{{ .Path }}</option>{{ end }}
</select>
</label>
<label>Name
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
<span class="err" id="cp-name-err"></span>
</label>
<label>Title (optional)
<input type="text" name="title" id="cp-title" maxlength="200">
</label>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Allow (optional)</h3>
<div class="list" data-field="acl.allow"></div>
<button type="button" class="add" data-target="acl.allow">+ Add allow rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">ACL Deny (optional)</h3>
<div class="list" data-field="acl.deny"></div>
<button type="button" class="add" data-target="acl.deny">+ Add deny rule</button>
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Admins (optional)</h3>
<div class="list" data-field="admins"></div>
<button type="button" class="add" data-target="admins">+ Add admin</button>
<div style="margin-top: 1rem;">
<button type="submit" class="primary">Create</button>
</div>
</form>
</section>
{{ end }}
{{ if .IsSuperAdmin }}
<section class="card">
<h2>Server config <button type="button" data-diag="config">refresh</button></h2>
<p class="help">Effective config from environment variables. Read-only.</p>
<pre id="diag-config">loading</pre>
</section>
<section class="card">
<h2>Logs <button type="button" data-diag="logs">refresh</button>
<span class="muted">level: <select id="diag-level"><option value="">all</option><option value="debug">debug+</option><option value="info" selected>info+</option><option value="warn">warn+</option><option value="error">error</option></select></span>
</h2>
<pre id="diag-logs">loading</pre>
</section>
<section class="card">
<h2>whoami <button type="button" data-diag="whoami">refresh</button></h2>
<p class="help">Headers as they arrived at the binary. Useful for debugging SSO header passthrough.</p>
<pre id="diag-whoami">loading</pre>
</section>
{{ end }}
<script>
(function() {
var prefix = {{ .ProfilePathPrefix }};
var hasAdminScope = {{ .HasAnyAdminScope }};
var isSuper = {{ .IsSuperAdmin }};
// ── Theme ──────────────────────────────────────────────────────────────
var THEME_KEY = "zddc-theme";
function applyTheme(v) {
if (v === "light" || v === "dark") {
document.documentElement.setAttribute("data-theme", v);
} else {
document.documentElement.removeAttribute("data-theme");
}
}
var current = (function() { try { return localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { return "auto"; } })();
applyTheme(current);
document.querySelectorAll('input[name="theme"]').forEach(function(r) {
if (r.value === current) r.checked = true;
r.addEventListener("change", function() {
try { localStorage.setItem(THEME_KEY, r.value); } catch (e) {}
applyTheme(r.value);
});
});
// ── Local storage panel ────────────────────────────────────────────────
function bytes(s) { return new TextEncoder().encode(s).length; }
function renderLS() {
var tbody = document.querySelector("#ls-table tbody");
tbody.textContent = "";
var keys = [];
for (var i = 0; i < localStorage.length; i++) keys.push(localStorage.key(i));
keys.sort();
if (keys.length === 0) {
var tr = document.createElement("tr");
var td = document.createElement("td");
td.colSpan = 3; td.className = "muted"; td.textContent = "(empty)";
tr.appendChild(td); tbody.appendChild(tr);
return;
}
keys.forEach(function(k) {
var v = localStorage.getItem(k) || "";
var tr = document.createElement("tr");
var tdK = document.createElement("td"); tdK.textContent = k;
var tdV = document.createElement("td"); tdV.className = "value";
tdV.textContent = v.length > 200 ? v.slice(0, 200) + "…" : v;
var tdB = document.createElement("td"); tdB.className = "numeric"; tdB.textContent = bytes(v);
tr.appendChild(tdK); tr.appendChild(tdV); tr.appendChild(tdB);
tbody.appendChild(tr);
});
}
renderLS();
document.getElementById("ls-export").addEventListener("click", function() {
var dump = {};
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
dump[k] = localStorage.getItem(k);
}
var blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "zddc-localstorage.json";
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("ls-import").addEventListener("click", function() {
document.getElementById("ls-import-file").click();
});
document.getElementById("ls-import-file").addEventListener("change", function(ev) {
var file = ev.target.files && ev.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function() {
try {
var parsed = JSON.parse(String(reader.result || ""));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
alert("Import file must be a JSON object of string→string."); return;
}
Object.keys(parsed).forEach(function(k) {
var v = parsed[k];
if (typeof v === "string") localStorage.setItem(k, v);
});
renderLS();
alert("Imported " + Object.keys(parsed).length + " keys.");
} catch (e) {
alert("Import failed: " + e);
}
};
reader.readAsText(file);
ev.target.value = "";
});
document.getElementById("ls-clear").addEventListener("click", function() {
if (!confirm("Clear ALL localStorage at this origin? This includes theme and any saved presets.")) return;
localStorage.clear();
renderLS();
applyTheme("auto");
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
});
// ── Create project ────────────────────────────────────────────────────
if (hasAdminScope) {
function rowFor(field) {
var div = document.createElement("div"); div.className = "row";
var input = document.createElement("input");
input.type = "text"; input.dataset.field = field;
var del = document.createElement("button");
del.type = "button"; del.textContent = ""; del.className = "del";
div.appendChild(input); div.appendChild(del);
return div;
}
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
btn.addEventListener("click", function() {
var field = btn.dataset.target;
document.querySelector('#cp-form .list[data-field="' + field + '"]').appendChild(rowFor(field));
});
});
document.getElementById("cp-form").addEventListener("click", function(e) {
if (e.target && e.target.classList && e.target.classList.contains("del")) {
e.target.closest(".row").remove();
}
});
function collectList(field) {
var out = [];
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
if (i.value.trim()) out.push(i.value.trim());
});
return out;
}
document.getElementById("cp-form").addEventListener("submit", function(ev) {
ev.preventDefault();
document.getElementById("cp-name-err").textContent = "";
document.getElementById("cp-ok").hidden = true;
var allow = collectList("acl.allow");
var deny = collectList("acl.deny");
var admins = collectList("admins");
var title = document.getElementById("cp-title").value.trim();
var body = {
parent: document.getElementById("cp-parent").value,
name: document.getElementById("cp-name").value.trim()
};
if (title) body.title = title;
if (allow.length || deny.length) body.acl = { allow: allow, deny: deny };
if (admins.length) body.admins = admins;
fetch(prefix + "/projects", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
credentials: "same-origin",
body: JSON.stringify(body)
}).then(function(r) {
return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; });
}).then(function(res) {
if (res.ok) {
var parsed = {};
try { parsed = JSON.parse(res.text); } catch (e) {}
var ok = document.getElementById("cp-ok");
ok.hidden = false;
ok.textContent = "Created " + (parsed.path || "(unknown path)") + ". Reload to see it in the lists above.";
return;
}
try {
var p = JSON.parse(res.text);
if (p && p.errors && p.errors.length) {
document.getElementById("cp-name-err").textContent = p.errors.map(function(e) { return e.field + ": " + e.message; }).join("; ");
return;
}
} catch (e) {}
document.getElementById("cp-name-err").textContent = "HTTP " + res.status + ": " + res.text;
});
});
}
// ── Super-admin diagnostics ───────────────────────────────────────────
if (isSuper) {
function loadDiag(target, qs) {
var el = document.getElementById("diag-" + target);
el.classList.remove("err");
el.textContent = "loading…";
var url = prefix + "/" + target + (qs ? ("?" + qs) : "");
fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
.then(function(r) { return r.text().then(function(t) { return { ok: r.ok, status: r.status, text: t }; }); })
.then(function(res) {
if (!res.ok) {
el.classList.add("err");
el.textContent = "HTTP " + res.status + "\n\n" + res.text;
return;
}
try { el.textContent = JSON.stringify(JSON.parse(res.text), null, 2); }
catch (e) { el.textContent = res.text; }
}).catch(function(err) { el.classList.add("err"); el.textContent = String(err); });
}
document.querySelectorAll("button[data-diag]").forEach(function(b) {
b.addEventListener("click", function() {
var t = b.dataset.diag;
var qs = t === "logs" ? ("level=" + (document.getElementById("diag-level").value || "")) : "";
loadDiag(t, qs);
});
});
var lvl = document.getElementById("diag-level");
if (lvl) lvl.addEventListener("change", function() { loadDiag("logs", "level=" + (lvl.value || "")); });
loadDiag("config");
loadDiag("logs", "level=info");
loadDiag("whoami");
}
})();
</script>
</body>
</html>
`))

View file

@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// projectCreateRequest is the wire shape for POST /.profile/projects.
//
// All fields except parent and name are optional. The ACL/admins/title
// fields are bundled into a starter .zddc only if at least one is supplied;
// otherwise the new directory is left empty and inherits ACL from its
// ancestors.
type projectCreateRequest struct {
Parent string `json:"parent"`
Name string `json:"name"`
Title string `json:"title,omitempty"`
ACL *zddc.ACLRules `json:"acl,omitempty"`
Admins []string `json:"admins,omitempty"`
}
// projectCreateResponse is the success payload.
type projectCreateResponse struct {
Path string `json:"path"`
URL string `json:"url"`
}
// serveProfileProjectsCreate handles POST /.profile/projects.
//
// Authorization is delegated to CanEditZddc on the prospective new
// directory: the caller must have authority that would let them write a
// .zddc at that location (super-admin via root admins, or a strict-ancestor
// admin grant). Non-authorized callers receive 404 to keep this endpoint's
// existence hidden alongside the rest of the admin surface.
func serveProfileProjectsCreate(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Admin gate first so non-admins see 404 regardless of method, matching
// the rest of /.profile/'s existence-leakage policy.
email := EmailFromContext(r)
if !hasAnyAdminScope(cfg.Root, email) {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req projectCreateRequest
if err := dec.Decode(&req); err != nil {
http.Error(w, "Bad Request: "+err.Error(), http.StatusBadRequest)
return
}
if err := zddc.ValidateProjectName(req.Name); err != nil {
writeFieldError(w, http.StatusBadRequest, "name", err.Error())
return
}
parentAbs, err := resolvePath(cfg.Root, req.Parent)
if err != nil {
http.NotFound(w, r)
return
}
if pi, err := os.Stat(parentAbs); err != nil || !pi.IsDir() {
http.Error(w, "Parent directory not found", http.StatusBadRequest)
return
}
newDir := filepath.Join(parentAbs, req.Name)
if !zddc.CanEditZddc(cfg.Root, newDir, email) {
http.NotFound(w, r)
return
}
if _, err := os.Stat(newDir); err == nil {
http.Error(w, "Conflict: directory already exists", http.StatusConflict)
return
}
// If the body supplies any .zddc fields, validate them BEFORE we mkdir
// so a validation failure leaves no on-disk trace.
wantsZddc := req.Title != "" || (req.ACL != nil && (len(req.ACL.Allow) > 0 || len(req.ACL.Deny) > 0)) || len(req.Admins) > 0
var zf zddc.ZddcFile
if wantsZddc {
zf.Title = req.Title
if req.ACL != nil {
zf.ACL = *req.ACL
}
zf.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
}
}
if err := os.MkdirAll(newDir, 0o755); err != nil {
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
if wantsZddc {
if err := zddc.WriteFile(newDir, zf); err != nil {
// Best-effort rollback: remove the empty dir we just created.
_ = os.Remove(newDir)
http.Error(w, "Internal Server Error: "+err.Error(), http.StatusInternalServerError)
return
}
}
urlPath := urlPathOf(cfg.Root, newDir)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(projectCreateResponse{
Path: urlPath,
URL: urlPath + "/",
})
}
// writeFieldError emits a single-error writeError JSON body — used when
// validation fails for a top-level scalar field like `name`.
func writeFieldError(w http.ResponseWriter, status int, field, message string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(writeError{Errors: []zddc.FieldError{{Field: field, Message: message}}})
}

View file

@ -27,13 +27,27 @@ type ProjectInfo struct {
// It returns all top-level directories under cfg.Root that the requesting
// user has access to, as a JSON array of ProjectInfo.
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
email := EmailFromContext(r)
projects, err := EnumerateProjects(cfg, EmailFromContext(r))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(projects); err != nil {
slog.Error("encoding project list", "err", err)
}
}
// EnumerateProjects returns the visible top-level projects for the given
// caller, reusing the same access logic as ServeProjectList. Exported so
// the profile page can render the same list server-side without an HTTP
// round-trip.
func EnumerateProjects(cfg config.Config, email string) ([]ProjectInfo, error) {
entries, err := os.ReadDir(cfg.Root)
if err != nil {
slog.Error("reading root directory", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
return nil, err
}
var projects []ProjectInfo
@ -69,10 +83,5 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request)
Title: title,
})
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(projects); err != nil {
slog.Error("encoding project list", "err", err)
}
return projects, nil
}

View file

@ -23,8 +23,8 @@ func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
for _, name := range []string{
"Project-A",
"Project-B",
".devshell", // dot-prefixed dir — must be excluded
"_template", // underscore scaffolding — must be excluded
".devshell", // dot-prefixed dir — must be excluded
"_template", // underscore scaffolding — must be excluded
"_archive",
} {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {

View file

@ -9,31 +9,41 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
)
// adminCustomCSSName is the on-disk filename a server operator places at
// the root to theme the admin pages. It deliberately uses the .admin.css
// suffix (not just custom.css) so it pattern-matches the .zddc / .admin
// reserved-prefix family, and so anyone scanning the root tree sees it
// is admin-related.
const adminCustomCSSName = ".admin.css"
// profileCustomCSSName is the preferred on-disk filename for operator-supplied
// profile / editor theming. The legacy `.admin.css` is honored as a fallback
// so an operator who already deployed the older name does not see their
// styling vanish on upgrade; new deployments should use the `.profile.css`
// name.
const (
profileCustomCSSName = ".profile.css"
adminCustomCSSName = ".admin.css" // legacy fallback
)
// hasCustomAdminCSS reports whether <fsRoot>/.admin.css exists. The
// editor template uses this to conditionally inject the <link> tag.
func hasCustomAdminCSS(fsRoot string) bool {
_, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName))
return err == nil
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css (or the legacy
// .admin.css) exists. The editor and profile templates use this to decide
// whether to inject the <link> tag.
func hasCustomProfileCSS(fsRoot string) bool {
if _, err := os.Stat(filepath.Join(fsRoot, profileCustomCSSName)); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)); err == nil {
return true
}
return false
}
// zddcAssetsPathPrefix is the URL prefix for admin-only static assets.
// They sit under /.admin/zddc/assets/ rather than /.admin/assets/ so
// They sit under /.profile/zddc/assets/ rather than /.profile/assets/ so
// they share the editor's broader auth gate (subtree-or-super-admin)
// instead of /.admin/'s super-admin-only gate — otherwise a subtree
// admin would 404 on the custom CSS link emitted by the editor page.
const zddcAssetsPathPrefix = ZddcAdminPathPrefix + "/assets"
// instead of /.profile/'s super-admin-only diagnostics gate — otherwise a
// subtree admin would 404 on the custom CSS link emitted by the editor.
const zddcAssetsPathPrefix = ZddcProfilePathPrefix + "/assets"
// serveZddcAssets handles /.admin/zddc/assets/<file>. V1 only ships
// `custom.css` (passthrough of <root>/.admin.css when present); other
// paths return 404 so we don't accidentally expose arbitrary files.
// hasAnyAdminScope has already gated the request via ServeZddc.
// serveZddcAssets handles /.profile/zddc/assets/<file>. V1 only ships
// `custom.css` (passthrough of <root>/.profile.css when present, falling
// back to <root>/.admin.css); other paths return 404 so we don't
// accidentally expose arbitrary files. hasAnyAdminScope has already gated
// the request via ServeZddc.
func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
@ -43,11 +53,13 @@ func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request)
rest := strings.TrimPrefix(r.URL.Path, zddcAssetsPathPrefix+"/")
switch rest {
case "custom.css":
path := filepath.Join(cfg.Root, adminCustomCSSName)
fi, err := os.Stat(path)
if err != nil || fi.IsDir() {
http.NotFound(w, r)
return
path := filepath.Join(cfg.Root, profileCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
path = filepath.Join(cfg.Root, adminCustomCSSName)
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
http.NotFound(w, r)
return
}
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")

View file

@ -13,21 +13,21 @@ import (
// editorView is the data passed to the editor template. Field naming is
// kept short for template ergonomics.
type editorView struct {
Path string
IsRoot bool
CanEdit bool
Exists bool
Email string
HasCustomCSS bool
File zddc.ZddcFile
EffectiveChain []chainEntry
AdminPathPrefix string // /.admin
AssetsPathPrefix string // /.admin/zddc/assets
Path string
IsRoot bool
CanEdit bool
Exists bool
Email string
HasCustomCSS bool
File zddc.ZddcFile
EffectiveChain []chainEntry
ProfilePathPrefix string // /.profile
AssetsPathPrefix string // /.profile/zddc/assets
}
// serveZddcEditor renders the form-based .zddc editor at
// GET /.admin/zddc/edit?path=<dir>. The form posts JSON back to
// /.admin/zddc?path=<dir>; the inline JS shim handles dynamic-row
// GET /.profile/zddc/edit?path=<dir>. The form posts JSON back to
// /.profile/zddc?path=<dir>; the inline JS shim handles dynamic-row
// add/remove and surfaces field errors from the JSON response.
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
@ -71,16 +71,16 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request)
}
view := editorView{
Path: urlPathOf(cfg.Root, abs),
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
Exists: exists,
Email: email,
HasCustomCSS: hasCustomAdminCSS(cfg.Root),
File: zf,
EffectiveChain: entries,
AdminPathPrefix: AdminPathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
Path: urlPathOf(cfg.Root, abs),
IsRoot: abs == cfg.Root,
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
Exists: exists,
Email: email,
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
File: zf,
EffectiveChain: entries,
ProfilePathPrefix: ProfilePathPrefix,
AssetsPathPrefix: zddcAssetsPathPrefix,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -159,8 +159,8 @@ var editorTemplate = template.Must(template.New("editor").Parse(`<!DOCTYPE html>
</head>
<body>
<div class="breadcrumb">
<a href="{{ .AdminPathPrefix }}/">&larr; admin</a> &nbsp;/&nbsp;
<a href="{{ .AdminPathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} &nbsp;/&nbsp; <span>{{ .Path }}</span>{{ end }}
<a href="{{ .ProfilePathPrefix }}/">&larr; profile</a> &nbsp;/&nbsp;
<a href="{{ .ProfilePathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} &nbsp;/&nbsp; <span>{{ .Path }}</span>{{ end }}
</div>
<h1>.zddc editor</h1>
@ -236,7 +236,7 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
var path = {{ .Path }};
var canEdit = {{ .CanEdit }};
var isRoot = {{ .IsRoot }};
var apiURL = "{{ .AdminPathPrefix }}/zddc?path=" + encodeURIComponent(path);
var apiURL = "{{ .ProfilePathPrefix }}/zddc?path=" + encodeURIComponent(path);
function rowFor(field, value) {
var div = document.createElement("div");

View file

@ -12,24 +12,24 @@ import (
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// ZddcAdminPathPrefix is the URL prefix for the .zddc editor (both API and
// 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 /.admin gate.
const ZddcAdminPathPrefix = AdminPathPrefix + "/zddc"
// receive 404 to keep editor existence hidden, matching the /.profile gate.
const ZddcProfilePathPrefix = ProfilePathPrefix + "/zddc"
// ServeZddc dispatches all /.admin/zddc/* routes. ServeAdmin already
// trimmed the /.admin prefix and confirmed at least the super-admin gate,
// but this handler is also reachable for subtree-only admins, so it
// re-checks authorization itself and bypasses the super-admin requirement
// imposed at the top of ServeAdmin.
// 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 /.admin/zddc?path=<dir> → JSON: parsed file + chain
// POST /.admin/zddc?path=<dir> → write (JSON body)
// DELETE /.admin/zddc?path=<dir> → remove file
// GET /.admin/zddc/tree → JSON: list of editable dirs
// GET /.admin/zddc/edit?path=<dir> → server-rendered editor page
//
// 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)
@ -40,8 +40,8 @@ func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
// r.URL.Path is the full URL path; sub-route is everything after
// /.admin/zddc.
sub := strings.TrimPrefix(r.URL.Path, ZddcAdminPathPrefix)
// /.profile/zddc.
sub := strings.TrimPrefix(r.URL.Path, ZddcProfilePathPrefix)
switch {
case sub == "" || sub == "/":
@ -128,20 +128,20 @@ func urlPathOf(fsRoot, abs string) string {
// 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"`
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"`
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"`
EffectiveChain []chainEntry `json:"effective_chain"`
}
type zddcWriteRequest struct {
@ -154,7 +154,7 @@ type writeError struct {
Errors []zddc.FieldError `json:"errors"`
}
// serveZddcAPI handles /.admin/zddc?path=<dir> for GET, POST, DELETE.
// 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"))
@ -346,4 +346,3 @@ func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, out)
}

View file

@ -62,8 +62,8 @@ func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do
func TestServeZddcAuthGate(t *testing.T) {
// root admin = root@example.com; subtree admin alice@example.com on /projects.
cfg, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
})
@ -74,18 +74,18 @@ func TestServeZddcAuthGate(t *testing.T) {
email string
wantStatus int
}{
{"anon GET root", http.MethodGet, "/.admin/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.admin/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.admin/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.admin/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.admin/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.admin/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.admin/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.admin/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.admin/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
{"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK},
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK},
{"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
}
_ = cfg
@ -93,7 +93,7 @@ func TestServeZddcAuthGate(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
body := ""
if tc.method == http.MethodPost {
if tc.target == "/.admin/zddc?path=/" {
if tc.target == "/.profile/zddc?path=/" {
// Root POST: writer must remain in admins list.
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
} else {
@ -114,7 +114,7 @@ func TestServeZddcGetReturnsChain(t *testing.T) {
"projects": "title: All Projects\n",
"projects/sub": "title: Substation\n",
})
rec := do(http.MethodGet, "/.admin/zddc?path=/projects/sub", "root@example.com", "")
rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
@ -151,7 +151,7 @@ func TestServeZddcPostValidatesGlob(t *testing.T) {
"projects": "",
})
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/projects", "root@example.com", body)
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
@ -170,7 +170,7 @@ func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
})
// root tries to remove themselves, leaving only bob.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/", "root@example.com", body)
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
}
@ -182,7 +182,7 @@ func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
})
// root adds bob alongside themselves — fine.
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/", "root@example.com", body)
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
@ -194,11 +194,11 @@ func TestServeZddcWriteRoundTrip(t *testing.T) {
"projects": "",
})
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
rec := do(http.MethodPost, "/.admin/zddc?path=/projects", "root@example.com", body)
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
if rec.Code != http.StatusOK {
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
}
rec = do(http.MethodGet, "/.admin/zddc?path=/projects", "root@example.com", "")
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
}
@ -216,13 +216,13 @@ func TestServeZddcWriteRoundTrip(t *testing.T) {
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
_, do := zddcTestSetup(t, map[string]string{
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
"": "admins:\n - root@example.com\n",
"alpha": "admins:\n - alice@example.com\n",
"alpha/x": "title: alpha-x\n",
"beta": "admins:\n - bob@example.com\n",
})
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
rec := do(http.MethodGet, "/.admin/zddc/tree", "alice@example.com", "")
rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -247,7 +247,7 @@ func TestServeZddcEditorRenders(t *testing.T) {
"": "admins:\n - root@example.com\n",
"projects": "title: Engineering\n",
})
rec := do(http.MethodGet, "/.admin/zddc/edit?path=/projects", "root@example.com", "")
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -255,8 +255,8 @@ func TestServeZddcEditorRenders(t *testing.T) {
if !strings.Contains(body, "Engineering") {
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
}
if !strings.Contains(body, "/.admin/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.admin/zddc?path=")
if !strings.Contains(body, "/.profile/zddc?path=") {
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
}
if !strings.Contains(body, "Subtree admins of /projects") {
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
@ -269,7 +269,7 @@ func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
"projects": "admins:\n - alice@example.com\n",
})
// alice viewing her own grant file: read-only.
rec := do(http.MethodGet, "/.admin/zddc/edit?path=/projects", "alice@example.com", "")
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "")
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -284,7 +284,7 @@ func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
"": "admins:\n - root@example.com\n",
})
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
rec := do(http.MethodGet, "/.admin/zddc?path="+p, "root@example.com", "")
rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "")
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
}
@ -292,8 +292,8 @@ func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
}
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
// Confirm that putting /.admin/zddc/* under the broader gate did not
// regress the super-admin gate on /.admin/whoami etc.
// Confirm that putting /.profile/zddc/* under the broader gate did not
// regress the super-admin gate on /.profile/whoami etc.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
t.Fatalf("write .zddc: %v", err)
@ -301,20 +301,20 @@ func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/whoami", nil)
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeAdmin(cfg, nil, rec, req)
ServeProfile(cfg, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.admin/whoami got %d, want 404", rec.Code)
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/.admin/whoami", nil)
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeAdmin(cfg, nil, rec, req)
ServeProfile(cfg, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.admin/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
}
@ -329,7 +329,7 @@ func TestServeZddcAssetsCustomCSS(t *testing.T) {
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/zddc/assets/custom.css", nil)
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)
@ -352,7 +352,7 @@ func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
zddc.InvalidateCache(root)
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
req := httptest.NewRequest(http.MethodGet, "/.admin/zddc/assets/custom.css", nil)
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeZddc(cfg, rec, req)

View file

@ -17,11 +17,11 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) {
for _, name := range []string{
"Project-A",
"Project-B",
".zddc", // hidden file
".devshell", // hidden dir
"_template", // scaffolding dir
"_archive", // scaffolding dir
"_notes.txt", // scaffolding file
".zddc", // hidden file
".devshell", // hidden dir
"_template", // scaffolding dir
"_archive", // scaffolding dir
"_notes.txt", // scaffolding file
"normal.txt",
} {
path := filepath.Join(dir, name)

View file

@ -5,9 +5,9 @@ import "time"
// FileInfo matches Caddy's browse JSON output exactly.
// The archive browser (source.js) expects this exact shape.
type FileInfo struct {
Name string `json:"name"` // filename; directories have a trailing "/"
Name string `json:"name"` // filename; directories have a trailing "/"
Size int64 `json:"size"`
URL string `json:"url"` // relative URL to the item
URL string `json:"url"` // relative URL to the item
ModTime time.Time `json:"mod_time"`
Mode uint32 `json:"mode"`
IsDir bool `json:"is_dir"`

View file

@ -5,7 +5,7 @@ import "path/filepath"
// IsAdmin reports whether email is listed in the admins entry of the ROOT
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
// deliberately ignored by this function — it gates the server-wide debug
// admin role (/.admin/{whoami,config,logs}) which only the bootstrap
// admin role (/.profile/{whoami,config,logs}) which only the bootstrap
// super-admin should reach.
//
// Subtree-scoped admin authority (the "fiefdom" model) is checked via

View file

@ -152,8 +152,8 @@ func TestIsSubtreeAdmin(t *testing.T) {
{
name: "root admin → admin of any subtree",
files: map[string]string{
"": "admins:\n - alice@example.com\n",
"projects/x": "",
"": "admins:\n - alice@example.com\n",
"projects/x": "",
},
dir: "projects/x",
email: "alice@example.com",
@ -162,9 +162,9 @@ func TestIsSubtreeAdmin(t *testing.T) {
{
name: "subtree admin granted at intermediate level",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/x": "",
},
dir: "projects/x",
email: "alice@example.com",
@ -173,8 +173,8 @@ func TestIsSubtreeAdmin(t *testing.T) {
{
name: "subtree admin granted at the leaf level itself",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
},
dir: "projects",
email: "alice@example.com",
@ -193,9 +193,9 @@ func TestIsSubtreeAdmin(t *testing.T) {
{
name: "admin granted in sibling subtree does not leak",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"foo": "admins:\n - alice@example.com\n",
"bar": "",
"": "admins:\n - root@example.com\n",
"foo": "admins:\n - alice@example.com\n",
"bar": "",
},
dir: "bar",
email: "alice@example.com",
@ -264,7 +264,7 @@ func TestCanEditZddc(t *testing.T) {
want: false,
},
{
name: "no zddc files at all → nobody edits root",
name: "no zddc files at all → nobody edits root",
files: map[string]string{},
dir: "",
email: "anyone@example.com",
@ -314,9 +314,9 @@ func TestCanEditZddc(t *testing.T) {
{
name: "subtree admin CANNOT edit sibling's grant file",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"foo": "admins:\n - alice@example.com\n",
"bar": "admins:\n - bob@example.com\n",
"": "admins:\n - root@example.com\n",
"foo": "admins:\n - alice@example.com\n",
"bar": "admins:\n - bob@example.com\n",
},
dir: "bar",
email: "alice@example.com",
@ -325,9 +325,9 @@ func TestCanEditZddc(t *testing.T) {
{
name: "two-level delegation — mid-level admin edits leaf below their grant",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"projects/sub/x": "",
},
dir: "projects/sub/x",
@ -348,9 +348,9 @@ func TestCanEditZddc(t *testing.T) {
{
name: "two-level delegation — bob can still edit deeper",
files: map[string]string{
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"": "admins:\n - root@example.com\n",
"projects": "admins:\n - alice@example.com\n",
"projects/sub": "admins:\n - bob@example.com\n",
"projects/sub/x": "",
},
dir: "projects/sub/x",

View file

@ -53,6 +53,39 @@ type FieldError struct {
Message string `json:"message"`
}
// ValidateProjectName returns an error if name is not acceptable as a new
// directory name created under cfg.Root. The rules mirror the reserved-prefix
// policy enforced elsewhere (resolvePath, ScanZddcFiles, ServeProjectList) so
// a project created here is enumerable by the same listing code.
//
// Rules:
// - length 1..64
// - first char alphanumeric (rejects leading '.' and '_', matching the
// hidden-segment convention)
// - subsequent chars alphanumeric, '-', or '_'
// - rejects path separators, whitespace, and any '.' anywhere (so "..",
// ".hidden", "foo.bar" all fail — directory names stay flat)
func ValidateProjectName(name string) error {
if name == "" {
return fmt.Errorf("name is empty")
}
if len(name) > 64 {
return fmt.Errorf("name exceeds 64 characters")
}
for i, r := range name {
switch {
case r >= 'A' && r <= 'Z':
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case (r == '-' || r == '_') && i > 0:
// allowed in the body, not as the leading character
default:
return fmt.Errorf("name contains invalid character %q at position %d", r, i)
}
}
return nil
}
func ValidateFile(zf ZddcFile) []FieldError {
var errs []FieldError
check := func(field string, vals []string) {

View file

@ -64,6 +64,44 @@ func TestValidateFile(t *testing.T) {
}
}
func TestValidateProjectName(t *testing.T) {
cases := []struct {
name string
ok bool
}{
{"alpha", true},
{"Alpha", true},
{"a", true},
{"a1", true},
{"a-1", true},
{"a_b", true},
{"123-project", true},
{"Site-3", true},
{"", false},
{".hidden", false},
{"_template", false},
{"-leading-dash", false},
{"foo bar", false},
{"foo/bar", false},
{"foo\\bar", false},
{"foo.bar", false},
{"..", false},
{".", false},
{string(make([]byte, 65)), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateProjectName(tc.name)
if tc.ok && err != nil {
t.Errorf("ValidateProjectName(%q) = %v, want nil", tc.name, err)
}
if !tc.ok && err == nil {
t.Errorf("ValidateProjectName(%q) = nil, want error", tc.name)
}
})
}
}
func TestValidateFileTitleLength(t *testing.T) {
long := make([]byte, 201)
for i := range long {