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.
172 lines
5.1 KiB
Go
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")
|
|
}
|