diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 2de0965..d0c3291 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 /.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) { diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 2d91ab2..77e7f58 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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}, diff --git a/zddc/internal/archive/index.go b/zddc/internal/archive/index.go index 884e7f2..c081e46 100644 --- a/zddc/internal/archive/index.go +++ b/zddc/internal/archive/index.go @@ -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. diff --git a/zddc/internal/archive/index_test.go b/zddc/internal/archive/index_test.go index 855a642..4c6e3eb 100644 --- a/zddc/internal/archive/index_test.go +++ b/zddc/internal/archive/index_test.go @@ -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 { diff --git a/zddc/internal/archive/watcher.go b/zddc/internal/archive/watcher.go index fd649e4..2e63a1e 100644 --- a/zddc/internal/archive/watcher.go +++ b/zddc/internal/archive/watcher.go @@ -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 diff --git a/zddc/internal/handler/adminhandler.go b/zddc/internal/handler/adminhandler.go deleted file mode 100644 index cba1411..0000000 --- a/zddc/internal/handler/adminhandler.go +++ /dev/null @@ -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= — 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 = ` - - - -zddc-server — admin debug - - - - -

zddc-server admin debug

-

Live state from the running process. Fetched client-side; refresh each section with its button.

- -

whoami

-

What headers actually arrived. The configured_email_header is the header name the binary is reading; observed_email is the value at that name; headers is everything received.

-
loading…
- -

config

-

Effective config from environment variables. tls_cert / tls_key show the supplied path strings; file contents are not read.

-
loading…
- -

logs level:

-
loading…
- - - - -` - -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) -} diff --git a/zddc/internal/handler/adminhandler_test.go b/zddc/internal/handler/adminhandler_test.go deleted file mode 100644 index 9e7d008..0000000 --- a/zddc/internal/handler/adminhandler_test.go +++ /dev/null @@ -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{"", "/.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) - } -} diff --git a/zddc/internal/handler/archivehandler_test.go b/zddc/internal/handler/archivehandler_test.go index 325d70d..262aefb 100644 --- a/zddc/internal/handler/archivehandler_test.go +++ b/zddc/internal/handler/archivehandler_test.go @@ -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. // -// / -// 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 +// / +// 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() diff --git a/zddc/internal/handler/logring.go b/zddc/internal/handler/logring.go index 950ead7..1ea8926 100644 --- a/zddc/internal/handler/logring.go +++ b/zddc/internal/handler/logring.go @@ -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 } diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go new file mode 100644 index 0000000..9661039 --- /dev/null +++ b/zddc/internal/handler/profilehandler.go @@ -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=. +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 + } +} diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go new file mode 100644 index 0000000..6da9848 --- /dev/null +++ b/zddc/internal/handler/profilehandler_test.go @@ -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) + } + } +} diff --git a/zddc/internal/handler/profilepage.go b/zddc/internal/handler/profilepage.go new file mode 100644 index 0000000..6d93960 --- /dev/null +++ b/zddc/internal/handler/profilepage.go @@ -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(` + + + +zddc-server — profile + + +{{ if .HasCustomCSS }}{{ end }} + + + + +

Your profile

+ +
+

Identity

+ {{ if .Email }} +

Signed in as {{ .Email }}.

+ {{ else }} +

Not signed in. The server reads identity from the {{ .EmailHeader }} header. If you expected to be authenticated, your reverse proxy or SSO gateway is not forwarding it.

+ {{ end }} +

Configured email header: {{ .EmailHeader }}

+
+ +
+

Effective access

+

+ Super-admin: {{ if .IsSuperAdmin }}yes{{ else }}no{{ end }} +

+

Visible projects

+ {{ if .Projects }} + + {{ else }} +

No projects accessible.

+ {{ end }} + {{ if .HasAnyAdminScope }} +

Subtrees you administer

+ {{ if .AdminSubtrees }} +
    + {{ range .AdminSubtrees }}
  • {{ .Path }}{{ if .Title }} — {{ .Title }}{{ end }} {{ if .CanEdit }}(editable){{ else }}(read-only — you cannot edit the file granting your own authority){{ end }}
  • {{ end }} +
+ {{ else }} +

None.

+ {{ end }} + {{ end }} +
+ +
+

Theme

+

Applies to every ZDDC tool you open in this browser. Stored in localStorage["zddc-theme"].

+
+ + + +
+
+ +
+

Local storage

+

Browser-side state used by the ZDDC tools at this origin. The profile page can read and write it for you.

+
KeyValueBytes
+
+ + + + +
+
+ +{{ if .HasAnyAdminScope }} +
+

Editable .zddc files

+

Open the form-based editor for any subtree you administer.

+ {{ if .HasEditableSubtrees }} +
    + {{ range .EditableParentChoices }}
  • {{ .Path }}/.zddc{{ if .Title }} — {{ .Title }}{{ end }}
  • {{ end }} +
+ {{ else }} +

No .zddc files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.

+ {{ end }} +
+ +
+

Create new project folder

+

Creates a directory under the chosen parent. If you fill in any of title / allow / deny / admins, a starter .zddc is also written; otherwise the directory is empty and inherits ACL from its ancestors.

+ +
+ + + +

ACL — Allow (optional)

+
+ +

ACL — Deny (optional)

+
+ +

Admins (optional)

+
+ +
+ +
+
+
+{{ end }} + +{{ if .IsSuperAdmin }} +
+

Server config

+

Effective config from environment variables. Read-only.

+
loading…
+
+ +
+

Logs + level: +

+
loading…
+
+ +
+

whoami

+

Headers as they arrived at the binary. Useful for debugging SSO header passthrough.

+
loading…
+
+{{ end }} + + + + +`)) diff --git a/zddc/internal/handler/profileprojects.go b/zddc/internal/handler/profileprojects.go new file mode 100644 index 0000000..a5a33a9 --- /dev/null +++ b/zddc/internal/handler/profileprojects.go @@ -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}}}) +} diff --git a/zddc/internal/handler/projectshandler.go b/zddc/internal/handler/projectshandler.go index 4572684..8c9152a 100644 --- a/zddc/internal/handler/projectshandler.go +++ b/zddc/internal/handler/projectshandler.go @@ -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 } diff --git a/zddc/internal/handler/projectshandler_test.go b/zddc/internal/handler/projectshandler_test.go index 3dd2ced..1004451 100644 --- a/zddc/internal/handler/projectshandler_test.go +++ b/zddc/internal/handler/projectshandler_test.go @@ -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 { diff --git a/zddc/internal/handler/zddc_assets.go b/zddc/internal/handler/zddc_assets.go index 7a11c04..73f6317 100644 --- a/zddc/internal/handler/zddc_assets.go +++ b/zddc/internal/handler/zddc_assets.go @@ -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 /.admin.css exists. The -// editor template uses this to conditionally inject the tag. -func hasCustomAdminCSS(fsRoot string) bool { - _, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName)) - return err == nil +// hasCustomProfileCSS reports whether /.profile.css (or the legacy +// .admin.css) exists. The editor and profile templates use this to decide +// whether to inject the 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/. V1 only ships -// `custom.css` (passthrough of /.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/. V1 only ships +// `custom.css` (passthrough of /.profile.css when present, falling +// back to /.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") diff --git a/zddc/internal/handler/zddceditor.go b/zddc/internal/handler/zddceditor.go index 27bbbf5..6206a44 100644 --- a/zddc/internal/handler/zddceditor.go +++ b/zddc/internal/handler/zddceditor.go @@ -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=. The form posts JSON back to -// /.admin/zddc?path=; the inline JS shim handles dynamic-row +// GET /.profile/zddc/edit?path=. The form posts JSON back to +// /.profile/zddc?path=; 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(`

.zddc editor

@@ -236,7 +236,7 @@ admins:{{ range .Admins }} {{ . }}{{ end }}{{ 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"); diff --git a/zddc/internal/handler/zddchandler.go b/zddc/internal/handler/zddchandler.go index 87e20cf..644bc5b 100644 --- a/zddc/internal/handler/zddchandler.go +++ b/zddc/internal/handler/zddchandler.go @@ -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= → JSON: parsed file + chain -// POST /.admin/zddc?path= → write (JSON body) -// DELETE /.admin/zddc?path= → remove file -// GET /.admin/zddc/tree → JSON: list of editable dirs -// GET /.admin/zddc/edit?path= → server-rendered editor page +// +// GET /.profile/zddc?path= → JSON: parsed file + chain +// POST /.profile/zddc?path= → write (JSON body) +// DELETE /.profile/zddc?path= → remove file +// GET /.profile/zddc/tree → JSON: list of editable dirs +// GET /.profile/zddc/edit?path= → server-rendered editor page func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) @@ -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= for GET, POST, DELETE. +// serveZddcAPI handles /.profile/zddc?path= for GET, POST, DELETE. func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) { email := EmailFromContext(r) abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path")) @@ -346,4 +346,3 @@ func serveZddcTree(cfg config.Config, w http.ResponseWriter, r *http.Request) { } writeJSON(w, out) } - diff --git a/zddc/internal/handler/zddchandler_test.go b/zddc/internal/handler/zddchandler_test.go index 7ce60ac..f401fdb 100644 --- a/zddc/internal/handler/zddchandler_test.go +++ b/zddc/internal/handler/zddchandler_test.go @@ -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) diff --git a/zddc/internal/listing/listing_test.go b/zddc/internal/listing/listing_test.go index 95e9a88..a916fd4 100644 --- a/zddc/internal/listing/listing_test.go +++ b/zddc/internal/listing/listing_test.go @@ -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) diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 6934a6f..992434b 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -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"` diff --git a/zddc/internal/zddc/admin.go b/zddc/internal/zddc/admin.go index cfea0c0..29976b8 100644 --- a/zddc/internal/zddc/admin.go +++ b/zddc/internal/zddc/admin.go @@ -5,7 +5,7 @@ import "path/filepath" // IsAdmin reports whether email is listed in the admins entry of the ROOT // .zddc file (/.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 diff --git a/zddc/internal/zddc/admin_test.go b/zddc/internal/zddc/admin_test.go index 191760d..b009752 100644 --- a/zddc/internal/zddc/admin_test.go +++ b/zddc/internal/zddc/admin_test.go @@ -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", diff --git a/zddc/internal/zddc/validate.go b/zddc/internal/zddc/validate.go index 986a64d..fe052a2 100644 --- a/zddc/internal/zddc/validate.go +++ b/zddc/internal/zddc/validate.go @@ -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) { diff --git a/zddc/internal/zddc/validate_test.go b/zddc/internal/zddc/validate_test.go index 597c3dc..6fb3f18 100644 --- a/zddc/internal/zddc/validate_test.go +++ b/zddc/internal/zddc/validate_test.go @@ -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 {