Files
paliad/internal/handlers/audit.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

172 lines
5.1 KiB
Go

package handlers
import (
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// audit.go — admin-only global audit-log timeline (t-paliad-071).
//
// Both the page shell and the API endpoint are registered behind
// auth.RequireAdminFunc in handlers.go, so the in-handler logic can assume
// the caller is a global_admin and only validate the request shape.
// GET /api/audit-log — paginated, filterable timeline across paliad's four
// audit sources (project_events, caldav_sync_log, reminder_log,
// partner_unit_events).
//
// Query params:
//
// source — one of project_events, caldav_sync_log, reminder_log,
// partner_unit_events; empty = all
// from — ISO-8601 timestamp, inclusive lower bound
// to — ISO-8601 timestamp, inclusive upper bound
// q — free-text search (subject, description, title, event_type, actor)
// before_ts / before_id — keyset cursor (timestamp + id of last seen row)
// limit — page size (default 50, capped at 200)
//
// Response shape: { entries: [...], next_cursor: { ts, id } | null }
func handleListAuditLog(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.audit == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "audit service not configured",
})
return
}
q := r.URL.Query()
filter := services.AuditFilter{
Source: q.Get("source"),
Search: q.Get("q"),
}
switch filter.Source {
case "",
services.AuditSourceProjectEvents,
services.AuditSourceCalDAVLog,
services.AuditSourceReminderLog,
services.AuditSourcePartnerUnitEvents:
// ok
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source"})
return
}
if v := q.Get("from"); v != "" {
t, err := parseAuditTimestamp(v)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid from"})
return
}
filter.From = t
}
if v := q.Get("to"); v != "" {
t, err := parseAuditTimestamp(v)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid to"})
return
}
filter.To = t
}
// Keyset cursor: both halves required to take effect. The frontend echoes
// the next_cursor object verbatim, so a malformed pair is a programmer
// error worth surfacing rather than silently falling back to page 1.
beforeTSRaw := q.Get("before_ts")
beforeIDRaw := q.Get("before_id")
if (beforeTSRaw == "") != (beforeIDRaw == "") {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "before_ts and before_id must be supplied together",
})
return
}
if beforeTSRaw != "" {
t, err := parseAuditTimestamp(beforeTSRaw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before_ts"})
return
}
id, err := uuid.Parse(beforeIDRaw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before_id"})
return
}
filter.BeforeTS = &t
filter.BeforeID = &id
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
return
}
filter.Limit = n
}
rows, err := dbSvc.audit.ListEntries(r.Context(), filter)
if err != nil {
writeServiceError(w, err)
return
}
resp := map[string]any{
"entries": rows,
"next_cursor": nil,
}
// "Has more" is implied by a full page — when the page is exactly the
// requested limit, the next call needs the keyset of the last row. We
// return nil otherwise so the client can stop paginating without a
// dedicated total-count query.
limit := filter.Limit
if limit <= 0 {
limit = services.DefaultAuditPageLimit
}
if len(rows) == limit {
last := rows[len(rows)-1]
resp["next_cursor"] = map[string]any{
"ts": last.Timestamp,
"id": last.ID,
}
}
writeJSON(w, http.StatusOK, resp)
}
// parseAuditTimestamp accepts either a full RFC3339 timestamp or a bare
// YYYY-MM-DD date — the latter is what the <input type="date"> control
// produces, which the date-range filter uses for "last N days" presets.
// Date-only values are interpreted as midnight UTC; the from/to bounds are
// inclusive in the SQL, so a "to" date of 2026-04-29 catches everything up
// to the start of that day. The page renders an explicit hint so users know
// to pick "tomorrow" if they want today's rows.
func parseAuditTimestamp(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t, nil
}
return time.Time{}, errInvalidTimestamp
}
var errInvalidTimestamp = &auditError{"invalid timestamp"}
type auditError struct{ msg string }
func (e *auditError) Error() string { return e.msg }
// handleAdminAuditLogPage serves the SPA shell for /admin/audit-log. The
// route is gated through RequireAdminFunc at registration; non-admins get
// the standard 302 to /dashboard?forbidden=admin.
func handleAdminAuditLogPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-audit-log.html")
}