diff --git a/internal/voice/client.go b/internal/voice/client.go index 6f8ece6..60b5a55 100644 --- a/internal/voice/client.go +++ b/internal/voice/client.go @@ -99,6 +99,33 @@ func (c *Client) ListParticipants(roomName string) ([]*livekit.ParticipantInfo, return resp.Participants, nil } +func (c *Client) MuteParticipant(roomName string, identity string) error { + if c == nil { + return fmt.Errorf("voice client not configured") + } + + participant, err := c.roomClient.GetParticipant(context.Background(), &livekit.RoomParticipantIdentity{ + Room: roomName, + Identity: identity, + }) + if err != nil { + return fmt.Errorf("get participant: %w", err) + } + + for _, track := range participant.Tracks { + _, err := c.roomClient.MutePublishedTrack(context.Background(), &livekit.MuteRoomTrackRequest{ + Room: roomName, + Identity: identity, + TrackSid: track.Sid, + Muted: true, + }) + if err != nil { + return fmt.Errorf("mute track %s: %w", track.Sid, err) + } + } + return nil +} + func boolPtr(b bool) *bool { return &b } diff --git a/internal/voice/handlers.go b/internal/voice/handlers.go index dc625fa..7642829 100644 --- a/internal/voice/handlers.go +++ b/internal/voice/handlers.go @@ -7,27 +7,32 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" "github.com/livekit/protocol/livekit" ) type Handler struct { - db *sql.DB - client *Client - hub *gateway.Hub + db *sql.DB + client *Client + hub *gateway.Hub + checker *permissions.Checker } func NewHandler(db *sql.DB, client *Client, hub *gateway.Hub) *Handler { return &Handler{ - db: db, - client: client, - hub: hub, + db: db, + client: client, + hub: hub, + checker: permissions.NewChecker(db), } } func (h *Handler) RegisterRoutes(r chi.Router) { + r.Use(middleware.RequireAuth) r.Post("/token", h.GetToken) r.Get("/rooms/{roomID}/participants", h.GetParticipants) + r.Post("/rooms/{roomID}/participants/{userID}/mute", h.MuteParticipant) } type tokenRequest struct { @@ -148,3 +153,53 @@ func (h *Handler) GetParticipants(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } + +// @Summary Mute a participant +// @Description Mute a participant's tracks in a voice room (requires MUTE_MEMBERS permission) +// @Tags voice +// @Security SessionAuth +// @Param roomID path string true "Room ID" +// @Param userID path string true "User ID (Identity)" +// @Success 200 {object} map[string]string +// @Router /voice/rooms/{roomID}/participants/{userID}/mute [post] +func (h *Handler) MuteParticipant(w http.ResponseWriter, r *http.Request) { + roomID := chi.URLParam(r, "roomID") + targetIdentity := chi.URLParam(r, "userID") + + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // Find the server_id for this channel to check permissions. + var serverID string + err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, roomID).Scan(&serverID) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, `{"error":"room not found or not a server channel"}`, http.StatusNotFound) + } else { + http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError) + } + return + } + + hasPerm, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, roomID, permissions.MUTE_MEMBERS) + if err != nil { + http.Error(w, `{"error":"failed to check permissions"}`, http.StatusInternalServerError) + return + } + if !hasPerm { + http.Error(w, `{"error":"forbidden: missing MUTE_MEMBERS permission"}`, http.StatusForbidden) + return + } + + if err := h.client.MuteParticipant(roomID, targetIdentity); err != nil { + http.Error(w, `{"error":"failed to mute participant"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "success"}) +} diff --git a/web/src/components/VoicePanel.tsx b/web/src/components/VoicePanel.tsx index e8b335f..8707f3f 100644 --- a/web/src/components/VoicePanel.tsx +++ b/web/src/components/VoicePanel.tsx @@ -1,5 +1,7 @@ import { useVoiceStore } from '../stores/voice.ts'; import { useAuthStore } from '../stores/auth.ts'; +import { useServerStore } from '../stores/server.ts'; +import { api } from '../lib/api.ts'; import { VoiceControls } from './VoiceControls.tsx'; import { VideoGrid } from './VideoGrid.tsx'; import { AudioRenderers } from './AudioRenderers.tsx'; @@ -15,6 +17,18 @@ export function VoicePanel() { const dismissWhisper = useVoiceStore((state) => state.dismissWhisper); const currentUserId = useAuthStore((state) => state.user?.id); + const userPermissions = useServerStore((state) => state.userPermissions); + const canMute = userPermissions.mute_members || userPermissions.administrator; + + const handleMute = async (identity: string) => { + if (!currentRoom) return; + try { + await api.post(`/voice/rooms/${currentRoom}/participants/${identity}/mute`); + } catch (err) { + console.error('Failed to mute participant', err); + } + }; + if (!currentRoom) return null; return ( @@ -108,13 +122,24 @@ export function VoicePanel() { [speaking] )} {!isMe && ( - +
+ {canMute && !p.isMuted && ( + + )} + +
)} );