// ui_audit.go — Audit log read-only surfaces. // // Routes (wired in server.go): // // GET /audit → handleUIAudit (HTML) // GET /audit.csv → handleUIAuditCSV (CSV download honouring current filters) // 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/csv" "encoding/json" "fmt" "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{} } } // auditFilterFromQuery extracts the AuditFilter + range preset from // the request querystring. Shared by the HTML, CSV, and JSON handlers // so all three honour the same filter URL. func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) { q := r.URL.Query() rng := q.Get("range") if rng == "" { rng = "24h" } return 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: 5000, // CSV export tolerates more rows; HTML clamps via paging later }, rng } func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } f, rng := auditFilterFromQuery(r) // HTML page caps lower than CSV — keeps the table snappy. if f.Limit > 500 { f.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 } f, _ := auditFilterFromQuery(r) if f.Limit > 500 { f.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}) } // handleUIAuditCSV streams the filtered audit log as CSV. Auth-gated // like the HTML page; honours the same filter querystring so an // operator can refine the view in the browser, hit Export, and get // exactly what's on screen (plus more rows up to the 5000 cap). func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { return } f, _ := auditFilterFromQuery(r) entries, err := s.deps.Store.ListAudit(r.Context(), f) if err != nil { stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) return } // Resolve user_id → username and host_id → name once for the // human-friendly columns. userNames := map[string]string{} if users, err := s.deps.Store.ListUsers(r.Context()); err == nil { for _, ux := range users { userNames[ux.ID] = ux.Username } } hostNames := map[string]string{} if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil { for _, h := range hosts { hostNames[h.ID] = h.Name } } stamp := time.Now().UTC().Format("20060102-150405") w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="audit-%s.csv"`, stamp)) cw := csv.NewWriter(w) defer cw.Flush() _ = cw.Write([]string{"timestamp_utc", "actor", "user", "user_id", "action", "target_kind", "target_id", "target_name", "payload"}) for _, e := range entries { var uid, uname string if e.UserID != nil { uid = *e.UserID uname = userNames[uid] } var tk, tid, tname string if e.TargetKind != nil { tk = *e.TargetKind } if e.TargetID != nil { tid = *e.TargetID } if tk == "host" { tname = hostNames[tid] } payload := "" if len(e.Payload) > 0 { payload = string(e.Payload) } _ = cw.Write([]string{ e.TS.UTC().Format("2006-01-02 15:04:05"), e.Actor, uname, uid, e.Action, tk, tid, tname, payload, }) } }