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 }