Files
restic-manager/internal/server/http/ui_audit.go
T

131 lines
3.5 KiB
Go

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