mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
- I-1: Add key holder election in Hub (lowest userID per channel); reject non-key-holder voice_e2ee_offer with NOT_KEY_HOLDER error - I-2: Accept raw (unpadded) base64 in E2EE announce/offer handlers via decodeBase64Loose fallback - I-6: Copy E2EE public key value while h.mu.RLock is held in getClientE2EEPubKey - I-7: Lower loginRateLimitPerMinute from 60 to 5 - C-1: TOCTOU fix — target channel check held under same lock as client lookup - C-2: Include is_key_holder bool in voice_token payload so client knows whether to initiate key distribution - M-5/M-6: Add ErrCodeBadPayload/ErrCodeNotKeyHolder error constants - Fix pre-existing api build errors: block_handler.go getUserFromContext, router.go RequirePermission arg count - Add user_blocks table to all test DB schemas (ws, api DM) - Add voice_e2ee_test.go and constants_test.go covering all fixes
117 lines
4.2 KiB
Go
117 lines
4.2 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// handleVoiceLeave processes an explicit voice_leave message or a disconnect.
|
|
// 1. Gets old voiceChID from clearVoiceChID().
|
|
// 2. If was in voice: remove from DB (with retry), broadcast voice_leave.
|
|
// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone).
|
|
func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) {
|
|
oldChID, oldJoinToken := c.clearVoiceState()
|
|
if oldChID == 0 {
|
|
slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID)
|
|
return
|
|
}
|
|
|
|
username := ""
|
|
if c.user != nil {
|
|
username = c.user.Username
|
|
}
|
|
slog.Info("voice leave",
|
|
"user_id", c.userID,
|
|
"username", username,
|
|
"channel_id", oldChID,
|
|
"remote", c.remoteAddr,
|
|
)
|
|
|
|
if err := leaveVoiceChannelWithRetry(ctx, h, c.userID, oldChID, oldJoinToken); err != nil {
|
|
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice leave failed — please rejoin if issues persist"))
|
|
}
|
|
|
|
h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID))
|
|
|
|
// Re-elect key holder now that this user has left the channel.
|
|
h.updateKeyHolder(oldChID)
|
|
|
|
// E2EE keys are now managed client-side via ECDH key exchange.
|
|
// When a participant leaves, remaining clients rotate the room key
|
|
// automatically — the server has no key material to clear.
|
|
|
|
// Remove from LiveKit (best-effort).
|
|
if h.livekit != nil {
|
|
if err := h.livekit.RemoveParticipant(oldChID, c.userID, oldJoinToken); err != nil { //nolint:contextcheck // TODO: propagate context through this call path
|
|
slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)",
|
|
"err", err, "user_id", c.userID, "channel_id", oldChID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// leaveVoiceChannelWithRetry attempts to remove the voice state from the DB
|
|
// using a channel-conditional delete. Only the row matching (userID, channelID)
|
|
// is removed — if the user has since moved to a different channel, the delete
|
|
// is a safe no-op. This prevents a race where a delayed retry could wipe a
|
|
// newer voice membership.
|
|
//
|
|
// The first attempt is synchronous. If it fails, subsequent retries run in a
|
|
// background goroutine with exponential backoff so the caller (readPump) is
|
|
// not blocked by time.Sleep. The goroutine respects ctx and the hub's stop
|
|
// channel to avoid leaking after shutdown (BUG-086).
|
|
// Returns nil on first-attempt success, the first error otherwise (retries
|
|
// continue in the background).
|
|
func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, channelID int64, joinToken string) error {
|
|
if joinToken == "" {
|
|
slog.Warn("LeaveVoiceChannelIfMatch skipped due to missing join token",
|
|
"user_id", userID, "channel_id", channelID)
|
|
return nil
|
|
}
|
|
|
|
// Synchronous first attempt — channel-conditional delete.
|
|
if _, err := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); err != nil {
|
|
slog.Warn("LeaveVoiceChannelIfMatch failed, retrying in background",
|
|
"err", err, "user_id", userID, "channel_id", channelID,
|
|
"attempt", 1, "max_retries", 3)
|
|
|
|
// Background retries — cancellable via ctx or hub stop.
|
|
go func() {
|
|
const maxRetries = 3
|
|
delay := 200 * time.Millisecond
|
|
|
|
for attempt := 2; attempt <= maxRetries; attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("LeaveVoiceChannelIfMatch retry cancelled (context)",
|
|
"user_id", userID, "channel_id", channelID, "attempt", attempt)
|
|
return
|
|
case <-h.stop:
|
|
slog.Info("LeaveVoiceChannelIfMatch retry cancelled (hub stop)",
|
|
"user_id", userID, "channel_id", channelID, "attempt", attempt)
|
|
return
|
|
case <-time.After(delay):
|
|
}
|
|
delay *= 2
|
|
|
|
if _, retryErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); retryErr != nil {
|
|
slog.Warn("LeaveVoiceChannelIfMatch retry failed",
|
|
"err", retryErr, "user_id", userID, "channel_id", channelID,
|
|
"attempt", attempt, "max_retries", maxRetries)
|
|
if attempt == maxRetries {
|
|
slog.Error("LeaveVoiceChannelIfMatch exhausted retries — ghost state may persist",
|
|
"err", retryErr, "user_id", userID, "channel_id", channelID)
|
|
}
|
|
} else {
|
|
slog.Info("LeaveVoiceChannelIfMatch succeeded on retry",
|
|
"user_id", userID, "channel_id", channelID, "attempt", attempt)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
return err
|
|
}
|
|
return nil
|
|
}
|