ZDDC/zddc/internal/handler/logring.go
ZDDC cb46c2ef8c 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>
2026-04-29 16:32:02 -05:00

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}
}