mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
channel_create and channel_update were handed to BroadcastToAll and enqueued with channelID 0, so the full channel payload -- name, topic and category of a channel that channel_overrides hides from the recipient's role -- went to every connected client and was replayed unconditionally from the ring buffer. Both now resolve an audience through the same READ_MESSAGES helper the voice path uses and enqueue under the real channel id, which filters live delivery and both replay tiers by one mechanism. channel_delete stays unfiltered by design: the row is already gone, so a check there would strand the channel in the sidebar of users who saw it via a positive override. Verified by a panel of agents; a base-revert control fails on both the live leak and the replay leak, while the pre-existing broadcast tests pass unmodified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1290 lines
45 KiB
Go
1290 lines
45 KiB
Go
// Package ws provides the WebSocket hub and client management for OwnCord.
|
|
package ws
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/plugin"
|
|
"github.com/owncord/server/service"
|
|
"github.com/owncord/server/stackutil"
|
|
"github.com/owncord/server/syncutil"
|
|
)
|
|
|
|
// broadcastMsg is an internal message queued for delivery.
|
|
type broadcastMsg struct {
|
|
channelID int64 // 0 = send to all connected clients
|
|
msg []byte
|
|
// recipients, when non-nil, replaces topic fan-out with direct delivery to
|
|
// exactly these user IDs. Used by voice_state/voice_leave: they are global
|
|
// in scope (every sidebar shows them) but must not disclose a channel the
|
|
// recipient's role may not READ, and the audience is resolved off the hub
|
|
// goroutine so deliverBroadcast stays free of permission queries.
|
|
recipients []int64
|
|
}
|
|
|
|
// Hub manages all active WebSocket clients and routes messages between them.
|
|
// All exported methods are safe to call from multiple goroutines.
|
|
type Hub struct {
|
|
clients map[int64]*Client
|
|
mu syncutil.RWMutex
|
|
db *db.DB
|
|
limiter *auth.RateLimiter
|
|
broadcast chan broadcastMsg
|
|
// clientEvents carries register AND unregister requests on one channel so
|
|
// a connection's Register→Unregister sequence is processed in submission
|
|
// order. With two separate channels, Run's select picked randomly between
|
|
// them when both were ready — a fast connect/disconnect could process the
|
|
// unregister first (a no-op for an unknown client) and then the register,
|
|
// admitting an already-dead client as a ghost until the stale sweep.
|
|
clientEvents chan clientEvent
|
|
stop chan struct{}
|
|
stopOnce sync.Once
|
|
gracefulOnce sync.Once
|
|
livekit *LiveKitClient
|
|
lkProcess *LiveKitProcess
|
|
registry *HandlerRegistry
|
|
permChecker *permissions.Checker
|
|
// messageSvc gates plugin broadcasts through the same posting policy as a
|
|
// real message send (permissions, DM membership, DM blocks). Nil only in
|
|
// bare test hubs; the broadcast gate fails closed then.
|
|
messageSvc *service.MessageService
|
|
|
|
pubsub *PubSub // topic-based pub/sub for O(subscribers) broadcast
|
|
topicLimiter *TopicRateLimiter // per-topic throughput caps
|
|
|
|
seq uint64 // atomic monotonic sequence counter
|
|
seqMu syncutil.Mutex // serializes seq assignment + replay insertion + delivery order
|
|
replayBuf *EventRingBuffer // recent broadcast events for reconnection replay
|
|
broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel
|
|
|
|
// Phase B Step 7 — event persistence. nil = ring buffer only. Atomic
|
|
// because main.go wires these after NewRouter has already started the
|
|
// Run loop, which reads them on the broadcast/replay paths.
|
|
eventPersister atomic.Pointer[EventPersister]
|
|
eventStore atomic.Pointer[EventStore] // read path for cold-tier replay
|
|
|
|
// Phase C Step 9 — plugin wiring.
|
|
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; wire before Run
|
|
pluginSink atomic.Pointer[plugin.EventSink] // hub→plugin event fan-out; nil = no plugins
|
|
|
|
// running flips when Run starts; plain-field setters check it so a late
|
|
// call fails loudly instead of racing the dispatch loop.
|
|
running atomic.Bool
|
|
|
|
// Phase B Step 7 — reconnection tier metrics. Incremented per resume.
|
|
reconnectTierBuf atomic.Uint64
|
|
reconnectTierDB atomic.Uint64
|
|
reconnectTierFull atomic.Uint64
|
|
|
|
// Sequence watermark of the last channel-visibility change. Visibility
|
|
// updates are sent as targeted, unsequenced messages, so clients resuming
|
|
// from a seq at or before this point must take the full-ready path to
|
|
// converge (replay cannot deliver them). Reset on restart — a fresh
|
|
// connection always gets a correctly filtered ready payload anyway.
|
|
visibilityChangeSeq atomic.Uint64
|
|
|
|
// Settings cache — avoids per-connection DB queries for server_name/motd.
|
|
settingsMu syncutil.RWMutex
|
|
settingsName string
|
|
settingsMotd string
|
|
settingsLastUpdate time.Time
|
|
|
|
// voiceKeyHolders maps channelID → userID of the current key holder.
|
|
// The key holder is the connected participant with the lowest userID in the channel.
|
|
// Protected by keyHolderMu.
|
|
keyHolderMu sync.RWMutex
|
|
voiceKeyHolders map[int64]int64
|
|
}
|
|
|
|
// NewHub creates a Hub ready to be started with Run.
|
|
// It also initializes the settings cache from the database.
|
|
// If svc is non-nil, V2 handlers receive service references for business logic delegation.
|
|
func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *Hub {
|
|
reg := NewHandlerRegistry()
|
|
|
|
h := &Hub{
|
|
clients: make(map[int64]*Client),
|
|
db: database,
|
|
limiter: limiter,
|
|
broadcast: make(chan broadcastMsg, 1024),
|
|
clientEvents: make(chan clientEvent, 64),
|
|
stop: make(chan struct{}),
|
|
pubsub: NewPubSub(),
|
|
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
|
replayBuf: NewEventRingBuffer(1000),
|
|
registry: reg,
|
|
permChecker: permissions.NewChecker(database),
|
|
settingsName: "OwnCord Server",
|
|
settingsMotd: "Welcome!",
|
|
voiceKeyHolders: make(map[int64]int64),
|
|
}
|
|
|
|
// V2 handler registrations (need Hub fields for deps).
|
|
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
|
|
|
|
chatDeps := ChatDeps{
|
|
Limiter: h.limiter,
|
|
}
|
|
presenceDeps := PresenceDeps{
|
|
Limiter: h.limiter,
|
|
}
|
|
reactionDeps := ReactionDeps{}
|
|
if svc != nil {
|
|
chatDeps.MessageSvc = svc.Messages
|
|
presenceDeps.ChannelSvc = svc.Channels
|
|
reactionDeps.MessageSvc = svc.Messages
|
|
h.messageSvc = svc.Messages
|
|
}
|
|
|
|
registerChatHandlers(reg, chatDeps)
|
|
registerPresenceHandlers(reg, presenceDeps)
|
|
registerReactionHandlers(reg, reactionDeps)
|
|
// Phase C Step 9 — plugin slash commands. Registry is read live because
|
|
// SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts.
|
|
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
|
|
Registry: func() *plugin.Registry { return h.pluginRegistry },
|
|
MessageSvc: h.messageSvc,
|
|
})
|
|
registerVoiceControlsV2(reg, VoiceDeps{
|
|
DB: h.db,
|
|
Limiter: h.limiter,
|
|
Permissions: h.permChecker,
|
|
LiveKit: h.livekit,
|
|
TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit)
|
|
KeyHolder: h,
|
|
})
|
|
|
|
h.refreshSettingsLocked(context.Background())
|
|
return h
|
|
}
|
|
|
|
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
|
|
func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
|
|
h.settingsMu.RLock()
|
|
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
|
name, motd := h.settingsName, h.settingsMotd
|
|
h.settingsMu.RUnlock()
|
|
return name, motd
|
|
}
|
|
h.settingsMu.RUnlock()
|
|
|
|
h.settingsMu.Lock()
|
|
defer h.settingsMu.Unlock()
|
|
// Double-check after acquiring write lock.
|
|
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
|
return h.settingsName, h.settingsMotd
|
|
}
|
|
h.refreshSettingsLocked(ctx)
|
|
return h.settingsName, h.settingsMotd
|
|
}
|
|
|
|
// refreshSettingsLocked reloads server_name and motd from the DB.
|
|
// Caller must hold settingsMu (write lock) or call during init.
|
|
func (h *Hub) refreshSettingsLocked(ctx context.Context) {
|
|
if h.db == nil {
|
|
return
|
|
}
|
|
// The refresh serves the hub-wide settings cache, not the connection that
|
|
// happened to trigger it — a dying connection's ctx must not fail the
|
|
// fetches (the TTL stamp below would then pin stale values for 30s).
|
|
ctx = context.WithoutCancel(ctx)
|
|
if name, err := h.db.GetSetting(ctx, "server_name"); err == nil {
|
|
h.settingsName = name
|
|
}
|
|
if motd, err := h.db.GetSetting(ctx, "motd"); err == nil {
|
|
h.settingsMotd = motd
|
|
}
|
|
h.settingsLastUpdate = time.Now()
|
|
}
|
|
|
|
// SetLiveKit sets the LiveKit client on the hub. Must be called before Run;
|
|
// late calls are ignored with an error log.
|
|
func (h *Hub) SetLiveKit(lk *LiveKitClient) {
|
|
if h.rejectIfRunning("SetLiveKit") {
|
|
return
|
|
}
|
|
h.livekit = lk
|
|
}
|
|
|
|
// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit
|
|
// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed
|
|
// as a dep at registration time (before SetLiveKit is called).
|
|
func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) {
|
|
if h.livekit == nil {
|
|
return "", fmt.Errorf("voice not configured")
|
|
}
|
|
return h.livekit.GenerateToken(userID, username, channelID, voiceJoinToken, canPublish, canSubscribe, canVideo, canScreenShare)
|
|
}
|
|
|
|
// URL delegates to the LiveKit client. Returns empty string if not configured.
|
|
func (h *Hub) URL() string {
|
|
if h.livekit == nil {
|
|
return ""
|
|
}
|
|
return h.livekit.URL()
|
|
}
|
|
|
|
// LiveKitHealthCheck probes the LiveKit server for connectivity.
|
|
// It tries the SDK client first (ListRooms), and falls back to an HTTP probe
|
|
// if a managed process is configured. Returns false with a reason if LiveKit
|
|
// is not configured or unreachable.
|
|
func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) {
|
|
if h.livekit == nil {
|
|
return false, fmt.Errorf("not configured")
|
|
}
|
|
return h.livekit.HealthCheck(ctx)
|
|
}
|
|
|
|
// SetLiveKitProcess sets the LiveKit process manager on the hub. Must be
|
|
// called before Run; late calls are ignored with an error log.
|
|
func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
|
|
if h.rejectIfRunning("SetLiveKitProcess") {
|
|
return
|
|
}
|
|
h.lkProcess = p
|
|
}
|
|
|
|
// Run starts the hub's dispatch loop. It blocks until Stop is called.
|
|
// Must be called in its own goroutine.
|
|
//
|
|
// A panic recovery wrapper restarts the select loop automatically. If the hub
|
|
// panics more than 3 times within a 60-second window it stops permanently to
|
|
// avoid a tight crash loop.
|
|
func (h *Hub) Run() {
|
|
h.running.Store(true)
|
|
var panicCount int
|
|
var lastPanicReset time.Time
|
|
|
|
for {
|
|
func() {
|
|
staleTicker := time.NewTicker(30 * time.Second)
|
|
defer staleTicker.Stop()
|
|
sessionSweepTicker := time.NewTicker(30 * time.Second)
|
|
defer sessionSweepTicker.Stop()
|
|
voiceSweepTicker := time.NewTicker(60 * time.Second)
|
|
defer voiceSweepTicker.Stop()
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
now := time.Now()
|
|
if lastPanicReset.IsZero() || now.Sub(lastPanicReset) > 60*time.Second {
|
|
panicCount = 0
|
|
lastPanicReset = now
|
|
}
|
|
panicCount++
|
|
|
|
slog.Error("hub: panic recovered",
|
|
"panic", r,
|
|
"panic_count", panicCount,
|
|
"stack", stackutil.Capture())
|
|
|
|
if panicCount >= 3 {
|
|
slog.Error("hub: too many panics in 60s, stopping")
|
|
h.Stop()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-h.stop:
|
|
return
|
|
case ev := <-h.clientEvents:
|
|
if ev.add {
|
|
// No handshake permission set on this path (and no DB
|
|
// call allowed on the hub goroutine) — nil denies the
|
|
// inherited voice-channel subscription.
|
|
h.registerNow(ev.c, nil)
|
|
} else {
|
|
h.unregisterNow(ev.c)
|
|
}
|
|
case bm := <-h.broadcast:
|
|
h.deliverBroadcast(bm)
|
|
case <-staleTicker.C:
|
|
h.sweepStaleClients()
|
|
case <-sessionSweepTicker.C:
|
|
h.sweepRevokedSessions()
|
|
case <-voiceSweepTicker.C:
|
|
h.sweepStaleVoiceStates()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// If we reach here without a panic recovery continuing, stop.
|
|
if panicCount >= 3 {
|
|
return
|
|
}
|
|
// If stop was signaled, exit.
|
|
select {
|
|
case <-h.stop:
|
|
return
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop signals Run to exit. Safe to call multiple times.
|
|
func (h *Hub) Stop() {
|
|
h.stopOnce.Do(func() { close(h.stop) })
|
|
}
|
|
|
|
// GracefulStop stops the LiveKit process (if managed) and then stops the hub.
|
|
// Safe to call multiple times concurrently.
|
|
func (h *Hub) GracefulStop() {
|
|
h.gracefulOnce.Do(func() {
|
|
// Broadcast restart notice to all connected clients.
|
|
h.BroadcastServerRestart("shutdown", 5)
|
|
|
|
// Stop LiveKit process.
|
|
if h.lkProcess != nil {
|
|
h.lkProcess.Stop()
|
|
}
|
|
|
|
// Give clients 5 seconds to disconnect gracefully.
|
|
time.Sleep(5 * time.Second)
|
|
|
|
// Close all remaining client connections.
|
|
h.mu.Lock()
|
|
for _, c := range h.clients {
|
|
c.closeSend()
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
// Stop the hub dispatch loop.
|
|
h.stopOnce.Do(func() { close(h.stop) })
|
|
})
|
|
}
|
|
|
|
// CleanupVoiceForChannel removes all voice participants from the given channel.
|
|
// Called when a channel is deleted.
|
|
func (h *Hub) CleanupVoiceForChannel(channelID int64) {
|
|
// Cleanup must complete even if the triggering request goes away.
|
|
ctx := context.Background()
|
|
// Get all users in the channel's voice state from DB.
|
|
states, err := h.db.GetChannelVoiceStates(ctx, channelID)
|
|
if err != nil {
|
|
slog.Error("CleanupVoiceForChannel GetChannelVoiceStates", "err", err, "channel_id", channelID)
|
|
return
|
|
}
|
|
if len(states) == 0 {
|
|
return
|
|
}
|
|
|
|
// Clean up DB state and LiveKit for each participant.
|
|
for _, vs := range states {
|
|
if err := h.db.LeaveVoiceChannel(ctx, vs.UserID); err != nil {
|
|
slog.Error("CleanupVoiceForChannel LeaveVoiceChannel", "err", err, "user_id", vs.UserID, "channel_id", channelID)
|
|
}
|
|
|
|
// Clear client voice state.
|
|
h.mu.RLock()
|
|
if client, ok := h.clients[vs.UserID]; ok {
|
|
client.clearVoiceChID()
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
// Remove from LiveKit (best-effort).
|
|
if h.livekit != nil {
|
|
_ = h.livekit.RemoveParticipant(ctx, channelID, vs.UserID, vs.JoinedAt)
|
|
}
|
|
}
|
|
|
|
// Broadcast voice_leave for each participant.
|
|
for _, vs := range states {
|
|
h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, vs.UserID))
|
|
}
|
|
}
|
|
|
|
// IsUserConnected returns true if a client with the given userID is already
|
|
// registered in the hub. Safe to call from any goroutine.
|
|
func (h *Hub) IsUserConnected(userID int64) bool {
|
|
h.mu.RLock()
|
|
_, ok := h.clients[userID]
|
|
h.mu.RUnlock()
|
|
return ok
|
|
}
|
|
|
|
// GetClient returns the client for userID, or nil if not connected.
|
|
// Safe to call from any goroutine.
|
|
func (h *Hub) GetClient(userID int64) *Client {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return h.clients[userID]
|
|
}
|
|
|
|
// Register queues a client for registration with the hub.
|
|
func (h *Hub) Register(c *Client) {
|
|
h.clientEvents <- clientEvent{c: c, add: true}
|
|
}
|
|
|
|
// Unregister queues a client for removal from the hub.
|
|
func (h *Hub) Unregister(c *Client) {
|
|
h.clientEvents <- clientEvent{c: c}
|
|
}
|
|
|
|
// clientEvent is a register (add=true) or unregister (add=false) request.
|
|
// Both kinds share one channel so per-connection ordering is preserved.
|
|
type clientEvent struct {
|
|
c *Client
|
|
add bool
|
|
}
|
|
|
|
// registerNow adds c to the hub and subscribes it to its topics.
|
|
//
|
|
// readableChannelIDs is the set of channels the user holds READ_MESSAGES on,
|
|
// as computed by the handshake (serve.go). It gates the inherited voice-channel
|
|
// subscription only; a nil set denies it (fail closed).
|
|
func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
|
|
h.mu.Lock()
|
|
if old, exists := h.clients[c.userID]; exists {
|
|
oldVoiceChID, oldVoiceJoinToken := old.clearVoiceState()
|
|
if c.lastSeq > 0 {
|
|
// Network reconnect — preserve voice state so the user stays
|
|
// in voice during brief WS drops.
|
|
if c.getVoiceChID() == 0 {
|
|
c.setVoiceState(oldVoiceChID, oldVoiceJoinToken)
|
|
}
|
|
}
|
|
// Fresh connections (lastSeq == 0): do NOT transfer voice state.
|
|
// Stale voice cleanup (DB + broadcast + LiveKit) is owned entirely
|
|
// by the handshake path in serve.go, which runs before registerNow.
|
|
// registerNow only handles in-memory client replacement.
|
|
|
|
// Remove the old client from all pub/sub topics before replacing.
|
|
h.pubsub.UnsubscribeAll(old)
|
|
|
|
// Kick the stale connection atomically before registering
|
|
// the new one — prevents TOCTOU races on duplicate login.
|
|
slog.Warn("hub: kicking stale connection for re-registering user",
|
|
"user_id", c.userID, "last_seq", c.lastSeq)
|
|
old.closeSend()
|
|
}
|
|
h.clients[c.userID] = c
|
|
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients))
|
|
h.mu.Unlock()
|
|
|
|
// Subscribe the new client to default pub/sub topics.
|
|
h.pubsub.Subscribe(c, TopicGlobal)
|
|
h.pubsub.Subscribe(c, UserTopic(c.userID))
|
|
// If the client already has a focused channel (e.g. test clients created with
|
|
// NewTestClientWithChannel, or reconnecting clients), subscribe immediately so
|
|
// deliverBroadcast can reach them without waiting for a channel_focus message.
|
|
if chID := c.getChannelID(); chID != 0 {
|
|
h.pubsub.Subscribe(c, ChannelTopic(chID))
|
|
}
|
|
// If the client is already in a voice channel (e.g. reconnect), re-subscribe
|
|
// to that channel's topic so the message stream keeps flowing without a new
|
|
// channel_focus. Voice membership is gated on CONNECT_VOICE alone, so it must
|
|
// not by itself grant a channel's message stream: subscribe only when the
|
|
// handshake confirmed READ_MESSAGES on that channel.
|
|
if voiceChID := c.getVoiceChID(); voiceChID != 0 && readableChannelIDs[voiceChID] {
|
|
h.pubsub.Subscribe(c, ChannelTopic(voiceChID))
|
|
}
|
|
}
|
|
|
|
func (h *Hub) unregisterNow(c *Client) bool {
|
|
h.mu.Lock()
|
|
current, exists := h.clients[c.userID]
|
|
if exists && current == c {
|
|
delete(h.clients, c.userID)
|
|
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
|
|
h.mu.Unlock()
|
|
h.pubsub.UnsubscribeAll(c)
|
|
return false // not replaced
|
|
}
|
|
h.mu.Unlock()
|
|
return true // different client registered = was replaced
|
|
}
|
|
|
|
// BroadcastToChannel enqueues msg for delivery to all clients subscribed to
|
|
// channelID. When channelID is 0 the message is sent to every connected client.
|
|
// Non-blocking: if the broadcast channel is full the message is dropped with a warning.
|
|
func (h *Hub) BroadcastToChannel(channelID int64, msg []byte) {
|
|
select {
|
|
case h.broadcast <- broadcastMsg{channelID: channelID, msg: msg}:
|
|
default:
|
|
h.broadcastDrops.Add(1)
|
|
slog.Warn("hub: broadcast channel full, dropping message",
|
|
"channel_id", channelID, "msg_len", len(msg))
|
|
}
|
|
}
|
|
|
|
// BroadcastToAll enqueues msg for delivery to every connected client.
|
|
// Non-blocking: if the broadcast channel is full the message is dropped with a warning.
|
|
func (h *Hub) BroadcastToAll(msg []byte) {
|
|
select {
|
|
case h.broadcast <- broadcastMsg{channelID: 0, msg: msg}:
|
|
default:
|
|
h.broadcastDrops.Add(1)
|
|
slog.Warn("hub: broadcast channel full, dropping global message",
|
|
"msg_len", len(msg))
|
|
}
|
|
}
|
|
|
|
// broadcastVoiceEvent enqueues a voice_state / voice_leave message for the
|
|
// connected clients whose current role may READ channelID.
|
|
//
|
|
// These events used to go out via BroadcastToAll, which handed every
|
|
// authenticated client the membership and camera/mute state of voice channels
|
|
// that channel_overrides hides from their role — while the equivalent read path
|
|
// (buildReady) deliberately filters voice states to readable channels. Tagging
|
|
// the event with its real channel id also makes reconnect replay filter it,
|
|
// where a channelID of 0 was replayed unconditionally.
|
|
//
|
|
// The audience is resolved here, on the caller's goroutine, so the hub's
|
|
// dispatch loop never blocks on permission lookups.
|
|
func (h *Hub) broadcastVoiceEvent(ctx context.Context, channelID int64, msg []byte) {
|
|
h.broadcastChannelScoped(ctx, channelID, msg, "voice event")
|
|
}
|
|
|
|
// broadcastChannelScoped enqueues msg for exactly the connected clients whose
|
|
// current role may READ channelID, tagged with that channel id so reconnect
|
|
// replay filters it too (EventsSinceFiltered replays a channelID of 0
|
|
// unconditionally). kind only labels the drop warning.
|
|
func (h *Hub) broadcastChannelScoped(ctx context.Context, channelID int64, msg []byte, kind string) {
|
|
bm := broadcastMsg{
|
|
channelID: channelID,
|
|
msg: msg,
|
|
recipients: h.channelReadAudience(ctx, channelID),
|
|
}
|
|
select {
|
|
case h.broadcast <- bm:
|
|
default:
|
|
h.broadcastDrops.Add(1)
|
|
slog.Warn("hub: broadcast channel full, dropping "+kind,
|
|
"channel_id", channelID, "msg_len", len(msg))
|
|
}
|
|
}
|
|
|
|
// channelReadAudience returns the connected user IDs whose current role may READ
|
|
// channelID. Always non-nil, so an empty result means "deliver to nobody"
|
|
// rather than "no filter". Roles are resolved per client (an admin may have
|
|
// reassigned one mid-session) and the channel verdict is memoised per role, so
|
|
// the cost is one role lookup per connected client plus one override lookup per
|
|
// distinct role. Fails closed: a client whose role cannot be resolved is left
|
|
// out. Mirrors RefreshChannelVisibility, which resolves visibility the same way.
|
|
func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 {
|
|
h.mu.RLock()
|
|
userIDs := make([]int64, 0, len(h.clients))
|
|
for uid := range h.clients {
|
|
userIDs = append(userIDs, uid)
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
audience := make([]int64, 0, len(userIDs))
|
|
if h.db == nil || h.permChecker == nil {
|
|
return audience
|
|
}
|
|
visibleByRole := make(map[int64]bool)
|
|
for _, uid := range userIDs {
|
|
role, err := h.db.GetRoleForUser(ctx, uid)
|
|
if err != nil || role == nil {
|
|
continue
|
|
}
|
|
visible, ok := visibleByRole[role.ID]
|
|
if !ok {
|
|
visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, role.ID, channelID, permissions.ReadMessages)
|
|
visibleByRole[role.ID] = visible
|
|
}
|
|
if visible {
|
|
audience = append(audience, uid)
|
|
}
|
|
}
|
|
return audience
|
|
}
|
|
|
|
// BroadcastServerRestart sends a server_restart message to all connected clients.
|
|
// reason describes why the server is restarting (e.g., "update").
|
|
// delaySeconds tells clients how long until the server actually shuts down.
|
|
func (h *Hub) BroadcastServerRestart(reason string, delaySeconds int) {
|
|
h.BroadcastToAll(buildServerRestartMsg(reason, delaySeconds))
|
|
}
|
|
|
|
// BroadcastChannelCreate sends a channel_create message to the connected
|
|
// clients whose current role may READ ch. It used to go out via BroadcastToAll,
|
|
// which handed every authenticated client the name, category and topic of a
|
|
// channel that channel_overrides hides from their role — metadata the ready
|
|
// payload (buildReady/VisibleChannelIDs) deliberately withholds.
|
|
//
|
|
// The admin HubBroadcaster interface carries no context, so — like
|
|
// RefreshChannelVisibility — the audience is resolved against Background: the
|
|
// fan-out must complete regardless of the triggering request.
|
|
func (h *Hub) BroadcastChannelCreate(ch *db.Channel) {
|
|
h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelCreate(ch), "channel_create")
|
|
}
|
|
|
|
// BroadcastChannelUpdate sends a channel_update message to the connected
|
|
// clients whose current role may READ ch. Same disclosure as
|
|
// BroadcastChannelCreate; same filtered fan-out.
|
|
func (h *Hub) BroadcastChannelUpdate(ch *db.Channel) {
|
|
h.broadcastChannelScoped(context.Background(), ch.ID, buildChannelUpdate(ch), "channel_update")
|
|
}
|
|
|
|
// BroadcastChannelDelete sends a channel_delete message to all connected clients.
|
|
//
|
|
// Deliberately unfiltered: the payload is the bare channel id, with none of the
|
|
// metadata create/update carry, and by the time the admin handler calls this the
|
|
// channel row — and with it the ON DELETE CASCADE'd channel_overrides — is
|
|
// already gone, so a permission check here would answer from base role perms
|
|
// and could drop the delete for exactly the users who saw the channel via a
|
|
// positive override, stranding it in their sidebar.
|
|
func (h *Hub) BroadcastChannelDelete(channelID int64) {
|
|
h.BroadcastToAll(buildChannelDelete(channelID))
|
|
}
|
|
|
|
// RefreshChannelVisibility re-evaluates which connected clients may see ch
|
|
// after a channel_overrides change and sends targeted channel_create /
|
|
// channel_delete messages so sidebars converge without a reconnect. Clients
|
|
// that lose visibility are also unsubscribed from the channel topic and have
|
|
// their focused channel cleared so live messages stop flowing.
|
|
//
|
|
// The sends deliberately bypass the sequenced broadcast/replay path: a
|
|
// replayed channel_delete would be filtered by the allowed-channel set
|
|
// computed at replay time, which after an override change is exactly the
|
|
// inverse of the intended audience. Clients tolerate seq-less messages.
|
|
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
|
|
if ch == nil {
|
|
return
|
|
}
|
|
|
|
h.mu.RLock()
|
|
clients := make([]*Client, 0, len(h.clients))
|
|
for _, c := range h.clients {
|
|
clients = append(clients, c)
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
// Called via the admin HubBroadcaster interface, which carries no context;
|
|
// the targeted re-sync must complete regardless of the triggering request.
|
|
ctx := context.Background()
|
|
|
|
// Visibility is a function of the role, so resolve each role once.
|
|
visibleByRole := make(map[int64]bool)
|
|
roleVisible := func(roleID int64) bool {
|
|
if v, ok := visibleByRole[roleID]; ok {
|
|
return v
|
|
}
|
|
visible := false
|
|
role, err := h.db.GetRoleByID(ctx, roleID)
|
|
if err == nil && role != nil {
|
|
// Single visibility predicate shared with buildReady / REST
|
|
// ListVisibleChannels; the checker fails closed on a lookup error
|
|
// and bypasses for admins, matching the other sites exactly.
|
|
visible = h.permChecker.HasChannelPerm(ctx, role.Permissions, roleID, ch.ID, permissions.ReadMessages)
|
|
}
|
|
visibleByRole[roleID] = visible
|
|
return visible
|
|
}
|
|
|
|
for _, c := range clients {
|
|
if c.user == nil {
|
|
continue
|
|
}
|
|
// c.user is a connect-time snapshot; an admin may have changed the
|
|
// user's role mid-session, so resolve the current role from the DB.
|
|
// Fail closed: on error send nothing rather than mis-target.
|
|
fresh, err := h.db.GetUserByID(ctx, c.user.ID)
|
|
if err != nil || fresh == nil {
|
|
slog.Warn("hub: RefreshChannelVisibility could not resolve user role",
|
|
"user_id", c.user.ID, "err", err)
|
|
continue
|
|
}
|
|
if roleVisible(fresh.RoleID) {
|
|
// Idempotent add on the client; also refreshes channel metadata.
|
|
c.sendMsg(buildChannelCreate(ch))
|
|
continue
|
|
}
|
|
c.sendMsg(buildChannelDelete(ch.ID))
|
|
h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))
|
|
c.mu.Lock()
|
|
if c.channelID == ch.ID {
|
|
c.channelID = 0
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Clients not connected right now missed the targeted sends above. Move
|
|
// the watermark so any resume from a seq at or before this point is
|
|
// forced onto the full-ready path instead of replay (stored after the
|
|
// sends so a concurrent seq advance errs toward re-syncing more clients).
|
|
h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq))
|
|
}
|
|
|
|
// mustFullResync reports whether a client resuming from lastSeq predates the
|
|
// most recent channel-visibility change and therefore cannot converge via
|
|
// replay.
|
|
func (h *Hub) mustFullResync(lastSeq uint64) bool {
|
|
w := h.visibilityChangeSeq.Load()
|
|
return w > 0 && lastSeq <= w
|
|
}
|
|
|
|
// BroadcastMemberBan sends a member_ban message to all connected clients
|
|
// and immediately disconnects the banned user's WebSocket connection (BUG-113).
|
|
func (h *Hub) BroadcastMemberBan(userID int64) {
|
|
h.BroadcastToAll(buildMemberBan(userID))
|
|
h.DisconnectUser(userID)
|
|
}
|
|
|
|
// DisconnectUser forcibly disconnects the client identified by userID.
|
|
// No-op if the user is not currently connected.
|
|
func (h *Hub) DisconnectUser(userID int64) {
|
|
h.mu.RLock()
|
|
c, ok := h.clients[userID]
|
|
h.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.Info("hub: disconnecting user", "user_id", userID)
|
|
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
|
|
h.kickClient(c)
|
|
}
|
|
|
|
// BroadcastUserUpdate sends a user_update message to all connected clients
|
|
// when a user changes their profile (username, avatar, identity key).
|
|
func (h *Hub) BroadcastUserUpdate(userID int64, username string, avatar *string, identityPublicKey *string) {
|
|
h.BroadcastToAll(buildUserUpdate(userID, username, avatar, identityPublicKey))
|
|
}
|
|
|
|
// BroadcastMemberUpdate sends a member_update message to all connected clients
|
|
// and re-evaluates the reassigned user's live channel subscriptions.
|
|
func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
|
|
h.BroadcastToAll(buildMemberUpdate(userID, roleName))
|
|
h.revokeUnreadableChannels(userID)
|
|
}
|
|
|
|
// revokeUnreadableChannels drops the channel-topic subscriptions the user's new
|
|
// role may no longer READ. READ_MESSAGES is checked once, at channel_focus, and
|
|
// then becomes a durable pub/sub subscription, so without this a demoted user
|
|
// keeps receiving every chat_message / chat_edited / reaction_update posted in
|
|
// the channels their old role could read for as long as the socket stays open.
|
|
//
|
|
// The per-client work mirrors RefreshChannelVisibility, the channel_overrides
|
|
// equivalent: targeted, unsequenced channel_delete + Unsubscribe (a replayed
|
|
// channel_delete would be filtered by the allowed set computed at replay time),
|
|
// then a visibilityChangeSeq bump so a client resuming across this change takes
|
|
// the full-ready path instead of replay.
|
|
//
|
|
// Only the topics the socket actually holds are examined — a blanket sweep over
|
|
// every channel would disclose the full channel-ID list to a demoted user.
|
|
func (h *Hub) revokeUnreadableChannels(userID int64) {
|
|
// Stored after the targeted sends (as in RefreshChannelVisibility) so a
|
|
// concurrent seq advance errs toward re-syncing more clients. Deferred
|
|
// because it must cover the early returns too: a user who is offline, or
|
|
// whose socket is closed below, converges via the full-ready path.
|
|
defer h.visibilityChangeSeq.Store(atomic.LoadUint64(&h.seq))
|
|
|
|
if h.db == nil {
|
|
return
|
|
}
|
|
h.mu.RLock()
|
|
c, ok := h.clients[userID]
|
|
h.mu.RUnlock()
|
|
if !ok || c.user == nil {
|
|
return
|
|
}
|
|
|
|
// Called via the admin HubBroadcaster interface, which carries no context;
|
|
// the re-evaluation must complete regardless of the triggering request.
|
|
ctx := context.Background()
|
|
|
|
// c.user is a connect-time snapshot and the role just changed, so resolve
|
|
// the current user — and through it the current role — from the DB.
|
|
var allowed map[int64]bool
|
|
user, err := h.db.GetUserByID(ctx, userID)
|
|
if err == nil && user != nil {
|
|
// Same predicate as the ready payload and reconnect replay filtering.
|
|
allowed, err = h.computeAllowedChannels(ctx, h.db, user)
|
|
}
|
|
if err != nil || user == nil {
|
|
// Visibility unresolved. Keeping the old subscriptions would leak, and
|
|
// revoking them all would hollow out a sidebar the user may still be
|
|
// entitled to, so close the socket instead: the client reconnects and
|
|
// rebuilds from a ready payload computed with the new role. kickClient
|
|
// rather than DisconnectUser — the latter sends a BANNED error, which
|
|
// makes the client clear its credentials instead of reconnecting.
|
|
slog.Warn("hub: role change visibility unresolved, closing socket",
|
|
"user_id", userID, "err", err)
|
|
h.kickClient(c)
|
|
return
|
|
}
|
|
|
|
for _, topic := range h.pubsub.TopicsForClient(userID) {
|
|
chID := channelTopicID(topic)
|
|
if chID == 0 || allowed[chID] {
|
|
continue
|
|
}
|
|
// DM access is gated on dm_participants, which no role change can
|
|
// alter, while allowed sources DMs from dm_open_state — a DM the user
|
|
// has closed (or every DM, if the DM lookup inside
|
|
// computeAllowedChannels failed) is missing from allowed even though
|
|
// its subscription is still legitimate. Never revoke a DM topic here;
|
|
// on a lookup error close the socket rather than guess.
|
|
ch, chErr := h.db.GetChannel(ctx, chID)
|
|
if chErr != nil {
|
|
slog.Warn("hub: role change channel lookup failed, closing socket",
|
|
"user_id", userID, "channel_id", chID, "err", chErr)
|
|
h.kickClient(c)
|
|
return
|
|
}
|
|
if ch != nil && ch.Type == "dm" {
|
|
continue
|
|
}
|
|
c.sendMsg(buildChannelDelete(chID))
|
|
h.pubsub.Unsubscribe(c, topic)
|
|
c.mu.Lock()
|
|
if c.channelID == chID {
|
|
c.channelID = 0
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// SendToUser delivers msg directly to the client identified by userID.
|
|
// Returns true if the client was found and the message was queued.
|
|
func (h *Hub) SendToUser(userID int64, msg []byte) bool {
|
|
h.mu.RLock()
|
|
c, ok := h.clients[userID]
|
|
h.mu.RUnlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
return c.trySendMsg(msg)
|
|
}
|
|
|
|
// SendToUserHigh sends a high-priority message to a specific user.
|
|
func (h *Hub) SendToUserHigh(userID int64, msg []byte) bool {
|
|
h.mu.RLock()
|
|
c, ok := h.clients[userID]
|
|
h.mu.RUnlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
c.sendHighMsg(msg)
|
|
return true
|
|
}
|
|
|
|
// BroadcastToAllLow enqueues a low-priority global broadcast.
|
|
// Low-priority messages are silently dropped if a client's buffer is full.
|
|
func (h *Hub) BroadcastToAllLow(msg []byte) {
|
|
// Low-priority global broadcasts bypass the sequenced broadcast channel
|
|
// and go directly through pub/sub — they don't need replay or seq numbering.
|
|
h.pubsub.PublishGlobalLow(msg)
|
|
}
|
|
|
|
// sendSequencedToUsersHigh stamps msg with a monotonic seq, stores it in the
|
|
// replay buffer under channelID, and fans the wrapped payload out to the
|
|
// provided users with high-priority delivery.
|
|
func (h *Hub) sendSequencedToUsersHigh(channelID int64, userIDs []int64, msg []byte) {
|
|
h.seqMu.Lock()
|
|
defer h.seqMu.Unlock()
|
|
|
|
seq := h.nextSeq()
|
|
wrapped := wrapWithSeq(msg, seq)
|
|
h.replayBuf.Push(seq, channelID, wrapped)
|
|
h.persistEvent(seq, channelID, wrapped)
|
|
|
|
for _, userID := range userIDs {
|
|
h.SendToUserHigh(userID, wrapped)
|
|
}
|
|
}
|
|
|
|
// ClientCount returns the number of currently registered clients (test helper).
|
|
func (h *Hub) ClientCount() int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.clients)
|
|
}
|
|
|
|
// BroadcastDropCount returns the cumulative number of messages dropped due to a
|
|
// full broadcast channel. Safe to call from any goroutine.
|
|
func (h *Hub) BroadcastDropCount() uint64 {
|
|
return h.broadcastDrops.Load()
|
|
}
|
|
|
|
// VoiceSessionCount returns the number of clients currently in a voice channel.
|
|
func (h *Hub) VoiceSessionCount() int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
count := 0
|
|
for _, c := range h.clients {
|
|
if c.getVoiceChID() != 0 {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// kickClient forcibly removes a client from the hub and closes its send channel,
|
|
// which causes writePump to exit and the WebSocket connection to close.
|
|
// It is safe to call from any goroutine.
|
|
func (h *Hub) kickClient(c *Client) {
|
|
h.mu.Lock()
|
|
if current, ok := h.clients[c.userID]; ok && current == c {
|
|
delete(h.clients, c.userID)
|
|
}
|
|
h.mu.Unlock()
|
|
h.pubsub.UnsubscribeAll(c)
|
|
c.closeSend()
|
|
}
|
|
|
|
// nextSeq returns the next monotonic sequence number for broadcast messages.
|
|
func (h *Hub) nextSeq() uint64 {
|
|
return atomic.AddUint64(&h.seq, 1)
|
|
}
|
|
|
|
// ReplayBuffer returns the hub's event ring buffer for reconnection replay.
|
|
func (h *Hub) ReplayBuffer() *EventRingBuffer {
|
|
return h.replayBuf
|
|
}
|
|
|
|
// SeedSeq sets the hub's monotonic sequence counter to seed (atomic). Used
|
|
// at startup to align in-memory seqs with the persisted MAX(events.seq) so
|
|
// wrapped-payload seqs stay monotonic across restarts. Calling SeedSeq with
|
|
// a value less than the current seq is a no-op (we never go backwards).
|
|
func (h *Hub) SeedSeq(seed uint64) {
|
|
for {
|
|
cur := atomic.LoadUint64(&h.seq)
|
|
if seed <= cur {
|
|
return
|
|
}
|
|
if atomic.CompareAndSwapUint64(&h.seq, cur, seed) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetEventPersister attaches a persister so subsequent broadcasts are also
|
|
// written to the persistent EventStore. Pass nil to disable. Safe to call
|
|
// at any time, including after Run has started.
|
|
func (h *Hub) SetEventPersister(p *EventPersister) {
|
|
h.eventPersister.Store(p)
|
|
}
|
|
|
|
// SetEventStore attaches a read-side EventStore used by the cold-tier
|
|
// reconnect replay path. Typically the same store backing SetEventPersister.
|
|
// Pass nil to disable. Safe to call at any time, including after Run has
|
|
// started.
|
|
func (h *Hub) SetEventStore(s EventStore) {
|
|
if s == nil {
|
|
h.eventStore.Store(nil)
|
|
return
|
|
}
|
|
h.eventStore.Store(&s)
|
|
}
|
|
|
|
// SetPluginRegistry wires the plugin.Registry so the hub can dispatch
|
|
// slash commands (chat_command messages) to plugin-owned handlers.
|
|
// Pass nil to disable plugin command dispatch. Must be called before Run;
|
|
// late calls are ignored with an error log.
|
|
func (h *Hub) SetPluginRegistry(r *plugin.Registry) {
|
|
if h.rejectIfRunning("SetPluginRegistry") {
|
|
return
|
|
}
|
|
h.pluginRegistry = r
|
|
}
|
|
|
|
// SetPluginEventSink wires the plugin.EventSink so the hub fans out each
|
|
// sequenced broadcast to subscribed plugins. Pass nil to disable. Safe to
|
|
// call at any time, including after Run has started.
|
|
func (h *Hub) SetPluginEventSink(s *plugin.EventSink) {
|
|
h.pluginSink.Store(s)
|
|
}
|
|
|
|
// rejectIfRunning reports whether Run has already started, logging an error
|
|
// when it has. Plain-field setters must be wired before Run: the dispatch
|
|
// loop and connection goroutines read those fields without synchronization,
|
|
// so a late set would be a data race. Late calls are dropped.
|
|
func (h *Hub) rejectIfRunning(setter string) bool {
|
|
if h.running.Load() {
|
|
slog.Error("ws: setter called after Hub.Run started; ignoring (must be wired before Run)",
|
|
"setter", setter)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ReconnectTierStats returns the per-tier resume hit counters in the order
|
|
// (buffer, db, full). Phase B Step 7 metrics surface; OpenTelemetry meters
|
|
// (Step 8) read from the same atomics.
|
|
func (h *Hub) ReconnectTierStats() (buffer, db, full uint64) {
|
|
return h.reconnectTierBuf.Load(), h.reconnectTierDB.Load(), h.reconnectTierFull.Load()
|
|
}
|
|
|
|
// persistEvent enqueues a broadcast event for cold-storage persistence. Safe
|
|
// to call with a nil persister; never blocks the broadcast hot path. seq is
|
|
// the same hub-assigned monotonic counter embedded in payload, so the row
|
|
// written to the EventStore has a row-seq that matches the wrapped-payload
|
|
// seq the client tracks.
|
|
func (h *Hub) persistEvent(seq uint64, channelID int64, payload []byte) {
|
|
p := h.eventPersister.Load()
|
|
if p == nil {
|
|
return
|
|
}
|
|
eventType := extractEventType(payload)
|
|
if eventType == "" {
|
|
eventType = "broadcast"
|
|
if channelID != 0 {
|
|
eventType = "channel_broadcast"
|
|
}
|
|
}
|
|
p.Enqueue(int64(seq), eventType, channelID, payload) //nolint:gosec // seq is a monotonically increasing counter, never reaches MaxInt64
|
|
}
|
|
|
|
// extractEventType scans a wrapped JSON envelope for the value of the "type"
|
|
// field and returns it. Returns "" on any parse failure so the caller can
|
|
// substitute a generic label. The scan is intentionally not a full JSON
|
|
// decode — it only looks for the literal `"type":"<value>"` token, which
|
|
// matches every wire-format envelope produced by this server. This avoids the
|
|
// allocation cost of `encoding/json` on the broadcast hot path.
|
|
func extractEventType(payload []byte) string {
|
|
const needle = `"type":"`
|
|
idx := bytes.Index(payload, []byte(needle))
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
start := idx + len(needle)
|
|
end := bytes.IndexByte(payload[start:], '"')
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
t := payload[start : start+end]
|
|
// Reject any value with control chars or escapes — we want a clean
|
|
// label, not arbitrary user-controlled metadata. Length-cap defensively.
|
|
if len(t) == 0 || len(t) > 64 {
|
|
return ""
|
|
}
|
|
for _, b := range t {
|
|
if b < 0x20 || b == '\\' {
|
|
return ""
|
|
}
|
|
}
|
|
return string(t)
|
|
}
|
|
|
|
// wrapWithSeq injects a "seq" field into a JSON message without re-serializing.
|
|
func wrapWithSeq(msg []byte, seq uint64) []byte {
|
|
// Fast path: inject seq after the opening brace.
|
|
// e.g., {"type":"chat_message",...} → {"seq":123,"type":"chat_message",...}
|
|
// Guard: msg must be a non-empty JSON object (starts with '{' and has content).
|
|
if len(msg) < 2 || msg[0] != '{' {
|
|
return msg
|
|
}
|
|
prefix := fmt.Sprintf(`{"seq":%d,`, seq)
|
|
result := make([]byte, 0, len(prefix)+len(msg)-1)
|
|
result = append(result, prefix...)
|
|
result = append(result, msg[1:]...) // skip opening brace
|
|
return result
|
|
}
|
|
|
|
// staleClientTimeout is the maximum duration a client can go without sending
|
|
// any message before being considered stale and disconnected. The client sends
|
|
// a ping every 30s, so 90s (3x) gives plenty of margin.
|
|
const staleClientTimeout = 90 * time.Second
|
|
|
|
// topicRateLimitPerSecond is the default maximum messages per second for any
|
|
// single channel topic. Prevents a busy channel from saturating the broadcast
|
|
// loop and starving other channels.
|
|
const topicRateLimitPerSecond = 100
|
|
|
|
// sweepStaleClients iterates over all connected clients and kicks any that
|
|
// have not sent a message within staleClientTimeout.
|
|
func (h *Hub) sweepStaleClients() {
|
|
now := time.Now()
|
|
h.mu.RLock()
|
|
var stale []*Client
|
|
for _, c := range h.clients {
|
|
if now.Sub(c.getLastActivity()) > staleClientTimeout {
|
|
stale = append(stale, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, c := range stale {
|
|
slog.Warn("hub: closing stale connection (no activity)",
|
|
"user_id", c.userID, "last_activity", c.getLastActivity())
|
|
h.kickClient(c)
|
|
}
|
|
}
|
|
|
|
// sweepRevokedSessions iterates all connected clients and kicks any whose
|
|
// session has been deleted, expired, or whose user has been banned. This
|
|
// provides time-based session enforcement for idle WebSocket connections
|
|
// that never trigger the message-count-based check (BUG-109).
|
|
func (h *Hub) sweepRevokedSessions() {
|
|
if h.db == nil {
|
|
return
|
|
}
|
|
// Hub run-loop sweeper — no request tie.
|
|
ctx := context.Background()
|
|
|
|
h.mu.RLock()
|
|
snapshot := make([]*Client, 0, len(h.clients))
|
|
for _, c := range h.clients {
|
|
if c.tokenHash != "" {
|
|
snapshot = append(snapshot, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, c := range snapshot {
|
|
result, err := h.db.GetSessionWithBanStatus(ctx, c.tokenHash)
|
|
if err != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {
|
|
slog.Info("session sweep: revoked/expired session, disconnecting",
|
|
"user_id", c.userID)
|
|
h.kickClient(c)
|
|
continue
|
|
}
|
|
tempUser := &db.User{Banned: result.Banned, BanExpires: result.BanExpires}
|
|
if auth.IsEffectivelyBanned(tempUser) {
|
|
slog.Info("session sweep: banned user, disconnecting",
|
|
"user_id", c.userID)
|
|
c.sendMsg(buildErrorMsg(ErrCodeBanned, "you are banned"))
|
|
h.kickClient(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sweepStaleVoiceStates queries all voice_states rows and removes any that
|
|
// don't match a connected client's voiceChID. This catches ghost users that
|
|
// slip through the primary cleanup paths (registerNow, readPump defer,
|
|
// LiveKit webhook).
|
|
func (h *Hub) sweepStaleVoiceStates() {
|
|
if h.db == nil {
|
|
return
|
|
}
|
|
// Hub run-loop sweeper — no request tie.
|
|
ctx := context.Background()
|
|
|
|
// Revocation must evict a live session, not merely block the next join.
|
|
// Nothing else in ws re-validates voice permissions for a connection that
|
|
// stays open, so a user stripped of CONNECT_VOICE kept their SFU session
|
|
// until they disconnected. Checked once a minute, and only for the handful
|
|
// of clients actually in voice.
|
|
h.mu.RLock()
|
|
inVoice := make([]*Client, 0, len(h.clients))
|
|
for _, c := range h.clients {
|
|
if c.getVoiceChID() != 0 {
|
|
inVoice = append(inVoice, c)
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
for _, c := range inVoice {
|
|
chID := c.getVoiceChID()
|
|
if chID == 0 || h.hasChannelPerm(ctx, c, chID, permissions.ConnectVoice) {
|
|
continue
|
|
}
|
|
slog.Warn("sweepStaleVoiceStates: evicting participant whose CONNECT_VOICE was revoked",
|
|
"user_id", c.userID, "channel_id", chID)
|
|
c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing CONNECT_VOICE permission"))
|
|
h.handleVoiceLeave(ctx, c)
|
|
}
|
|
|
|
allStates, err := h.db.GetAllVoiceStates(ctx)
|
|
if err != nil {
|
|
slog.Warn("sweepStaleVoiceStates: GetAllVoiceStates failed", "err", err)
|
|
return
|
|
}
|
|
if len(allStates) == 0 {
|
|
return
|
|
}
|
|
|
|
h.mu.RLock()
|
|
var stale []struct {
|
|
userID int64
|
|
channelID int64
|
|
joinedAt string
|
|
}
|
|
for _, vs := range allStates {
|
|
c, ok := h.clients[vs.UserID]
|
|
if !ok || c.getVoiceChID() != vs.ChannelID {
|
|
stale = append(stale, struct {
|
|
userID int64
|
|
channelID int64
|
|
joinedAt string
|
|
}{vs.UserID, vs.ChannelID, vs.JoinedAt})
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
for _, s := range stale {
|
|
// Channel-conditional delete: only removes the row if it still points
|
|
// at the channel we snapshotted. If the user rejoined or moved between
|
|
// the snapshot and now, the delete is a no-op and we skip the broadcast.
|
|
deleted, err := h.db.LeaveVoiceChannelIfMatch(ctx, s.userID, s.channelID, s.joinedAt)
|
|
if err != nil {
|
|
slog.Error("sweepStaleVoiceStates: LeaveVoiceChannelIfMatch failed",
|
|
"err", err, "user_id", s.userID, "channel_id", s.channelID)
|
|
continue
|
|
}
|
|
if !deleted {
|
|
continue
|
|
}
|
|
slog.Warn("sweepStaleVoiceStates: removed ghost voice state",
|
|
"user_id", s.userID, "channel_id", s.channelID)
|
|
h.broadcastVoiceEvent(ctx, s.channelID, buildVoiceLeave(s.channelID, s.userID))
|
|
if h.livekit != nil {
|
|
_ = h.livekit.RemoveParticipant(ctx, s.channelID, s.userID, s.joinedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deliverBroadcast stamps bm.msg with a monotonic sequence number, stores it
|
|
// in the replay buffer, and sends it to the appropriate clients via pub/sub.
|
|
func (h *Hub) deliverBroadcast(bm broadcastMsg) {
|
|
h.seqMu.Lock()
|
|
defer h.seqMu.Unlock()
|
|
|
|
seq := h.nextSeq()
|
|
msg := wrapWithSeq(bm.msg, seq)
|
|
|
|
// Store in replay buffer for reconnection recovery.
|
|
h.replayBuf.Push(seq, bm.channelID, msg)
|
|
h.persistEvent(seq, bm.channelID, msg)
|
|
|
|
// Fan out to plugins subscribed to this event type (Phase C Step 9).
|
|
// Dispatch is a no-op in the default build; the wazero build calls into
|
|
// the WASM module. Dispatch is called outside seqMu after we release it
|
|
// conceptually — but since seqMu is still held here, the call MUST NOT
|
|
// re-enter the hub. The default build is safe; the wazero build should
|
|
// dispatch asynchronously once the runtime is real.
|
|
if sink := h.pluginSink.Load(); sink != nil {
|
|
eventType := extractEventType(msg)
|
|
if eventType == "" {
|
|
eventType = "broadcast"
|
|
}
|
|
sink.Dispatch(context.Background(), eventType, msg)
|
|
}
|
|
|
|
switch {
|
|
case bm.recipients != nil:
|
|
// Visibility-filtered fan-out: the audience was resolved by the caller.
|
|
for _, userID := range bm.recipients {
|
|
h.SendToUser(userID, msg)
|
|
}
|
|
case bm.channelID == 0:
|
|
// Global broadcast — deliver to every connected client.
|
|
h.pubsub.PublishGlobal(msg)
|
|
default:
|
|
// Channel-scoped broadcast — deliver to subscribers of the channel topic.
|
|
topic := ChannelTopic(bm.channelID)
|
|
if !h.topicLimiter.Allow(topic) {
|
|
slog.Warn("hub: topic rate limit exceeded, dropping message",
|
|
"channel_id", bm.channelID, "seq", seq)
|
|
return
|
|
}
|
|
delivered := h.pubsub.Publish(topic, msg, 0)
|
|
slog.Debug("hub: channel broadcast",
|
|
"channel_id", bm.channelID, "delivered", delivered, "seq", seq)
|
|
}
|
|
}
|