mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs: add infrastructure roadmap plan Records the verified recommendations from an infrastructure review in three tracks: raising the single-instance ceiling, cheap seams for a possible multi-instance future, and ops hygiene. Includes explicit anti-recommendations and sequencing. Security-sensitive detail is intentionally excluded per docs/security.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): real health checks and saturation metrics /api/v1/metrics now exposes signals that were already computed in memory but never surfaced: reconnect replay tier hits, event-persister counters, SQLite writer-pool wait stats, aggregate per-client backpressure counters (including previously invisible low-priority drops), and permission-cache hit/miss. /health now returns a real verdict: hub dispatch-loop liveness, a bounded database ping, and a free-disk check, returning 503 with a subsystem reason when degraded. Checks are cached so the unauthenticated endpoint cannot amplify load. The hub's panic breaker now exits the process so a supervisor can restart it, instead of leaving broadcast delivery silently dead while clients still appear online. OTel instruments that were declared but never recorded are now wired (ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total, ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds). Also corrects the docs/api.md description of broadcast_drops, which counts hub-queue overflow, not client send-queue overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): implement scheduled backups, retention, and backup verification The backup_schedule and backup_retention settings have existed in the admin panel and API since the initial schema but were never read by any code. The 15-minute maintenance loop now enforces them: a scheduled backup is taken when the newest backup on disk is older than the schedule interval (manual backups reset the clock), and retention prunes backups older than the configured days while always keeping the newest one. Backups are now verified with PRAGMA integrity_check immediately after VACUUM INTO (a failed backup is removed rather than listed as restorable) and again before a restore may overwrite the live database. A failed VACUUM INTO also cleans up its partial output file — but never a pre-existing one. The backup directory is configurable via a new backup.dir key (default data/backups) so operators can point backups at another disk or an off-host mount, mirroring the SetDatabasePath plumb. Restore-handler tests now use real SQLite fixtures (the integrity gate correctly refuses text files) with the mid-copy failure injected through a test-only copy hook. Also adds audited gosec suppressions to the Windows disk-free syscall added in the previous commit, which the Windows lint leg flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): capacity and failure-mode guardrails - server.max_ws_connections: optional cap on concurrent WebSocket clients, checked before the upgrade with a 503 + Retry-After; rejections are counted and exposed as ws_conn_rejects in /api/v1/metrics. - Single-process database lock: an OS-level advisory lock (flock / exclusive handle) beside the SQLite file makes a second server process fail fast with a clear message instead of silently fighting the first over process-local state. A bounded retry covers the self-update/restore restart handoff, and the lock mechanism failing (e.g. network filesystems) only warns. - Disk-space awareness: boot-time warnings for the data and backup volumes, plus a disk_free_mb metrics field, via a small cross-platform diskutil package (already used by /health). - Upload storage failures: storage.Save now marks server-side filesystem failures with a sentinel (storage.ErrIO); handlers return 507 for those instead of blaming the client with a 400, and the emoji route stops echoing raw storage errors (which embed absolute paths) into responses. - Unknown config keys now warn at startup — a typo like admin_alowed_cidrs previously kept the default silently while the operator believed the setting changed. Never fatal: newer servers tolerate older configs. - Admin settings honesty: the three stored-but-inert settings (server_icon, max_upload_bytes, voice_quality) are shown read-only with a note pointing at the real config.yaml keys, instead of pretending to apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(db): write-path efficiency and capacity knobs - channel_focus/mark_read now skip the read-state UPSERT when the stored row already matches (same last_message_id, no mentions) — refocus events fire at up to 10/s/user and every no-op write still occupied the single SQLite writer connection. The extra existence check runs on the reader pool, which doesn't serialize. Same shape as the session-touch throttle. - DeleteExpiredSessions is now sargable: migration 031 normalizes legacy expiry formats to the RFC3339-Z layout the server writes and indexes expires_at, replacing the strftime full-table scan that ran on the writer every 15 minutes. - Boot-time ANALYZE runs only when a migration actually applied; unchanged schemas get the cheap PRAGMA optimize instead (which also covers crash-restarts that never reached the shutdown optimize). - The read/write SQL router gets a table-driven test with explicit expected values (INSERT ... RETURNING must hit the writer despite being :one). - New knobs, all defaulting to current behavior: database.max_readers, security.auth_rate_limit_multiplier (for shared-NAT communities), event_persistence.replay_ring_size and replay_cold_limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): shutdown lifecycle ordering - The event pruner and maintenance loop are now joined (bounded) before the database closes: bgCtx cancellation used to run AFTER database.Close via LIFO defers, contradicting its own comment, and neither goroutine was ever waited on — a mid-tick scheduled backup or prune could still hold the writer while the pool tore down. StartEventPruner returns a done channel with the same join contract EventPersister.Stop already had. - srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers' broadcasts still reach a live hub and the event persister instead of vanishing from the replay/event store across a restart. Shutdown does not wait on hijacked WebSocket connections, so the swap adds no delay. - GracefulStopContext threads the 30s shutdown budget into the hub: the 5s client-notice window (matching the countdown clients are shown) ends early when the budget expires, and is skipped entirely when nobody is connected — early-return startup paths and idle servers no longer sleep 5s for an audience of zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish - deploy/owncord.service: hardened systemd unit template with the two verified caveats encoded (install dir stays writable for self-update under ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a 'Linux (systemd)' deployment docs section — the Linux service story was previously 'Docker or nothing'. - New 'Reverse Proxy Topology' docs section with a working nginx snippet and the correct signaling-vs-media distinction: /livekit/* is already proxied by the server, only WebRTC media ports must be directly reachable. - docker-compose: log rotation, commented resource limits, and a healthcheck backed by a new 'chatserver healthcheck' subcommand (the distroless image has no shell) that probes /health without config side effects. - release.yml: a concurrency group (queue, never cancel), and boot-smoke gates — the freshly built server binaries and the Docker image are cold booted and probed healthy BEFORE anything is signed or pushed. The release feed drives signed self-updates, so a binary that compiles but dies on boot previously would have shipped itself to every auto-updating instance. - ci.yml: client-check/client-tests move to ubuntu with the reasoning recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a written graduation criterion instead of an open-ended non-blocking status. - docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs; architecture overview records presence/voice state as the fifth single-instance blocker and the macOS client scope decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams - Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped frames, correct message types (typing_start, presence_update), the correct /api/v1/ws path, and thresholds that fail a run where nobody authenticated or went ready — the script had drifted to pre-envelope framing and reported 100% green while every auth failed on the first frame. A new workflow_dispatch-only load-baseline workflow boots a real server, seeds users through the setup/invite APIs, runs the script, and uploads the k6 summary plus a metrics snapshot for before/after comparison. - Role-scoped channel-override changes now evict only the affected role's members from the permission cache (fail-safe: unreadable member list still flushes everything). InvalidateAll here repopulated every connected user — two reads each — synchronously inside the admin request via RefreshChannelVisibility, a stampede that scaled with total population rather than the role's size. Same pattern the per-user override endpoints already used. - Connect/disconnect presence broadcasts now pass through a 300ms latest-wins coalescer (QueuePresence): each un-coalesced presence change is a sequenced global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm fired O(users) of them from the connect critical path. A flap inside the window collapses to its final state; the wire format, seq ordering, and replay behaviour are unchanged, and the delivery path (BroadcastPresence) is untouched. - Storage seam: api handlers now consume a FileStore interface (consumer-side, same pattern as service.Store) with Open returning a seekable storage.File — writing down the contract (range-request seeks included) an alternative backend would have to meet, without building one. - The metrics surfaces and the LiveKit webhook/health endpoints get their own allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an externally-hosted LiveKit no longer requires widening the admin panel's perimeter. Startup now also warns when admin_allowed_cidrs is customized while trusted_proxies is empty — behind a proxy or container network the check would otherwise compare the proxy's private address, not the client's. - The container healthcheck probe now PINS the server's own certificate from disk (VerifyConnection, exact-match) instead of skipping TLS verification, addressing the CodeQL finding on the previous commit; WebPKI verification is used when no local cert exists (ACME). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): address self-review findings on the hardening branch Seven fixes from a high-effort review of the full branch diff: - healthcheck CLI now works under tls.mode acme: it overrides ServerName with the configured domain for WebPKI verification instead of pinning a cert that doesn't exist (or is stale) in that mode. Previously an ACME deployment's container healthcheck failed forever. - /health pings the READER pool (new db.PingRead): the writer ping queued behind a scheduled backup's VACUUM INTO and reported the server degraded for the whole backup — which an autoheal watchdog would turn into a nightly mid-backup restart. - /health runs its cached checks under context.WithoutCancel so a probe that disconnects mid-request cannot poison the shared cache with a false degraded verdict for the next 5 seconds. - The token CLI uses a new db.OpenShared that skips the single-process lock: minting a token against a running server is safe under WAL and was a documented workflow the lock had broken. - The per-user TOTP failure cap is no longer scaled by security.auth_rate_limit_multiplier — that knob exists for per-IP limits; scaling the only cross-IP brute-force defence multiplied an attacker's distributed guess budget. Mirrors the unscaled per-user login threshold. - A direct presence_update now drops the user's queued entry in the connect/disconnect coalescer, so a stale connect-time presence can no longer flush 300ms later over the user's fresher chosen status. - The scheduled-backup filename collision loop breaks on any stat error and bounds its suffix probing, instead of spinning the maintenance goroutine forever on a persistent EACCES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * test(admin): real SQLite fixture for the merged Close-failure restore test TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375) with a plain-text backup fixture; this branch's restore handler verifies backups with integrity_check before touching the live database, so the text fixture was (correctly) refused with 400 before the Close-failure branch under test was reached. Use a real backup via BackupToSafe, matching the other restore tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj --------- Co-authored-by: Claude <noreply@anthropic.com>
732 lines
33 KiB
Go
732 lines
33 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/owncord/server/config"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/permissions"
|
|
"github.com/owncord/server/telemetry"
|
|
)
|
|
|
|
const (
|
|
authDeadline = 10 * time.Second
|
|
writeTimeout = 10 * time.Second
|
|
settingsCacheTTL = 30 * time.Second
|
|
|
|
// wsReadLimitBytes is the maximum size of a single inbound WebSocket
|
|
// message. Must match the client-side upload cap.
|
|
wsReadLimitBytes = config.MaxMessageBytes
|
|
|
|
// maxColdReplay caps how many persisted events a single cold-tier reconnect
|
|
// replay may return. A gap that reaches the cap cannot be replayed correctly
|
|
// and falls back to a full ready — see handleReconnect.
|
|
maxColdReplay = 5000
|
|
)
|
|
|
|
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
|
|
// then drives the client's read/write loops.
|
|
// Do not wrap with AuthMiddleware — WS does its own auth.
|
|
//
|
|
// allowedOrigins controls which HTTP origins may open a WebSocket connection.
|
|
// Pass nil or []string{"*"} to allow all origins (insecure, for development).
|
|
// Pass explicit origins such as []string{"https://example.com"} to restrict access.
|
|
//
|
|
// maxConns, when > 0, refuses new connections with 503 once that many clients
|
|
// are registered — a static capacity guardrail (server.max_ws_connections).
|
|
// The check runs before the upgrade so a refused connection costs one HTTP
|
|
// request, not a socket plus goroutines. Registered count trails pre-auth
|
|
// connections by design; the 10s auth deadline bounds that gap.
|
|
func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string, maxConns int) http.HandlerFunc {
|
|
acceptOpts := OriginAcceptOptions(allowedOrigins)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if maxConns > 0 && hub.ClientCount() >= maxConns {
|
|
hub.connRejects.Add(1)
|
|
w.Header().Set("Retry-After", "30")
|
|
http.Error(w, "server at connection capacity", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
conn, err := websocket.Accept(w, r, acceptOpts)
|
|
if err != nil {
|
|
slog.Warn("ws upgrade failed", "err", err)
|
|
return
|
|
}
|
|
conn.SetReadLimit(wsReadLimitBytes) // match client-side upload cap
|
|
|
|
c, lastSeq, err := hub.upgradeAndAuth(conn, database, r)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
startPumps := func() {
|
|
writeCtx, writeCancel := context.WithCancel(ctx)
|
|
go writePump(writeCtx, conn, c)
|
|
readPump(ctx, conn, hub, c)
|
|
c.closeSend()
|
|
writeCancel()
|
|
}
|
|
|
|
// Reconnection with state recovery: if the client sent a last_seq,
|
|
// try to replay missed events from the ring buffer instead of
|
|
// sending a full ready payload.
|
|
if lastSeq > 0 {
|
|
if handled, shouldStartPumps := hub.handleReconnect(ctx, conn, c, database, lastSeq); handled {
|
|
if shouldStartPumps {
|
|
startPumps()
|
|
}
|
|
return
|
|
}
|
|
// Replay failed (seq too old) — fall through to full ready payload.
|
|
slog.Info("ws replay failed (seq too old), sending full ready", "user_id", c.userID, "last_seq", lastSeq)
|
|
}
|
|
|
|
if err := hub.handleFreshConnect(ctx, conn, c, database); err != nil {
|
|
return
|
|
}
|
|
|
|
// writePump runs in background; readPump blocks.
|
|
// When readPump returns (disconnect), close the send channel first
|
|
// so writePump drains any remaining messages, then cancel its context.
|
|
startPumps()
|
|
}
|
|
}
|
|
|
|
func (h *Hub) upgradeAndAuth(
|
|
conn *websocket.Conn, database *db.DB, r *http.Request,
|
|
) (*Client, uint64, error) {
|
|
user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database)
|
|
if err != nil {
|
|
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
|
|
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
|
|
return nil, 0, err
|
|
}
|
|
lastSeq := hint.LastSeq
|
|
|
|
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
|
|
c.remoteAddr = r.RemoteAddr
|
|
// Untrusted until handleReconnect checks it against the allowed set.
|
|
c.authChannelID = hint.ChannelID
|
|
|
|
// Look up role name for protocol-compliant payloads and cache on client.
|
|
roleName := "member"
|
|
if role, roleErr := database.GetRoleByID(r.Context(), user.RoleID); roleErr == nil && role != nil {
|
|
roleName = strings.ToLower(role.Name)
|
|
}
|
|
c.roleName = roleName
|
|
|
|
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
|
|
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID,
|
|
"WebSocket connected from "+r.RemoteAddr)
|
|
|
|
return c, lastSeq, nil
|
|
}
|
|
|
|
// handleReconnectPreRegisterRaceHook, when non-nil, runs once inside
|
|
// handleReconnect's h.seqMu critical section immediately before the
|
|
// mustFullResync re-check that guards registerNow. Test-only (nil in
|
|
// production); a real visibility change lands too fast relative to the DB
|
|
// round trips above to reliably land a concurrent goroutine in this window,
|
|
// so tests use this hook to pin it deterministically instead — mirrors the
|
|
// refreshChannelVisibilityRaceHook / voiceJoinPostTokenRaceHook pattern used
|
|
// for the analogous races elsewhere in this package (OC-0206).
|
|
var handleReconnectPreRegisterRaceHook func()
|
|
|
|
// handleReconnect attempts to resume a client via replay. Its two return
|
|
// values are independent signals for ServeWS:
|
|
// - handled reports whether this function owns the outcome of the
|
|
// connection attempt. false means "replay isn't possible, fall through
|
|
// to handleFreshConnect for a full ready."
|
|
// - startPumps reports whether ServeWS should start readPump/writePump.
|
|
// It is only meaningful when handled is true, and is false on the
|
|
// handshake-write-failure paths below: those paths already ran the full
|
|
// unregisterFailedHandshake teardown and closed conn themselves, so no
|
|
// pump may start — readPump's defer would find the client already gone
|
|
// (unregisterNow reporting replaced=false) and run that same teardown a
|
|
// second time (OC-0051): a duplicate MarkUserDisconnected, a duplicate
|
|
// offline presence broadcast, and a duplicate hub seq for it.
|
|
func (h *Hub) handleReconnect(
|
|
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
|
|
) (handled, startPumps bool) {
|
|
// Channel-visibility changes are delivered as targeted, unsequenced
|
|
// messages, so replay cannot bring a client that missed one back into a
|
|
// coherent state — force the full-ready path instead.
|
|
if h.mustFullResync(lastSeq) {
|
|
slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq)
|
|
h.reconnectTierFull.Add(1)
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
|
return false, false
|
|
}
|
|
// Compute the set of channel IDs the reconnecting user can access so that
|
|
// channel-scoped replay events are filtered by current permissions (M3).
|
|
allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user)
|
|
if err != nil {
|
|
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
|
|
"user_id", c.userID, "err", err)
|
|
return false, false
|
|
}
|
|
|
|
// Voice membership needs only CONNECT_VOICE, not READ_MESSAGES
|
|
// (voice_join.go), so a live participant resuming can have their own room
|
|
// excluded from allowedChannelIDs entirely — most commonly a DM voice call
|
|
// after the DM was closed (computeAllowedChannels sources DM IDs from
|
|
// dm_open_state). Capture it before registerNow performs the same
|
|
// lookup/transfer, so replay can be supplemented below with the room's own
|
|
// voice_state/voice_leave even though the room is outside the READ-gated
|
|
// allowed set. It is never added to allowedChannelIDs itself — that map
|
|
// also gates the ChannelTopic subscription in registerNow and would leak
|
|
// the channel's chat to a user who cannot read it.
|
|
var liveVoiceChID int64
|
|
if old := h.GetClient(c.userID); old != nil {
|
|
liveVoiceChID = old.getVoiceChID()
|
|
}
|
|
|
|
var (
|
|
events [][]byte
|
|
replaySource = "buffer"
|
|
persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below
|
|
maxPersistedSeq uint64
|
|
)
|
|
if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil {
|
|
events = buf
|
|
} else {
|
|
// Phase B Step 7 — try cold-tier replay from the EventStore before
|
|
// giving up and forcing a full ready re-sync.
|
|
if esp := h.eventStore.Load(); esp != nil {
|
|
es := *esp
|
|
channelIDs := make([]int64, 0, len(allowedChannelIDs))
|
|
for cid := range allowedChannelIDs {
|
|
channelIDs = append(channelIDs, cid)
|
|
}
|
|
coldCap := h.maxColdReplayLimit()
|
|
persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, coldCap) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64
|
|
switch {
|
|
case dbErr != nil:
|
|
slog.Warn("ws handleReconnect: cold-tier replay query failed",
|
|
"user_id", c.userID, "err", dbErr)
|
|
case len(persisted) >= coldCap:
|
|
// The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full
|
|
// result means the gap exceeds the cap and the NEWEST events were
|
|
// dropped. Replaying it would look like a complete resume to the
|
|
// client — it tracks only max(seq) and cannot detect the hole —
|
|
// silently losing state events that REST history never repairs.
|
|
// Leave events nil so the fall-through forces a full ready.
|
|
slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq, "cap", coldCap)
|
|
case len(persisted) > 0:
|
|
// Retention pruning (PruneEventsOlderThan) deletes purely by
|
|
// created_at with no seq-floor coordination, so this
|
|
// channel-filtered result can be a surviving suffix left behind
|
|
// after the events between lastSeq and persisted[0] were
|
|
// pruned. Accepting it as-is would present a hole as a complete
|
|
// resume, since the client tracks only max(seq). Probe the
|
|
// store's oldest surviving seq UNFILTERED before trusting it —
|
|
// a channel-filtered contiguity check on persisted itself can't
|
|
// work, since a sparse per-channel result is legitimately
|
|
// non-contiguous.
|
|
oldest, oldestErr := es.GetEventsSince(ctx, 0, 1)
|
|
switch {
|
|
case oldestErr != nil:
|
|
slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready",
|
|
"user_id", c.userID, "err", oldestErr)
|
|
case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64
|
|
var oldestSeq int64
|
|
if len(oldest) > 0 {
|
|
oldestSeq = oldest[0].Seq
|
|
}
|
|
slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq)
|
|
default:
|
|
persistedTail = make([][]byte, 0, len(persisted))
|
|
for _, p := range persisted {
|
|
persistedTail = append(persistedTail, p.Payload)
|
|
}
|
|
maxPersistedSeq = uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
|
|
|
|
// persisted is channel-filtered, so a hole in a channel
|
|
// outside allowedChannelIDs would slip past a contiguity
|
|
// check on persisted itself — and EventPersister can lose a
|
|
// row outright (a full queue drops silently in Enqueue, a
|
|
// per-row insert failure inside a batch flush is logged but
|
|
// never surfaced here; see event_persister.go). Count the
|
|
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
|
|
// every seq in it to be present. seq is the events table's
|
|
// primary key, so the count can only come up short, never
|
|
// over.
|
|
expectedCount := maxPersistedSeq - lastSeq
|
|
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
|
|
case gapErr != nil:
|
|
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
|
|
"user_id", c.userID, "err", gapErr)
|
|
persistedTail = nil
|
|
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
|
|
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
|
|
"expected", expectedCount, "found", gapCount)
|
|
persistedTail = nil
|
|
}
|
|
|
|
if persistedTail != nil {
|
|
// The EventPersister flushes asynchronously, so cold rows can
|
|
// lag the live seq: events broadcast after the last flush sit
|
|
// only in the ring buffer. Confirm the buffer can cover
|
|
// everything above the newest persisted row — the
|
|
// authoritative re-read happens atomically with registerNow
|
|
// below, but a hole here must still force a full ready
|
|
// rather than a replay with a silent gap at its end.
|
|
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
|
case tail != nil:
|
|
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
|
// Post-restart empty buffer with the hub seq seeded from
|
|
// the store max: nothing was broadcast after the last
|
|
// persisted row, so the cold rows alone are complete.
|
|
default:
|
|
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
|
|
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
|
persistedTail = nil
|
|
}
|
|
}
|
|
if persistedTail != nil {
|
|
events = persistedTail
|
|
replaySource = "db"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if events == nil {
|
|
h.reconnectTierFull.Add(1)
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
// Register BEFORE writing replay data so broadcasts that arrive during
|
|
// the write window are queued in the client's send buffer instead of
|
|
// being lost (BUG-123). writePump hasn't started yet, so queued messages
|
|
// will be drained once the pumps begin.
|
|
//
|
|
// The replay set built above can go stale between being read and c
|
|
// becoming reachable: deliverBroadcast (the hub's own Run goroutine)
|
|
// allocates a seq, pushes it to the ring buffer, and publishes to current
|
|
// subscribers — all under h.seqMu — concurrently with this handshake
|
|
// goroutine. registerNow is what subscribes this connection, so a
|
|
// broadcast landing in the gap between the snapshot above and
|
|
// registration reaches nobody, and the client's max(seq)-only tracking
|
|
// means it can never be requested again once a later frame arrives.
|
|
// Close the window by re-reading the ring-buffer-derived portion of
|
|
// `events` and calling registerNow inside the SAME h.seqMu critical
|
|
// section deliverBroadcast uses, so no seq can be allocated in between.
|
|
// Restore the client's channel subscription BEFORE registration.
|
|
//
|
|
// registerNow copies the channel subscription from the OLD client entry,
|
|
// but on a resume where the server already observed the previous socket
|
|
// close there is no old entry to copy from — so without this the resumed
|
|
// connection holds no ChannelTopic subscription until its post-auth_ok
|
|
// channel_focus round trip completes. Everything broadcast to that channel
|
|
// in the window (auth_ok write + up to maxColdReplay replay frames + pump
|
|
// startup + one RTT) is delivered to nobody on this socket, and the client
|
|
// can never ask for it back because it only ever reports max(seq).
|
|
//
|
|
// Set outside the h.seqMu section below so this does not introduce a
|
|
// seqMu -> c.mu lock-order edge that nothing else in the hub has.
|
|
//
|
|
// c.authChannelID is attacker-controlled, so it is honoured only when the
|
|
// freshly computed read-permission set contains it. Fail closed: an
|
|
// unknown or now-unreadable id leaves channelID at 0, which is exactly the
|
|
// pre-existing behaviour rather than a new denial.
|
|
if c.authChannelID != 0 {
|
|
if allowedChannelIDs[c.authChannelID] {
|
|
c.mu.Lock()
|
|
c.channelID = c.authChannelID
|
|
c.mu.Unlock()
|
|
} else {
|
|
slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame",
|
|
"user_id", c.userID, "channel_id", c.authChannelID)
|
|
}
|
|
}
|
|
|
|
h.seqMu.Lock()
|
|
switch replaySource {
|
|
case "buffer":
|
|
fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
|
|
if fresh == nil {
|
|
// The buffer window closed between the earlier check and this
|
|
// lock (an extreme write burst evicted lastSeq) — there is
|
|
// nothing left to fall back to for this attempt but a full ready.
|
|
h.seqMu.Unlock()
|
|
slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq)
|
|
h.reconnectTierFull.Add(1)
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
|
return false, false
|
|
}
|
|
events = fresh
|
|
case "db":
|
|
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
|
case tail != nil:
|
|
events = append(append([][]byte{}, persistedTail...), tail...)
|
|
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
|
events = persistedTail
|
|
default:
|
|
h.seqMu.Unlock()
|
|
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready",
|
|
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
|
h.reconnectTierFull.Add(1)
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
|
return false, false
|
|
}
|
|
}
|
|
if handleReconnectPreRegisterRaceHook != nil {
|
|
handleReconnectPreRegisterRaceHook()
|
|
}
|
|
// Re-check the watermark one last time, right before registerNow makes
|
|
// this connection reachable. RefreshChannelVisibility and
|
|
// revokeUnreadableChannels both iterate h.clients to fan out a targeted,
|
|
// unsequenced channel_create/channel_delete — a snapshot this
|
|
// still-mid-handshake connection is absent from — and both only bump the
|
|
// watermark afterward. Without this re-check, a visibility change that
|
|
// lands anywhere between the entry check above and here is missed twice:
|
|
// the fan-out can't reach an unregistered client, and the entry check has
|
|
// already passed, so nothing else catches it before this resume commits
|
|
// to permissions computed before the change (OC-0206).
|
|
if h.mustFullResync(lastSeq) {
|
|
h.seqMu.Unlock()
|
|
slog.Warn("ws handleReconnect: visibility changed during handshake, forcing full ready",
|
|
"user_id", c.userID, "last_seq", lastSeq)
|
|
h.reconnectTierFull.Add(1)
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
|
return false, false
|
|
}
|
|
h.registerNow(c, allowedChannelIDs)
|
|
h.seqMu.Unlock()
|
|
|
|
switch replaySource {
|
|
case "buffer":
|
|
h.reconnectTierBuf.Add(1)
|
|
case "db":
|
|
h.reconnectTierDB.Add(1)
|
|
}
|
|
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
|
|
|
|
// Best-effort supplement: the user's own live voice room may sit outside
|
|
// allowedChannelIDs (see the capture of liveVoiceChID above), so its
|
|
// voice_state/voice_leave would otherwise never reach this replay at all.
|
|
// Tries the ring buffer first, then the cold-tier store; a miss on both
|
|
// just leaves this one supplement as a no-op, not a regression versus the
|
|
// pre-fix behaviour.
|
|
if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] {
|
|
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
|
|
}
|
|
|
|
// Replay succeeded — send auth_ok then missed events. The replay tier
|
|
// is included in the payload so the client can attribute reconnect
|
|
// behaviour without separate metric scraping.
|
|
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
|
|
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
|
|
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
|
|
h.unregisterFailedHandshake(ctx, c)
|
|
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
|
// startPumps=false: the teardown above already ran in full. Starting
|
|
// readPump on this closed conn would hit an immediate Read error and
|
|
// its defer would run the identical teardown a second time (OC-0051).
|
|
return true, false
|
|
}
|
|
for _, evt := range events {
|
|
if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {
|
|
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
|
|
h.unregisterFailedHandshake(ctx, c)
|
|
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
|
return true, false
|
|
}
|
|
}
|
|
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
|
|
|
|
// Update presence but skip member_join — user was already known.
|
|
applyConnectStatus(ctx, database, c)
|
|
h.announceConnectPresence(c)
|
|
|
|
return true, true
|
|
}
|
|
|
|
// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or
|
|
// after afterSeq, bypassing the READ-gated channel filter entirely. Voice
|
|
// membership needs only CONNECT_VOICE (voice_join.go), so a resuming
|
|
// participant's own room is not always in their READ-visible set — a stock
|
|
// example is a DM voice call after the DM was closed. Tries the ring buffer
|
|
// first (fresh, so it observes anything pushed concurrently with the caller),
|
|
// then falls back to the cold-tier store; returns nil, not an error, on a
|
|
// miss in both, since this is a best-effort supplement to the main replay.
|
|
func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte {
|
|
if chID == 0 {
|
|
return nil
|
|
}
|
|
only := map[int64]bool{chID: true}
|
|
var raw [][]byte
|
|
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
|
|
raw = buf
|
|
} else if esp := h.eventStore.Load(); esp != nil {
|
|
es := *esp
|
|
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, h.maxColdReplayLimit()) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
raw = make([][]byte, 0, len(persisted))
|
|
for _, p := range persisted {
|
|
raw = append(raw, p.Payload)
|
|
}
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
filtered := make([][]byte, 0, len(raw))
|
|
for _, evt := range raw {
|
|
switch extractEventType(evt) {
|
|
case MsgTypeVoiceState, MsgTypeVoiceLeaveBC:
|
|
filtered = append(filtered, evt)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// unregisterFailedHandshake removes c after a post-registerNow handshake
|
|
// write failure. No readPump ever starts for this connection — the
|
|
// fresh-connect callers return an error that stops ServeWS before it starts
|
|
// the pumps, and handleReconnect's callers report startPumps=false for the
|
|
// same reason (OC-0051) — and the old connection this one replaced already
|
|
// ran its defer (skipping teardown because this client held the slot) — so
|
|
// when no replacement remains, the standard disconnect teardown must run
|
|
// here or the user stays online forever.
|
|
func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) {
|
|
// Snapshot voice state BEFORE unregister, mirroring readPump's defer
|
|
// (serve_pumps.go): once unregisterNow removes c, there is no way to tell
|
|
// whether it still owned a (possibly just-transferred) voice session.
|
|
voiceChID := c.getVoiceChID()
|
|
replaced := h.unregisterNow(c)
|
|
if !replaced {
|
|
cleanupCtx := context.WithoutCancel(ctx)
|
|
// A connection that inherited a transferred voice session (the
|
|
// replay-failure fallback in handleFreshConnect deliberately keeps
|
|
// the voice_states row and registerNow transfers it onto c) must have
|
|
// that session torn down here too, or the row, the LiveKit
|
|
// participant, and a stale E2EE key-holder entry all survive this
|
|
// connection's death until the next sweep (up to 60s).
|
|
if voiceChID != 0 {
|
|
h.handleVoiceLeave(cleanupCtx, c)
|
|
}
|
|
}
|
|
// shouldMarkOffline re-checks h.clients rather than trusting the
|
|
// `replaced` snapshot alone: it was sampled before handleVoiceLeave,
|
|
// which can block for seconds, so a reconnect landing during that window
|
|
// would otherwise be invisible here and mark the live session's user
|
|
// offline (OC-0019, mirrored from readPump's defer in serve_pumps.go).
|
|
if h.shouldMarkOffline(c, replaced) {
|
|
cleanupCtx := context.WithoutCancel(ctx)
|
|
_ = h.db.MarkUserDisconnected(cleanupCtx, c.userID)
|
|
// custom_status is nil, not c.user.CustomStatus: see the identical
|
|
// note in serve_pumps.go's readPump defer — that field is an
|
|
// auth-time snapshot, never updated, so broadcasting it here can
|
|
// resurrect a status the user already changed or cleared.
|
|
h.QueuePresence(c.userID, db.StatusOffline, nil)
|
|
}
|
|
}
|
|
|
|
// applyConnectStatus writes the status this session comes online as and caches
|
|
// it on the client.
|
|
//
|
|
// It is db.ConnectStatus(saved) rather than a flat "online": stamping online on
|
|
// every connect is what made a saved Do Not Disturb — and, before this phase,
|
|
// an "appear offline" — flash back to online on every reconnect, with the
|
|
// client racing to re-assert its choice afterwards. idle/dnd/invisible are
|
|
// deliberate choices and survive; anything else becomes online. The write still
|
|
// happens when the status is unchanged, because UpdateUserStatus also refreshes
|
|
// last_seen.
|
|
//
|
|
// It runs BEFORE the ready payload is built so the member list the client is
|
|
// handed already agrees with the presence broadcast that follows it.
|
|
func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {
|
|
status := db.ConnectStatus(c.user.Status)
|
|
if updateErr := database.UpdateUserStatus(ctx, c.userID, status); updateErr != nil {
|
|
slog.Warn("ws UpdateUserStatus", "err", updateErr)
|
|
}
|
|
c.user.Status = status
|
|
}
|
|
|
|
// announceConnectPresence fans out the status applyConnectStatus settled on,
|
|
// with the invisible mapping applied.
|
|
func (h *Hub) announceConnectPresence(c *Client) {
|
|
h.QueuePresence(c.userID, c.user.Status, c.user.CustomStatus)
|
|
}
|
|
|
|
// computeAllowedChannels returns the set of channel IDs a user may access,
|
|
// including both server channels (filtered by ReadMessages permission) and
|
|
// the user's open DM channels. The server-channel set comes from the single
|
|
// permissions.Checker predicate shared with buildReady and REST
|
|
// ListVisibleChannels, so replay-buffer filtering can never drift from the
|
|
// ready payload's visible channels.
|
|
func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) {
|
|
channels, err := database.ListChannels(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
|
|
}
|
|
|
|
role, err := database.GetRoleByID(ctx, user.RoleID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
|
|
}
|
|
|
|
// Nil role = zero access (fail closed). Admins skip the override fetch.
|
|
allowed := make(map[int64]bool)
|
|
if role != nil {
|
|
var overrides map[int64]db.ChannelOverride
|
|
if !permissions.HasAdmin(role.Permissions) {
|
|
overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err)
|
|
}
|
|
}
|
|
allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
|
|
}
|
|
|
|
// Include the user's open DM channels. Only the ID set matters here, so
|
|
// use the PK-covered dm_open_state lookup instead of the full DM query.
|
|
// Fatal like the three sibling lookups above: a silently DM-stripped
|
|
// replay advances the client's lastSeq past DM events it never received —
|
|
// a permanent hole. The caller's error path falls back to full ready.
|
|
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
|
|
if dmErr != nil {
|
|
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
|
|
}
|
|
for _, id := range dmIDs {
|
|
allowed[id] = true
|
|
}
|
|
|
|
return allowed, nil
|
|
}
|
|
|
|
func (h *Hub) handleFreshConnect(
|
|
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB,
|
|
) error {
|
|
// Clean stale voice state BEFORE building ready and registering.
|
|
// When a user F5-reloads while in voice, the DB row from the previous
|
|
// session must be removed so the ready payload doesn't include it and
|
|
// other clients see a voice_leave broadcast.
|
|
if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil {
|
|
// Replay-failure fallback (lastSeq > 0): registerNow below transfers
|
|
// the still-registered old connection's live voice state into this
|
|
// client. Deleting the DB row here — and the LiveKit participant,
|
|
// whose removal token is the very JoinedAt being transferred — would
|
|
// leave the user "in voice" on the hub only: voice_join bounces off
|
|
// ALREADY_JOINED and sweepStaleVoiceStates never heals
|
|
// memory-without-row. Keep the row so ready stays consistent. If the
|
|
// old client unregisters before registerNow runs, the transfer is
|
|
// skipped and the next sweep reaps the then-truly-stale row.
|
|
if old := h.GetClient(c.userID); c.lastSeq > 0 && old != nil && old.getVoiceChID() == vs.ChannelID {
|
|
slog.Info("ws fresh connect: keeping voice state for replay-failure fallback",
|
|
"user_id", c.userID, "channel_id", vs.ChannelID)
|
|
} else {
|
|
slog.Info("ws fresh connect: cleaning stale voice state",
|
|
"user_id", c.userID, "channel_id", vs.ChannelID)
|
|
if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil {
|
|
slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr)
|
|
}
|
|
h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceLeave(vs.ChannelID, c.userID))
|
|
if h.livekit != nil {
|
|
// BUG-089: Capture stale join token so the goroutine only removes
|
|
// the exact stale participant. The identity includes joinedAt, so
|
|
// even if the user rejoins voice quickly, the new session has a
|
|
// different identity and won't be removed. The removal must
|
|
// complete even if this connection drops mid-handshake, so detach
|
|
// from cancellation (values kept); shutdown is handled via h.stop.
|
|
staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt
|
|
lkCtx := context.WithoutCancel(ctx)
|
|
go func() {
|
|
select {
|
|
case <-h.stop:
|
|
return
|
|
default:
|
|
}
|
|
if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil {
|
|
slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)",
|
|
"err", err, "user_id", staleUserID, "channel_id", staleChID)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look up role for permission-filtered ready payload.
|
|
// Fail closed: if the role lookup fails, disconnect rather than serving
|
|
// a permissive ready payload with nil role (BUG-094).
|
|
userRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID)
|
|
if roleErr != nil || userRole == nil {
|
|
slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr)
|
|
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
|
|
return fmt.Errorf("role lookup failed for user %d: %w", c.userID, roleErr)
|
|
}
|
|
|
|
// Register BEFORE writing auth_ok + ready so broadcasts that arrive during
|
|
// the write window are queued in the client's send buffer instead of
|
|
// being lost (BUG-123). writePump hasn't started yet, so queued messages
|
|
// will be drained once the pumps begin.
|
|
//
|
|
// Only the replay-failure fallback (lastSeq > 0) can inherit voice state
|
|
// from the previous connection, so that is the only case where registerNow
|
|
// needs the read-permission set. Fail closed on error: nil denies the
|
|
// inherited voice-channel subscription.
|
|
var allowedChannelIDs map[int64]bool
|
|
if c.lastSeq > 0 {
|
|
allowed, allowedErr := h.computeAllowedChannels(ctx, database, c.user)
|
|
if allowedErr != nil {
|
|
slog.Warn("ws handleFreshConnect: computeAllowedChannels failed, skipping voice channel subscription",
|
|
"user_id", c.userID, "err", allowedErr)
|
|
} else {
|
|
allowedChannelIDs = allowed
|
|
}
|
|
}
|
|
h.registerNow(c, allowedChannelIDs)
|
|
|
|
// Settle the session's status before buildReady reads the member list, so
|
|
// the ready payload and the presence broadcast below cannot disagree.
|
|
applyConnectStatus(ctx, database, c)
|
|
|
|
// Fresh connection or replay fallback: full auth_ok + ready flow.
|
|
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
|
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil {
|
|
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
|
|
h.unregisterFailedHandshake(ctx, c)
|
|
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
|
return err
|
|
}
|
|
if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil {
|
|
slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready))
|
|
if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {
|
|
slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err)
|
|
h.unregisterFailedHandshake(ctx, c)
|
|
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
|
return err
|
|
}
|
|
} else {
|
|
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
|
|
_ = conn.Write(ctx, websocket.MessageText,
|
|
buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
|
|
h.unregisterFailedHandshake(ctx, c)
|
|
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
|
|
return readyErr
|
|
}
|
|
|
|
slog.Info("ws broadcasting member_join and presence", "user_id", c.userID, "username", c.user.Username)
|
|
h.BroadcastToAll(buildMemberJoin(c.user, c.roleName))
|
|
h.announceConnectPresence(c)
|
|
|
|
return nil
|
|
}
|