// 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" "net/url" "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 // Sort + Dir reflect the *resolved* sort (after allowlist // validation) so the template can render arrows on the active // column. Sort string // "ts" | "actor" | "user_id" | "action" | "target_kind" Dir string // "asc" | "desc" // SortHrefs is a fully-encoded /audit?…&sort=COL&dir=… for each // sortable column. Built server-side because constructing the // querystring inside a Go html/template applies // URL-attribute escaping to '=' (turning 'range=all' into // 'range%3dall' on the wire), which loses every filter on click. // CSVHref is the analogous link for the export button. SortHrefs map[string]string CSVHref string } // 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 OrderBy: q.Get("sort"), OrderAsc: q.Get("dir") == "asc", }, 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 } // Resolve the sort key once so the page model and the template // see the same value the SQL just used. f.OrderBy may have been // '' or unknown → 'ts'; the template needs the resolved one. resolvedSort := "ts" switch f.OrderBy { case "actor", "user_id", "action", "target_kind": resolvedSort = f.OrderBy } dir := "desc" if f.OrderAsc { dir = "asc" } // Build the per-column sort hrefs once, so the template only // has to emit them. Each click flips dir on the active column; // any other column starts at desc (newest-first / Z→A). base := url.Values{} if rng != "" { base.Set("range", rng) } if f.UserID != "" { base.Set("user_id", f.UserID) } if f.Actor != "" { base.Set("actor", f.Actor) } if f.ActionLike != "" { base.Set("action", f.ActionLike) } if f.TargetKind != "" { base.Set("target_kind", f.TargetKind) } csvHref := "/audit.csv?" + base.Encode() hrefs := make(map[string]string, 5) for _, col := range []string{"ts", "actor", "user_id", "action", "target_kind"} { v := url.Values{} for k, vs := range base { v[k] = vs } v.Set("sort", col) newDir := "desc" if col == resolvedSort && dir == "desc" { newDir = "asc" } v.Set("dir", newDir) hrefs[col] = "/audit?" + v.Encode() } page := auditPage{ Filter: f, Range: rng, Entries: entries, UserNames: map[string]string{}, HostNames: map[string]string{}, Sort: resolvedSort, Dir: dir, SortHrefs: hrefs, CSVHref: csvHref, } 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() // user_id and target_id are internal ULIDs that carry no meaning // to anyone reading the CSV — the resolved name (or — for system // rows / non-host targets) is what an operator wants. The HTML // page still shows IDs in the Target column for traceability when // no name is available; the CSV is for human reporting only. _ = cw.Write([]string{"timestamp_utc", "actor", "user", "action", "target_kind", "target_name", "payload"}) for _, e := range entries { var uname string if e.UserID != nil { uname = userNames[*e.UserID] } var tk, tname string if e.TargetKind != nil { tk = *e.TargetKind } if tk == "host" && e.TargetID != nil { tname = hostNames[*e.TargetID] } 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, e.Action, tk, tname, payload, }) } }