Files
OwnCord/Server/ws/voice_controls.go
T
jevb 3236918012 refactor: server hardening + client decomposition + protocol resilience
Server:
- Split monolithic voice_handlers.go into voice_join/leave/controls/broadcast
- Add metrics endpoint (admin-IP-restricted /api/v1/metrics)
- Add orphaned attachment cleanup in maintenance loop
- Add sentinel errors (db/errors.go, ws/errors.go)
- Add ring buffer for event replay on reconnect
- Add heartbeat monitoring with stale connection sweep
- Improve hub with panic recovery, graceful shutdown, seq tracking
- Typed message structs replace raw map[string]interface{}

Client:
- Decompose MainPage into ChatArea + SidebarArea controllers
- Add disposable.ts lifecycle management pattern
- Add member list right-click context menu (kick/ban/role)
- Tighten CSP (media-src, font-src, object-src, base-uri)
- Improve store with shallowEqual, 500-msg cap, batch updates
- Add search API endpoint wiring
- Fix LiveKit session cleanup and reconnection

Docs:
- Add CODEMAPS for architecture, backend, frontend, data, deps
- Add protocol-schema.json (machine-readable, 36 message types)
- Add platform research report
- Update PROTOCOL.md with seq/replay fields
2026-03-21 10:08:44 +01:00

154 lines
4.8 KiB
Go

package ws
import (
"encoding/json"
"fmt"
"log/slog"
"github.com/owncord/server/permissions"
)
// handleVoiceMute processes a voice_mute message.
// 1. Parses muted bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) {
var p struct {
Muted bool `json:"muted"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_mute payload"))
return
}
if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil {
slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update mute state"))
return
}
slog.Debug("voice mute changed", "user_id", c.userID, "muted", p.Muted)
h.broadcastVoiceStateUpdate(c)
}
// handleVoiceDeafen processes a voice_deafen message.
// 1. Parses deafened bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
var p struct {
Deafened bool `json:"deafened"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_deafen payload"))
return
}
if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil {
slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update deafen state"))
return
}
slog.Debug("voice deafen changed", "user_id", c.userID, "deafened", p.Deafened)
h.broadcastVoiceStateUpdate(c)
}
// handleVoiceCamera processes a voice_camera message.
// 1. Rate limits at 2/sec per user.
// 2. Checks USE_VIDEO permission.
// 3. Parses enabled bool.
// 4. Enforces MaxVideo limit via LiveKit.
// 5. Updates DB.
// 6. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceCamera(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_camera:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceCameraRateLimit, voiceCameraWindow) {
c.sendMsg(buildRateLimitError("too many camera toggles", voiceCameraWindow.Seconds()))
return
}
voiceChID := c.getVoiceChID()
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
}
if !h.requireChannelPerm(c, voiceChID, permissions.UseVideo, "USE_VIDEO") {
return
}
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_camera payload"))
return
}
// Enforce MaxVideo limit when enabling camera.
if p.Enabled {
ch, chErr := h.db.GetChannel(voiceChID)
if chErr == nil && ch != nil && ch.VoiceMaxVideo > 0 && h.livekit != nil {
videoCount, countErr := h.livekit.CountVideoTracks(voiceChID)
if countErr != nil {
slog.Error("handleVoiceCamera CountVideoTracks", "err", countErr, "channel_id", voiceChID)
} else if videoCount >= ch.VoiceMaxVideo {
c.sendMsg(buildErrorMsg(ErrCodeVideoLimit,
fmt.Sprintf("maximum %d video streams reached", ch.VoiceMaxVideo)))
return
}
}
}
if err := h.db.UpdateVoiceCamera(c.userID, p.Enabled); err != nil {
slog.Error("ws handleVoiceCamera UpdateVoiceCamera", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update camera state"))
return
}
slog.Debug("voice camera changed", "user_id", c.userID, "enabled", p.Enabled)
h.broadcastVoiceStateUpdate(c)
}
// handleVoiceScreenshare processes a voice_screenshare message.
// 1. Rate limits at 2/sec per user.
// 2. Checks SHARE_SCREEN permission.
// 3. Parses enabled bool.
// 4. Updates DB.
// 5. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceScreenshare(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_screenshare:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceScreenshareRateLimit, voiceScreenshareWindow) {
c.sendMsg(buildRateLimitError("too many screenshare toggles", voiceScreenshareWindow.Seconds()))
return
}
voiceChID := c.getVoiceChID()
if voiceChID == 0 {
c.sendMsg(buildErrorMsg(ErrCodeVoiceError, "not in a voice channel"))
return
}
if !h.requireChannelPerm(c, voiceChID, permissions.ShareScreen, "SHARE_SCREEN") {
return
}
var p struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "invalid voice_screenshare payload"))
return
}
if err := h.db.UpdateVoiceScreenshare(c.userID, p.Enabled); err != nil {
slog.Error("ws handleVoiceScreenshare UpdateVoiceScreenshare", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to update screenshare state"))
return
}
slog.Debug("voice screenshare changed", "user_id", c.userID, "enabled", p.Enabled)
h.broadcastVoiceStateUpdate(c)
}