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:
parent
916e53d873
commit
cb46c2ef8c
25 changed files with 1617 additions and 695 deletions
|
|
@ -110,11 +110,12 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, w ht
|
||||||
urlPath := r.URL.Path
|
urlPath := r.URL.Path
|
||||||
email := handler.EmailFromContext(r)
|
email := handler.EmailFromContext(r)
|
||||||
|
|
||||||
// Admin debug routes — gated by IsAdmin allowlist in <root>/.zddc.
|
// Profile routes — the page itself is reachable to anyone (anonymous
|
||||||
// Non-admins receive 404 (not 403) so the existence of the admin page
|
// included); admin-only sub-resources (whoami / config / logs /
|
||||||
// is invisible to unauthorized callers.
|
// projects / .zddc editor) keep their existing per-resource 404
|
||||||
if urlPath == handler.AdminPathPrefix || strings.HasPrefix(urlPath, handler.AdminPathPrefix+"/") {
|
// existence-leakage gates inside ServeProfile.
|
||||||
handler.ServeAdmin(cfg, ring, w, r)
|
if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
|
||||||
|
handler.ServeProfile(cfg, ring, w, r)
|
||||||
return
|
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
|
// 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
|
// 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
|
// 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.
|
// handled below) are explicitly allowed through.
|
||||||
for _, seg := range segments {
|
for _, seg := range segments {
|
||||||
if seg == "" || !strings.HasPrefix(seg, ".") {
|
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
|
// setupLogger installs a slog default that fans every record out to stderr
|
||||||
// (the existing TextHandler — user-visible logging is unchanged) AND to an
|
// (the existing TextHandler — user-visible logging is unchanged) AND to an
|
||||||
// in-memory ring buffer that backs the /.admin/logs endpoint. Returns the
|
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns
|
||||||
// ring so handlers can read it.
|
// the ring so handlers can read it.
|
||||||
func setupLogger(level string) *handler.LogRing {
|
func setupLogger(level string) *handler.LogRing {
|
||||||
var l slog.Level
|
var l slog.Level
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
|
|
||||||
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
||||||
// rejects requests whose URL contains a dot-prefixed segment (other than
|
// 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
|
// The guard exists so the in-image dev-shell can keep persistent state
|
||||||
// (settings, source clones, Go module cache) under /srv/.devshell on the
|
// (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},
|
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
||||||
|
|
||||||
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
|
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
|
||||||
// through to its own handler (which 404s on missing tracking number,
|
// through to its own handler (which 404s on missing tracking number);
|
||||||
// but importantly NOT via the dot-prefix guard); .admin is handled
|
// .profile is handled by ServeProfile and the page itself is public.
|
||||||
// by an earlier dispatch branch and hits the IsAdmin gate.
|
// /.admin no longer exists — it is hard-cut and falls through to the
|
||||||
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler, status matches
|
// dot-prefix guard, which 404s.
|
||||||
{".admin not blocked by guard", "/.admin/whoami", http.StatusNotFound}, // no admins configured → IsAdmin false → 404 from admin handler
|
{".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.
|
// Normal files unaffected.
|
||||||
{"plain file", "/Project-A/doc.txt", http.StatusOK},
|
{"plain file", "/Project-A/doc.txt", http.StatusOK},
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ type RevisionEntry struct {
|
||||||
|
|
||||||
// TrackingEntry holds all revision data for one tracking number.
|
// TrackingEntry holds all revision data for one tracking number.
|
||||||
type TrackingEntry struct {
|
type TrackingEntry struct {
|
||||||
HighestBaseRev string // highest base revision (for trackingNumber.html)
|
HighestBaseRev string // highest base revision (for trackingNumber.html)
|
||||||
ByRevision map[string]*RevisionEntry // base revision → entry
|
ByRevision map[string]*RevisionEntry // base revision → entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index is the in-memory archive index.
|
// Index is the in-memory archive index.
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,11 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
||||||
|
|
||||||
// Highest-rev shortcut + each per-rev redirect should be present.
|
// Highest-rev shortcut + each per-rev redirect should be present.
|
||||||
wantNames := []string{
|
wantNames := []string{
|
||||||
"123.html", // highest of 123 → A
|
"123.html", // highest of 123 → A
|
||||||
"123_A.html", // explicit A
|
"123_A.html", // explicit A
|
||||||
"123_~A.html", // explicit draft
|
"123_~A.html", // explicit draft
|
||||||
"456.html", // highest of 456 → 0
|
"456.html", // highest of 456 → 0
|
||||||
"456_0.html", // explicit 0
|
"456_0.html", // explicit 0
|
||||||
}
|
}
|
||||||
for _, n := range wantNames {
|
for _, n := range wantNames {
|
||||||
if _, ok := got[n]; !ok {
|
if _, ok := got[n]; !ok {
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watcher watches fsRoot for filesystem changes and updates the archive index
|
// Watcher watches fsRoot for filesystem changes and updates the archive index
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -19,13 +19,13 @@ import (
|
||||||
// archiveTestRoot lays down a two-project tree so listings exercise scope and
|
// 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.
|
// ACL cascading. ACLs are written per-test in the helper that calls this.
|
||||||
//
|
//
|
||||||
// <root>/
|
// <root>/
|
||||||
// ProjectA/
|
// ProjectA/
|
||||||
// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
|
// 2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
|
||||||
// 2025-01-01_T1 (IFR) - Title/100_A (IFC) - 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
|
// 2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf
|
||||||
// ProjectB/
|
// ProjectB/
|
||||||
// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
|
// 2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
|
||||||
func archiveTestRoot(t *testing.T) (string, *archive.Index) {
|
func archiveTestRoot(t *testing.T) (string, *archive.Index) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ func (h *RingHandler) WithGroup(name string) slog.Handler {
|
||||||
|
|
||||||
// MultiHandler fans out each Handle call to every wrapped handler. Used to
|
// 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
|
// 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 {
|
type MultiHandler struct {
|
||||||
handlers []slog.Handler
|
handlers []slog.Handler
|
||||||
}
|
}
|
||||||
|
|
|
||||||
241
zddc/internal/handler/profilehandler.go
Normal file
241
zddc/internal/handler/profilehandler.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
498
zddc/internal/handler/profilehandler_test.go
Normal file
498
zddc/internal/handler/profilehandler_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
464
zddc/internal/handler/profilepage.go
Normal file
464
zddc/internal/handler/profilepage.go
Normal 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="/">← home</a> / <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>
|
||||||
|
`))
|
||||||
134
zddc/internal/handler/profileprojects.go
Normal file
134
zddc/internal/handler/profileprojects.go
Normal 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}}})
|
||||||
|
}
|
||||||
|
|
@ -27,13 +27,27 @@ type ProjectInfo struct {
|
||||||
// It returns all top-level directories under cfg.Root that the requesting
|
// It returns all top-level directories under cfg.Root that the requesting
|
||||||
// user has access to, as a JSON array of ProjectInfo.
|
// user has access to, as a JSON array of ProjectInfo.
|
||||||
func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
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)
|
entries, err := os.ReadDir(cfg.Root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("reading root directory", "err", err)
|
slog.Error("reading root directory", "err", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
return nil, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var projects []ProjectInfo
|
var projects []ProjectInfo
|
||||||
|
|
@ -69,10 +83,5 @@ func ServeProjectList(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
Title: title,
|
Title: title,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return projects, nil
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ func TestServeProjectListFiltersHiddenAndScaffolding(t *testing.T) {
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"Project-A",
|
"Project-A",
|
||||||
"Project-B",
|
"Project-B",
|
||||||
".devshell", // dot-prefixed dir — must be excluded
|
".devshell", // dot-prefixed dir — must be excluded
|
||||||
"_template", // underscore scaffolding — must be excluded
|
"_template", // underscore scaffolding — must be excluded
|
||||||
"_archive",
|
"_archive",
|
||||||
} {
|
} {
|
||||||
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -9,31 +9,41 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminCustomCSSName is the on-disk filename a server operator places at
|
// profileCustomCSSName is the preferred on-disk filename for operator-supplied
|
||||||
// the root to theme the admin pages. It deliberately uses the .admin.css
|
// profile / editor theming. The legacy `.admin.css` is honored as a fallback
|
||||||
// suffix (not just custom.css) so it pattern-matches the .zddc / .admin
|
// so an operator who already deployed the older name does not see their
|
||||||
// reserved-prefix family, and so anyone scanning the root tree sees it
|
// styling vanish on upgrade; new deployments should use the `.profile.css`
|
||||||
// is admin-related.
|
// name.
|
||||||
const adminCustomCSSName = ".admin.css"
|
const (
|
||||||
|
profileCustomCSSName = ".profile.css"
|
||||||
|
adminCustomCSSName = ".admin.css" // legacy fallback
|
||||||
|
)
|
||||||
|
|
||||||
// hasCustomAdminCSS reports whether <fsRoot>/.admin.css exists. The
|
// hasCustomProfileCSS reports whether <fsRoot>/.profile.css (or the legacy
|
||||||
// editor template uses this to conditionally inject the <link> tag.
|
// .admin.css) exists. The editor and profile templates use this to decide
|
||||||
func hasCustomAdminCSS(fsRoot string) bool {
|
// whether to inject the <link> tag.
|
||||||
_, err := os.Stat(filepath.Join(fsRoot, adminCustomCSSName))
|
func hasCustomProfileCSS(fsRoot string) bool {
|
||||||
return err == nil
|
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.
|
// 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)
|
// they share the editor's broader auth gate (subtree-or-super-admin)
|
||||||
// instead of /.admin/'s super-admin-only gate — otherwise a subtree
|
// instead of /.profile/'s super-admin-only diagnostics gate — otherwise a
|
||||||
// admin would 404 on the custom CSS link emitted by the editor page.
|
// subtree admin would 404 on the custom CSS link emitted by the editor.
|
||||||
const zddcAssetsPathPrefix = ZddcAdminPathPrefix + "/assets"
|
const zddcAssetsPathPrefix = ZddcProfilePathPrefix + "/assets"
|
||||||
|
|
||||||
// serveZddcAssets handles /.admin/zddc/assets/<file>. V1 only ships
|
// serveZddcAssets handles /.profile/zddc/assets/<file>. V1 only ships
|
||||||
// `custom.css` (passthrough of <root>/.admin.css when present); other
|
// `custom.css` (passthrough of <root>/.profile.css when present, falling
|
||||||
// paths return 404 so we don't accidentally expose arbitrary files.
|
// back to <root>/.admin.css); other paths return 404 so we don't
|
||||||
// hasAnyAdminScope has already gated the request via ServeZddc.
|
// accidentally expose arbitrary files. hasAnyAdminScope has already gated
|
||||||
|
// the request via ServeZddc.
|
||||||
func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func serveZddcAssets(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
w.Header().Set("Allow", "GET")
|
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+"/")
|
rest := strings.TrimPrefix(r.URL.Path, zddcAssetsPathPrefix+"/")
|
||||||
switch rest {
|
switch rest {
|
||||||
case "custom.css":
|
case "custom.css":
|
||||||
path := filepath.Join(cfg.Root, adminCustomCSSName)
|
path := filepath.Join(cfg.Root, profileCustomCSSName)
|
||||||
fi, err := os.Stat(path)
|
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
|
||||||
if err != nil || fi.IsDir() {
|
path = filepath.Join(cfg.Root, adminCustomCSSName)
|
||||||
http.NotFound(w, r)
|
if fi, err := os.Stat(path); err != nil || fi.IsDir() {
|
||||||
return
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,21 @@ import (
|
||||||
// editorView is the data passed to the editor template. Field naming is
|
// editorView is the data passed to the editor template. Field naming is
|
||||||
// kept short for template ergonomics.
|
// kept short for template ergonomics.
|
||||||
type editorView struct {
|
type editorView struct {
|
||||||
Path string
|
Path string
|
||||||
IsRoot bool
|
IsRoot bool
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
Exists bool
|
Exists bool
|
||||||
Email string
|
Email string
|
||||||
HasCustomCSS bool
|
HasCustomCSS bool
|
||||||
File zddc.ZddcFile
|
File zddc.ZddcFile
|
||||||
EffectiveChain []chainEntry
|
EffectiveChain []chainEntry
|
||||||
AdminPathPrefix string // /.admin
|
ProfilePathPrefix string // /.profile
|
||||||
AssetsPathPrefix string // /.admin/zddc/assets
|
AssetsPathPrefix string // /.profile/zddc/assets
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveZddcEditor renders the form-based .zddc editor at
|
// serveZddcEditor renders the form-based .zddc editor at
|
||||||
// GET /.admin/zddc/edit?path=<dir>. The form posts JSON back to
|
// GET /.profile/zddc/edit?path=<dir>. The form posts JSON back to
|
||||||
// /.admin/zddc?path=<dir>; the inline JS shim handles dynamic-row
|
// /.profile/zddc?path=<dir>; the inline JS shim handles dynamic-row
|
||||||
// add/remove and surfaces field errors from the JSON response.
|
// add/remove and surfaces field errors from the JSON response.
|
||||||
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
|
|
@ -71,16 +71,16 @@ func serveZddcEditor(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
view := editorView{
|
view := editorView{
|
||||||
Path: urlPathOf(cfg.Root, abs),
|
Path: urlPathOf(cfg.Root, abs),
|
||||||
IsRoot: abs == cfg.Root,
|
IsRoot: abs == cfg.Root,
|
||||||
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
|
CanEdit: zddc.CanEditZddc(cfg.Root, abs, email),
|
||||||
Exists: exists,
|
Exists: exists,
|
||||||
Email: email,
|
Email: email,
|
||||||
HasCustomCSS: hasCustomAdminCSS(cfg.Root),
|
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
||||||
File: zf,
|
File: zf,
|
||||||
EffectiveChain: entries,
|
EffectiveChain: entries,
|
||||||
AdminPathPrefix: AdminPathPrefix,
|
ProfilePathPrefix: ProfilePathPrefix,
|
||||||
AssetsPathPrefix: zddcAssetsPathPrefix,
|
AssetsPathPrefix: zddcAssetsPathPrefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a href="{{ .AdminPathPrefix }}/">← admin</a> /
|
<a href="{{ .ProfilePathPrefix }}/">← profile</a> /
|
||||||
<a href="{{ .AdminPathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} / <span>{{ .Path }}</span>{{ end }}
|
<a href="{{ .ProfilePathPrefix }}/zddc/edit?path=/">root</a>{{ if not .IsRoot }} / <span>{{ .Path }}</span>{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>.zddc editor</h1>
|
<h1>.zddc editor</h1>
|
||||||
|
|
@ -236,7 +236,7 @@ admins:{{ range .Admins }} {{ . }}{{ end }}</pre></details>{{ end }}
|
||||||
var path = {{ .Path }};
|
var path = {{ .Path }};
|
||||||
var canEdit = {{ .CanEdit }};
|
var canEdit = {{ .CanEdit }};
|
||||||
var isRoot = {{ .IsRoot }};
|
var isRoot = {{ .IsRoot }};
|
||||||
var apiURL = "{{ .AdminPathPrefix }}/zddc?path=" + encodeURIComponent(path);
|
var apiURL = "{{ .ProfilePathPrefix }}/zddc?path=" + encodeURIComponent(path);
|
||||||
|
|
||||||
function rowFor(field, value) {
|
function rowFor(field, value) {
|
||||||
var div = document.createElement("div");
|
var div = document.createElement("div");
|
||||||
|
|
|
||||||
|
|
@ -12,24 +12,24 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"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
|
// HTML page). All routes under this prefix require either super-admin
|
||||||
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
|
// authority (IsAdmin) or some subtree-admin grant; non-admins-of-anything
|
||||||
// receive 404 to keep editor existence hidden, matching the /.admin gate.
|
// receive 404 to keep editor existence hidden, matching the /.profile gate.
|
||||||
const ZddcAdminPathPrefix = AdminPathPrefix + "/zddc"
|
const ZddcProfilePathPrefix = ProfilePathPrefix + "/zddc"
|
||||||
|
|
||||||
// ServeZddc dispatches all /.admin/zddc/* routes. ServeAdmin already
|
// ServeZddc dispatches all /.profile/zddc/* routes. ServeProfile already
|
||||||
// trimmed the /.admin prefix and confirmed at least the super-admin gate,
|
// trimmed the /.profile prefix; this handler is reachable for any admin
|
||||||
// but this handler is also reachable for subtree-only admins, so it
|
// (super or subtree), so it re-checks authorization itself rather than
|
||||||
// re-checks authorization itself and bypasses the super-admin requirement
|
// inheriting one from the caller.
|
||||||
// imposed at the top of ServeAdmin.
|
|
||||||
//
|
//
|
||||||
// Sub-routes:
|
// Sub-routes:
|
||||||
// GET /.admin/zddc?path=<dir> → JSON: parsed file + chain
|
//
|
||||||
// POST /.admin/zddc?path=<dir> → write (JSON body)
|
// GET /.profile/zddc?path=<dir> → JSON: parsed file + chain
|
||||||
// DELETE /.admin/zddc?path=<dir> → remove file
|
// POST /.profile/zddc?path=<dir> → write (JSON body)
|
||||||
// GET /.admin/zddc/tree → JSON: list of editable dirs
|
// DELETE /.profile/zddc?path=<dir> → remove file
|
||||||
// GET /.admin/zddc/edit?path=<dir> → server-rendered editor page
|
// 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) {
|
func ServeZddc(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
email := EmailFromContext(r)
|
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
|
// r.URL.Path is the full URL path; sub-route is everything after
|
||||||
// /.admin/zddc.
|
// /.profile/zddc.
|
||||||
sub := strings.TrimPrefix(r.URL.Path, ZddcAdminPathPrefix)
|
sub := strings.TrimPrefix(r.URL.Path, ZddcProfilePathPrefix)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case sub == "" || sub == "/":
|
case sub == "" || sub == "/":
|
||||||
|
|
@ -128,20 +128,20 @@ func urlPathOf(fsRoot, abs string) string {
|
||||||
|
|
||||||
// chainEntry is one level of the effective-chain in API responses.
|
// chainEntry is one level of the effective-chain in API responses.
|
||||||
type chainEntry struct {
|
type chainEntry struct {
|
||||||
Dir string `json:"dir"`
|
Dir string `json:"dir"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
ACL zddc.ACLRules `json:"acl"`
|
ACL zddc.ACLRules `json:"acl"`
|
||||||
Admins []string `json:"admins,omitempty"`
|
Admins []string `json:"admins,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type zddcGetResponse struct {
|
type zddcGetResponse struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
IsRoot bool `json:"is_root"`
|
IsRoot bool `json:"is_root"`
|
||||||
CanEdit bool `json:"can_edit"`
|
CanEdit bool `json:"can_edit"`
|
||||||
File zddc.ZddcFile `json:"file"`
|
File zddc.ZddcFile `json:"file"`
|
||||||
EffectiveChain []chainEntry `json:"effective_chain"`
|
EffectiveChain []chainEntry `json:"effective_chain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type zddcWriteRequest struct {
|
type zddcWriteRequest struct {
|
||||||
|
|
@ -154,7 +154,7 @@ type writeError struct {
|
||||||
Errors []zddc.FieldError `json:"errors"`
|
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) {
|
func serveZddcAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
email := EmailFromContext(r)
|
email := EmailFromContext(r)
|
||||||
abs, err := resolvePath(cfg.Root, r.URL.Query().Get("path"))
|
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)
|
writeJSON(w, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,8 @@ func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do
|
||||||
func TestServeZddcAuthGate(t *testing.T) {
|
func TestServeZddcAuthGate(t *testing.T) {
|
||||||
// root admin = root@example.com; subtree admin alice@example.com on /projects.
|
// root admin = root@example.com; subtree admin alice@example.com on /projects.
|
||||||
cfg, do := zddcTestSetup(t, map[string]string{
|
cfg, do := zddcTestSetup(t, map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "admins:\n - alice@example.com\n",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
"projects/x": "",
|
"projects/x": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -74,18 +74,18 @@ func TestServeZddcAuthGate(t *testing.T) {
|
||||||
email string
|
email string
|
||||||
wantStatus int
|
wantStatus int
|
||||||
}{
|
}{
|
||||||
{"anon GET root", http.MethodGet, "/.admin/zddc?path=/", "", http.StatusNotFound},
|
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
|
||||||
{"non-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "mallory@example.com", http.StatusNotFound},
|
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
|
||||||
{"super-admin GET root", http.MethodGet, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
|
{"super-admin GET root", http.MethodGet, "/.profile/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 root (read-only)", http.MethodGet, "/.profile/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 own grant file (read-only)", http.MethodGet, "/.profile/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 GET deeper", http.MethodGet, "/.profile/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 own grant file (forbidden)", http.MethodPost, "/.profile/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},
|
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
|
||||||
{"super-admin POST root", http.MethodPost, "/.admin/zddc?path=/", "root@example.com", http.StatusOK},
|
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
|
||||||
{"non-admin POST anywhere", http.MethodPost, "/.admin/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
|
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
|
||||||
{"DELETE root rejected", http.MethodDelete, "/.admin/zddc?path=/", "root@example.com", http.StatusBadRequest},
|
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
|
||||||
{"super-admin DELETE leaf", http.MethodDelete, "/.admin/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
|
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = cfg
|
_ = cfg
|
||||||
|
|
@ -93,7 +93,7 @@ func TestServeZddcAuthGate(t *testing.T) {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
body := ""
|
body := ""
|
||||||
if tc.method == http.MethodPost {
|
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.
|
// Root POST: writer must remain in admins list.
|
||||||
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
|
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -114,7 +114,7 @@ func TestServeZddcGetReturnsChain(t *testing.T) {
|
||||||
"projects": "title: All Projects\n",
|
"projects": "title: All Projects\n",
|
||||||
"projects/sub": "title: Substation\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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,7 @@ func TestServeZddcPostValidatesGlob(t *testing.T) {
|
||||||
"projects": "",
|
"projects": "",
|
||||||
})
|
})
|
||||||
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
|
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 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
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.
|
// root tries to remove themselves, leaving only bob.
|
||||||
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
|
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 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
|
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.
|
// root adds bob alongside themselves — fine.
|
||||||
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
|
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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -194,11 +194,11 @@ func TestServeZddcWriteRoundTrip(t *testing.T) {
|
||||||
"projects": "",
|
"projects": "",
|
||||||
})
|
})
|
||||||
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
|
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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
|
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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
|
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) {
|
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
|
||||||
_, do := zddcTestSetup(t, map[string]string{
|
_, do := zddcTestSetup(t, map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"alpha": "admins:\n - alice@example.com\n",
|
"alpha": "admins:\n - alice@example.com\n",
|
||||||
"alpha/x": "title: alpha-x\n",
|
"alpha/x": "title: alpha-x\n",
|
||||||
"beta": "admins:\n - bob@example.com\n",
|
"beta": "admins:\n - bob@example.com\n",
|
||||||
})
|
})
|
||||||
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
|
// 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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
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",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "title: Engineering\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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
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") {
|
if !strings.Contains(body, "Engineering") {
|
||||||
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
|
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "/.admin/zddc?path=") {
|
if !strings.Contains(body, "/.profile/zddc?path=") {
|
||||||
t.Errorf("editor should reference API URL; body lacks /.admin/zddc?path=")
|
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "Subtree admins of /projects") {
|
if !strings.Contains(body, "Subtree admins of /projects") {
|
||||||
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
|
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",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
})
|
})
|
||||||
// alice viewing her own grant file: read-only.
|
// 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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
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",
|
"": "admins:\n - root@example.com\n",
|
||||||
})
|
})
|
||||||
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
|
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 {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
|
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) {
|
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
|
||||||
// Confirm that putting /.admin/zddc/* under the broader gate did not
|
// Confirm that putting /.profile/zddc/* under the broader gate did not
|
||||||
// regress the super-admin gate on /.admin/whoami etc.
|
// regress the super-admin gate on /.profile/whoami etc.
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
||||||
t.Fatalf("write .zddc: %v", err)
|
t.Fatalf("write .zddc: %v", err)
|
||||||
|
|
@ -301,20 +301,20 @@ func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
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"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeAdmin(cfg, nil, rec, req)
|
ServeProfile(cfg, nil, rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
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"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
ServeAdmin(cfg, nil, rec, req)
|
ServeProfile(cfg, nil, rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
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)
|
zddc.InvalidateCache(root)
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
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"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeZddc(cfg, rec, req)
|
ServeZddc(cfg, rec, req)
|
||||||
|
|
@ -352,7 +352,7 @@ func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
|
||||||
zddc.InvalidateCache(root)
|
zddc.InvalidateCache(root)
|
||||||
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
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"))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeZddc(cfg, rec, req)
|
ServeZddc(cfg, rec, req)
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,11 @@ func TestFromDirEntriesFiltersHidden(t *testing.T) {
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"Project-A",
|
"Project-A",
|
||||||
"Project-B",
|
"Project-B",
|
||||||
".zddc", // hidden file
|
".zddc", // hidden file
|
||||||
".devshell", // hidden dir
|
".devshell", // hidden dir
|
||||||
"_template", // scaffolding dir
|
"_template", // scaffolding dir
|
||||||
"_archive", // scaffolding dir
|
"_archive", // scaffolding dir
|
||||||
"_notes.txt", // scaffolding file
|
"_notes.txt", // scaffolding file
|
||||||
"normal.txt",
|
"normal.txt",
|
||||||
} {
|
} {
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import "time"
|
||||||
// FileInfo matches Caddy's browse JSON output exactly.
|
// FileInfo matches Caddy's browse JSON output exactly.
|
||||||
// The archive browser (source.js) expects this exact shape.
|
// The archive browser (source.js) expects this exact shape.
|
||||||
type FileInfo struct {
|
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"`
|
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"`
|
ModTime time.Time `json:"mod_time"`
|
||||||
Mode uint32 `json:"mode"`
|
Mode uint32 `json:"mode"`
|
||||||
IsDir bool `json:"is_dir"`
|
IsDir bool `json:"is_dir"`
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import "path/filepath"
|
||||||
// IsAdmin reports whether email is listed in the admins entry of the ROOT
|
// IsAdmin reports whether email is listed in the admins entry of the ROOT
|
||||||
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
|
// .zddc file (<fsRoot>/.zddc). Subdirectory .zddc files' admins keys are
|
||||||
// deliberately ignored by this function — it gates the server-wide debug
|
// 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.
|
// super-admin should reach.
|
||||||
//
|
//
|
||||||
// Subtree-scoped admin authority (the "fiefdom" model) is checked via
|
// Subtree-scoped admin authority (the "fiefdom" model) is checked via
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,8 @@ func TestIsSubtreeAdmin(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "root admin → admin of any subtree",
|
name: "root admin → admin of any subtree",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - alice@example.com\n",
|
"": "admins:\n - alice@example.com\n",
|
||||||
"projects/x": "",
|
"projects/x": "",
|
||||||
},
|
},
|
||||||
dir: "projects/x",
|
dir: "projects/x",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
|
|
@ -162,9 +162,9 @@ func TestIsSubtreeAdmin(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "subtree admin granted at intermediate level",
|
name: "subtree admin granted at intermediate level",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "admins:\n - alice@example.com\n",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
"projects/x": "",
|
"projects/x": "",
|
||||||
},
|
},
|
||||||
dir: "projects/x",
|
dir: "projects/x",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
|
|
@ -173,8 +173,8 @@ func TestIsSubtreeAdmin(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "subtree admin granted at the leaf level itself",
|
name: "subtree admin granted at the leaf level itself",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "admins:\n - alice@example.com\n",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
},
|
},
|
||||||
dir: "projects",
|
dir: "projects",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
|
|
@ -193,9 +193,9 @@ func TestIsSubtreeAdmin(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "admin granted in sibling subtree does not leak",
|
name: "admin granted in sibling subtree does not leak",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"foo": "admins:\n - alice@example.com\n",
|
"foo": "admins:\n - alice@example.com\n",
|
||||||
"bar": "",
|
"bar": "",
|
||||||
},
|
},
|
||||||
dir: "bar",
|
dir: "bar",
|
||||||
email: "alice@example.com",
|
email: "alice@example.com",
|
||||||
|
|
@ -264,7 +264,7 @@ func TestCanEditZddc(t *testing.T) {
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no zddc files at all → nobody edits root",
|
name: "no zddc files at all → nobody edits root",
|
||||||
files: map[string]string{},
|
files: map[string]string{},
|
||||||
dir: "",
|
dir: "",
|
||||||
email: "anyone@example.com",
|
email: "anyone@example.com",
|
||||||
|
|
@ -314,9 +314,9 @@ func TestCanEditZddc(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "subtree admin CANNOT edit sibling's grant file",
|
name: "subtree admin CANNOT edit sibling's grant file",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"foo": "admins:\n - alice@example.com\n",
|
"foo": "admins:\n - alice@example.com\n",
|
||||||
"bar": "admins:\n - bob@example.com\n",
|
"bar": "admins:\n - bob@example.com\n",
|
||||||
},
|
},
|
||||||
dir: "bar",
|
dir: "bar",
|
||||||
email: "alice@example.com",
|
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",
|
name: "two-level delegation — mid-level admin edits leaf below their grant",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "admins:\n - alice@example.com\n",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
"projects/sub": "admins:\n - bob@example.com\n",
|
"projects/sub": "admins:\n - bob@example.com\n",
|
||||||
"projects/sub/x": "",
|
"projects/sub/x": "",
|
||||||
},
|
},
|
||||||
dir: "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",
|
name: "two-level delegation — bob can still edit deeper",
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
"": "admins:\n - root@example.com\n",
|
"": "admins:\n - root@example.com\n",
|
||||||
"projects": "admins:\n - alice@example.com\n",
|
"projects": "admins:\n - alice@example.com\n",
|
||||||
"projects/sub": "admins:\n - bob@example.com\n",
|
"projects/sub": "admins:\n - bob@example.com\n",
|
||||||
"projects/sub/x": "",
|
"projects/sub/x": "",
|
||||||
},
|
},
|
||||||
dir: "projects/sub/x",
|
dir: "projects/sub/x",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,39 @@ type FieldError struct {
|
||||||
Message string `json:"message"`
|
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 {
|
func ValidateFile(zf ZddcFile) []FieldError {
|
||||||
var errs []FieldError
|
var errs []FieldError
|
||||||
check := func(field string, vals []string) {
|
check := func(field string, vals []string) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestValidateFileTitleLength(t *testing.T) {
|
||||||
long := make([]byte, 201)
|
long := make([]byte, 201)
|
||||||
for i := range long {
|
for i := range long {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue