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>
169 lines
4.3 KiB
Go
169 lines
4.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LogEntry is one captured slog record, in a shape suitable for JSON.
|
|
type LogEntry struct {
|
|
Time time.Time `json:"time"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
Attrs map[string]any `json:"attrs,omitempty"`
|
|
}
|
|
|
|
// LogRing is a fixed-size circular buffer of recent slog records, populated
|
|
// by RingHandler. Snapshot returns a copy in chronological order (oldest →
|
|
// newest); the buffer is never blocking.
|
|
type LogRing struct {
|
|
mu sync.Mutex
|
|
entries []LogEntry
|
|
size int
|
|
next int // index of the slot the next record will be written into
|
|
count int // number of entries currently held (≤ size)
|
|
}
|
|
|
|
// NewLogRing creates a ring with capacity n. Panics on n ≤ 0 — that's a
|
|
// programming error, not a runtime condition.
|
|
func NewLogRing(n int) *LogRing {
|
|
if n <= 0 {
|
|
panic("logring: capacity must be > 0")
|
|
}
|
|
return &LogRing{
|
|
entries: make([]LogEntry, n),
|
|
size: n,
|
|
}
|
|
}
|
|
|
|
func (r *LogRing) push(e LogEntry) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.entries[r.next] = e
|
|
r.next = (r.next + 1) % r.size
|
|
if r.count < r.size {
|
|
r.count++
|
|
}
|
|
}
|
|
|
|
// Snapshot returns the current contents in chronological order.
|
|
func (r *LogRing) Snapshot() []LogEntry {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]LogEntry, r.count)
|
|
if r.count < r.size {
|
|
copy(out, r.entries[:r.count])
|
|
return out
|
|
}
|
|
// Buffer full and wrapping: oldest is at r.next, newest at r.next-1.
|
|
copy(out, r.entries[r.next:])
|
|
copy(out[r.size-r.next:], r.entries[:r.next])
|
|
return out
|
|
}
|
|
|
|
// RingHandler is a slog.Handler that pushes records into a LogRing.
|
|
type RingHandler struct {
|
|
ring *LogRing
|
|
level slog.Leveler
|
|
attrs []slog.Attr
|
|
group string
|
|
}
|
|
|
|
// NewRingHandler returns a handler that records into ring at level and above.
|
|
func NewRingHandler(ring *LogRing, level slog.Leveler) *RingHandler {
|
|
return &RingHandler{ring: ring, level: level}
|
|
}
|
|
|
|
func (h *RingHandler) Enabled(_ context.Context, l slog.Level) bool {
|
|
min := slog.LevelInfo
|
|
if h.level != nil {
|
|
min = h.level.Level()
|
|
}
|
|
return l >= min
|
|
}
|
|
|
|
func (h *RingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
attrs := make(map[string]any, r.NumAttrs()+len(h.attrs))
|
|
for _, a := range h.attrs {
|
|
attrs[a.Key] = a.Value.Any()
|
|
}
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
attrs[a.Key] = a.Value.Any()
|
|
return true
|
|
})
|
|
if len(attrs) == 0 {
|
|
attrs = nil
|
|
}
|
|
h.ring.push(LogEntry{
|
|
Time: r.Time,
|
|
Level: r.Level.String(),
|
|
Message: r.Message,
|
|
Attrs: attrs,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (h *RingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs))
|
|
merged = append(merged, h.attrs...)
|
|
merged = append(merged, attrs...)
|
|
return &RingHandler{ring: h.ring, level: h.level, attrs: merged, group: h.group}
|
|
}
|
|
|
|
func (h *RingHandler) WithGroup(name string) slog.Handler {
|
|
// We don't render groups specially — just track the deepest one for
|
|
// debugging context. Most zddc-server logging is flat.
|
|
return &RingHandler{ring: h.ring, level: h.level, attrs: h.attrs, group: name}
|
|
}
|
|
|
|
// MultiHandler fans out each Handle call to every wrapped handler. Used to
|
|
// tee slog output to both stderr (the existing TextHandler) and the in-memory
|
|
// ring buffer that backs the /.profile/logs endpoint.
|
|
type MultiHandler struct {
|
|
handlers []slog.Handler
|
|
}
|
|
|
|
// NewMultiHandler returns a handler that broadcasts to all of hs.
|
|
func NewMultiHandler(hs ...slog.Handler) *MultiHandler {
|
|
return &MultiHandler{handlers: hs}
|
|
}
|
|
|
|
func (m *MultiHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
|
for _, h := range m.handlers {
|
|
if h.Enabled(ctx, l) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *MultiHandler) Handle(ctx context.Context, r slog.Record) error {
|
|
var firstErr error
|
|
for _, h := range m.handlers {
|
|
if !h.Enabled(ctx, r.Level) {
|
|
continue
|
|
}
|
|
if err := h.Handle(ctx, r.Clone()); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (m *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
out := make([]slog.Handler, len(m.handlers))
|
|
for i, h := range m.handlers {
|
|
out[i] = h.WithAttrs(attrs)
|
|
}
|
|
return &MultiHandler{handlers: out}
|
|
}
|
|
|
|
func (m *MultiHandler) WithGroup(name string) slog.Handler {
|
|
out := make([]slog.Handler, len(m.handlers))
|
|
for i, h := range m.handlers {
|
|
out[i] = h.WithGroup(name)
|
|
}
|
|
return &MultiHandler{handlers: out}
|
|
}
|