// ui_audit.go — Audit log read-only surfaces. // // Routes (wired in server.go): // // GET /audit → handleUIAudit (HTML) // GET /api/audit → handleAPIAudit (JSON) // // Filters: user, actor, action (substring), target_kind, time-range // preset (24h | 7d | 30d | all). Page-level live refresh is *not* // added here — audit is append-only and operators inspect history, // not current state. package http import ( "encoding/json" "log/slog" stdhttp "net/http" "strings" "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type auditPage struct { Filter store.AuditFilter Range string // "24h" | "7d" | "30d" | "all" Entries []store.AuditEntry UserNames map[string]string // user_id → username for row rendering HostNames map[string]string // host_id → name (for target_kind=host display) Actions []string // distinct actions seen so far, for the dropdown } // rangeToSince converts the time-range preset to a Since cutoff. "all" // (or unrecognised) returns the zero time, meaning "no lower bound". func rangeToSince(r string, now time.Time) time.Time { switch r { case "24h", "": return now.Add(-24 * time.Hour) case "7d": return now.Add(-7 * 24 * time.Hour) case "30d": return now.Add(-30 * 24 * time.Hour) default: return time.Time{} } } func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } q := r.URL.Query() rng := q.Get("range") if rng == "" { rng = "24h" } f := store.AuditFilter{ UserID: q.Get("user_id"), Actor: q.Get("actor"), ActionLike: strings.TrimSpace(q.Get("action")), TargetKind: q.Get("target_kind"), Since: rangeToSince(rng, time.Now().UTC()), Limit: 500, } entries, err := s.deps.Store.ListAudit(r.Context(), f) if err != nil { slog.Error("ui audit: list", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } page := auditPage{ Filter: f, Range: rng, Entries: entries, UserNames: map[string]string{}, HostNames: map[string]string{}, } if users, err := s.deps.Store.ListUsers(r.Context()); err == nil { for _, ux := range users { page.UserNames[ux.ID] = ux.Username } } if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil { for _, h := range hosts { page.HostNames[h.ID] = h.Name } } if actions, err := s.deps.Store.DistinctAuditActions(r.Context()); err == nil { page.Actions = actions } view := s.baseView(r, u) view.Title = "Audit · restic-manager" view.Active = "audit" view.Page = page if err := s.deps.UI.Render(w, "audit", view); err != nil { slog.Error("ui audit: render", "err", err) } } // handleAPIAudit is the JSON variant — same filters as the HTML page. func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) { if _, ok := s.requireUser(r); !ok { writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } q := r.URL.Query() rng := q.Get("range") if rng == "" { rng = "24h" } f := store.AuditFilter{ UserID: q.Get("user_id"), Actor: q.Get("actor"), ActionLike: strings.TrimSpace(q.Get("action")), TargetKind: q.Get("target_kind"), Since: rangeToSince(rng, time.Now().UTC()), Limit: 500, } entries, err := s.deps.Store.ListAudit(r.Context(), f) if err != nil { writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"entries": entries}) }