Files
dumpsterChat/internal/audit/handler.go
T

126 lines
3.6 KiB
Go

package audit
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strconv"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler exposes the audit log read API.
type Handler struct {
db *sql.DB
}
// NewHandler creates a new audit log handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
// Entry represents a single audit log entry.
type Entry struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
UserID *string `json:"user_id"`
Username *string `json:"username"`
ActionType string `json:"action_type"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
Reason *string `json:"reason"`
Changes json.RawMessage `json:"changes"`
CreatedAt string `json:"created_at"`
}
// List handles GET /servers/{serverID}/audit-log.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
// ponytail: membership check keeps it simple; MANAGE_SERVER permission is the real gate.
var isMember bool
err := h.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM members WHERE server_id = $1 AND user_id = $2)`, serverID, userID).Scan(&isMember)
if err != nil || !isMember {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
actionType := r.URL.Query().Get("action_type")
before := r.URL.Query().Get("before")
args := []interface{}{serverID}
query := `
SELECT a.id, a.server_id, a.user_id, u.username, a.action_type, a.target_type, a.target_id, a.reason, a.changes, a.created_at::text
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.server_id = $1
`
if actionType != "" {
args = append(args, actionType)
query += ` AND a.action_type = $` + strconv.Itoa(len(args))
}
if before != "" {
args = append(args, before)
query += ` AND a.created_at < (SELECT created_at FROM audit_log WHERE id = $` + strconv.Itoa(len(args)) + `)`
}
query += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := h.db.QueryContext(r.Context(), query, args...)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
entries := make([]Entry, 0)
for rows.Next() {
var e Entry
var userIDStr, username, targetType, targetID, reason sql.NullString
err := rows.Scan(&e.ID, &e.ServerID, &userIDStr, &username, &e.ActionType, &targetType, &targetID, &reason, &e.Changes, &e.CreatedAt)
if err != nil {
continue
}
if userIDStr.Valid {
e.UserID = &userIDStr.String
}
if username.Valid {
e.Username = &username.String
}
if targetType.Valid {
e.TargetType = &targetType.String
}
if targetID.Valid {
e.TargetID = &targetID.String
}
if reason.Valid {
e.Reason = &reason.String
}
entries = append(entries, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
// Cleanup removes audit log entries older than 90 days.
func (h *Handler) Cleanup(ctx context.Context) error {
_, err := h.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '90 days'`)
return err
}