package moderation import ( "context" "database/sql" "encoding/json" "errors" "net/http" "time" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) // Handler handles server moderation actions. type Handler struct { db *sql.DB hub *gateway.Hub } // NewHandler creates a new moderation handler. func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { return &Handler{db: db, hub: hub} } type banRequest struct { UserID string `json:"user_id"` Reason string `json:"reason"` } type muteRequest struct { UserID string `json:"user_id"` Reason string `json:"reason"` Duration string `json:"duration"` // e.g. "15m", "1h", "1d", empty = permanent } // banResponse is the JSON shape returned when banning a user. type banResponse struct { ID string `json:"id"` ServerID string `json:"server_id"` UserID string `json:"user_id"` BannedBy string `json:"banned_by"` Reason string `json:"reason"` CreatedAt string `json:"created_at"` } // Ban bans a user from a server. func (h *Handler) Ban(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 } var req banRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } // Remove member if present. _, _ = h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, req.UserID) var ban banResponse err := h.db.QueryRowContext(r.Context(), ` INSERT INTO bans (server_id, user_id, banned_by, reason) VALUES ($1, $2, $3, $4) RETURNING id, server_id, user_id, banned_by, reason, created_at::text `, serverID, req.UserID, userID, req.Reason).Scan(&ban.ID, &ban.ServerID, &ban.UserID, &ban.BannedBy, &ban.Reason, &ban.CreatedAt) if err != nil { http.Error(w, `{"error":"failed to ban user"}`, http.StatusInternalServerError) return } if h.hub != nil { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMemberRemove, Data: map[string]string{"server_id": serverID, "user_id": req.UserID}, }) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ban) } // Unban removes a ban. func (h *Handler) Unban(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") _, err := h.db.ExecContext(r.Context(), `DELETE FROM bans WHERE server_id = $1 AND user_id = $2`, serverID, targetID) if err != nil { http.Error(w, `{"error":"failed to unban user"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ListBans lists banned users. func (h *Handler) ListBans(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") rows, err := h.db.QueryContext(r.Context(), ` SELECT b.id, b.server_id, b.user_id, b.banned_by, b.reason, b.created_at::text, u.username FROM bans b JOIN users u ON u.id = b.user_id WHERE b.server_id = $1 ORDER BY b.created_at DESC `, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() type item struct { banResponse Username string `json:"username"` } var bans []item for rows.Next() { var b item if err := rows.Scan(&b.ID, &b.ServerID, &b.UserID, &b.BannedBy, &b.Reason, &b.CreatedAt, &b.Username); err != nil { continue } bans = append(bans, b) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(bans) } // Kick removes a member from a server. func (h *Handler) Kick(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") _, err := h.db.ExecContext(r.Context(), `DELETE FROM members WHERE server_id = $1 AND user_id = $2`, serverID, targetID) if err != nil { http.Error(w, `{"error":"failed to kick user"}`, http.StatusInternalServerError) return } if h.hub != nil { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMemberRemove, Data: map[string]string{"server_id": serverID, "user_id": targetID}, }) } w.WriteHeader(http.StatusNoContent) } // Mute creates a server mute. func (h *Handler) Mute(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 } var req muteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } var expiresAt sql.NullTime if req.Duration != "" { d, err := time.ParseDuration(req.Duration) if err != nil { http.Error(w, `{"error":"invalid duration"}`, http.StatusBadRequest) return } expiresAt = sql.NullTime{Time: time.Now().Add(d), Valid: true} } _, err := h.db.ExecContext(r.Context(), ` INSERT INTO server_mutes (server_id, user_id, muted_by, reason, expires_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (server_id, user_id) DO UPDATE SET muted_by = EXCLUDED.muted_by, reason = EXCLUDED.reason, expires_at = EXCLUDED.expires_at, created_at = NOW() `, serverID, req.UserID, userID, req.Reason, expiresAt) if err != nil { http.Error(w, `{"error":"failed to mute user"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // Unmute removes a server mute. func (h *Handler) Unmute(w http.ResponseWriter, r *http.Request) { serverID := chi.URLParam(r, "serverID") targetID := chi.URLParam(r, "userID") _, err := h.db.ExecContext(r.Context(), `DELETE FROM server_mutes WHERE server_id = $1 AND user_id = $2`, serverID, targetID) if err != nil { http.Error(w, `{"error":"failed to unmute user"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // IsMuted checks whether a user is currently muted in a server. func IsMuted(ctx context.Context, db *sql.DB, serverID, userID string) (bool, error) { var exists bool err := db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM server_mutes WHERE server_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW())) `, serverID, userID).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { return false, nil } return exists, err }