Files
bookhoard/internal/sync/websocket.go
T
John O'Keefe a431dfa10b fix(sync): websocket events are per-user — chokepoint the fan-out
Owner-flagged after the dev-docs audit: BroadcastProgressUpdate,
BroadcastAnnotationUpdate, and BroadcastConflictNotification fanned out
through the untargeted Broadcast, delivering one user's book progress,
conflict notices, and — worst — annotation payloads (which can carry
note/highlight text) to EVERY connected client on the instance. A
family-lens violation of exactly the kind the RLS move eliminated for
database reads: user B's client received events for user A's books,
including books in libraries hidden from B.

Fix is the RLS philosophy applied to the fan-out layer: enforcement at
one choke point. The untargeted Broadcast method and its channel/loop
machinery are deleted — BroadcastToUser is now the only door, and the
compiler forces every future emitter to name its audience instead of
relying on review. The three helpers take the acting user's ID; the
four sync call sites (progress.go x2, annotations.go x2) already had
the acting user in scope. Scan events were already per-triggering-
identity and collections already per-user — unchanged. Logging is
untouched: the only log line in the diff is the deleted method's own,
so docker logs remains the complete administrator audit trail.

TestBroadcastIsPerUser pins the invariant (alice's two devices receive
her three events; bob's client receives nothing — any message there is
a cross-user leak). Both websocket docs updated in the same commit:
the wire doc's fan-out section now states per-user delivery and the
chokepoint, and the gated endpoint doc's intro matches. Full make test
green.
2026-11-02 18:06:45 -05:00

233 lines
6.8 KiB
Go

package sync
import (
"context"
"log"
"sync"
"time"
"github.com/google/uuid"
)
// Message types for WebSocket communication
const (
MessageTypeProgressUpdate = "progress_update"
MessageTypeAnnotationUpdate = "annotation_update"
MessageTypeConflict = "conflict"
MessageTypeSyncComplete = "sync_complete"
MessageTypeHeartbeat = "heartbeat"
MessageTypeInitial = "initial_state"
MessageTypeScanProgress = "scan_progress"
MessageTypeScanComplete = "scan_complete"
MessageTypeScanError = "scan_error"
MessageTypeScanCancelled = "scan_cancelled"
)
// BroadcastMessage represents a message to broadcast to connected clients
type BroadcastMessage struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Data interface{} `json:"data"`
Source *SourceDevice `json:"source_device,omitempty"`
}
// SourceDevice represents the device that sent the update
type SourceDevice struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
// DeviceConnection represents an active WebSocket connection
type DeviceConnection struct {
ID string
UserID string
DeviceType string
DeviceName string
Connected time.Time
LastPing time.Time
Send chan BroadcastMessage
Disconnected chan struct{}
}
// ConnectionManager manages WebSocket connections
//
// CHOKEPOINT (2026-11-02): there is deliberately NO untargeted broadcast
// method. Every send must name its audience — the user whose data the
// event carries. The pre-fix Broadcast fanned progress, annotation, and
// conflict events out to every connected client on the instance,
// crossing user boundaries (a family-lens violation: hidden-library
// book progress and annotation payloads reached unrelated clients).
// The compiler now enforces what review used to.
type ConnectionManager struct {
connections map[string]*DeviceConnection
mu sync.RWMutex
}
// NewConnectionManager creates a new connection manager
func NewConnectionManager() *ConnectionManager {
return &ConnectionManager{
connections: make(map[string]*DeviceConnection),
}
}
// BroadcastToUser sends a message to all connections for a specific user
func (m *ConnectionManager) BroadcastToUser(userID string, msg BroadcastMessage) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, conn := range m.connections {
if conn.UserID == userID {
select {
case conn.Send <- msg:
default:
// Channel full, skip this connection
log.Printf("WebSocket: Channel full for %s, skipping broadcast", conn.DeviceName)
}
}
}
}
// BroadcastProgressUpdate broadcasts a progress update to the acting
// user's connections. Progress is per-user data — nobody else's client
// receives it.
func (m *ConnectionManager) BroadcastProgressUpdate(userID string, bookID uuid.UUID, percentage float64, source SourceDevice) {
msg := BroadcastMessage{
Type: MessageTypeProgressUpdate,
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"book_id": bookID.String(),
"percentage": percentage,
},
Source: &source,
}
m.BroadcastToUser(userID, msg)
}
// BroadcastAnnotationUpdate broadcasts an annotation update to the acting
// user's connections. Annotation payloads can carry note and highlight
// content — strictly per-user.
func (m *ConnectionManager) BroadcastAnnotationUpdate(userID string, bookID uuid.UUID, annotationType string, data interface{}, source SourceDevice) {
msg := BroadcastMessage{
Type: MessageTypeAnnotationUpdate,
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"book_id": bookID.String(),
"annotation_type": annotationType,
"data": data,
},
Source: &source,
}
m.BroadcastToUser(userID, msg)
}
// BroadcastConflictNotification broadcasts a conflict notification to the
// acting user's connections.
func (m *ConnectionManager) BroadcastConflictNotification(userID string, bookID [16]byte, notificationType string, conflictID string) {
msg := BroadcastMessage{
Type: MessageTypeConflict,
Timestamp: time.Now().Format(time.RFC3339),
Data: map[string]interface{}{
"book_id": uuid.UUID(bookID).String(),
"notification_type": notificationType,
"conflict_id": conflictID,
},
}
m.BroadcastToUser(userID, msg)
}
// AddConnection adds a new WebSocket connection
func (m *ConnectionManager) AddConnection(conn *DeviceConnection) {
m.mu.Lock()
defer m.mu.Unlock()
m.connections[conn.ID] = conn
log.Printf("WebSocket: Connection added for device %s (user: %s, type: %s)", conn.DeviceName, conn.UserID, conn.DeviceType)
}
// RemoveConnection removes a WebSocket connection
func (m *ConnectionManager) RemoveConnection(connID string) {
m.mu.Lock()
defer m.mu.Unlock()
if conn, exists := m.connections[connID]; exists {
close(conn.Disconnected)
delete(m.connections, connID)
log.Printf("WebSocket: Connection removed for device %s", conn.DeviceName)
}
}
// GetConnection retrieves a connection by ID
func (m *ConnectionManager) GetConnection(connID string) (*DeviceConnection, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
conn, exists := m.connections[connID]
return conn, exists
}
// GetUserConnections returns all connections for a specific user
func (m *ConnectionManager) GetUserConnections(userID string) []*DeviceConnection {
m.mu.RLock()
defer m.mu.RUnlock()
var conns []*DeviceConnection
for _, conn := range m.connections {
if conn.UserID == userID {
conns = append(conns, conn)
}
}
return conns
}
// GetConnectionCount returns the number of active connections
func (m *ConnectionManager) GetConnectionCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.connections)
}
// CleanupStaleConnections removes connections that haven't sent a ping in 2 minutes
func (m *ConnectionManager) CleanupStaleConnections() {
m.mu.Lock()
defer m.mu.Unlock()
staleThreshold := time.Now().Add(-2 * time.Minute)
for id, conn := range m.connections {
if conn.LastPing.Before(staleThreshold) {
log.Printf("WebSocket: Cleaning up stale connection for %s", conn.DeviceName)
close(conn.Disconnected)
delete(m.connections, id)
}
}
}
// GetConnectionStats returns statistics about connections
func (m *ConnectionManager) GetConnectionStats() map[string]int {
m.mu.RLock()
defer m.mu.RUnlock()
stats := make(map[string]int)
for _, conn := range m.connections {
stats[conn.DeviceType]++
}
return stats
}
// StartCleanupTask starts a background task to cleanup stale connections
func (m *ConnectionManager) StartCleanupTask() context.CancelFunc {
ticker := time.NewTicker(1 * time.Minute)
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
m.CleanupStaleConnections()
}
}
}()
return cancel
}