diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 23145334..73cca80f 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -301,6 +301,10 @@ async function api(method,path,body){ /* ═══ Utilities ═══ */ function esc(s){if(s===null||s===undefined)return'';return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} +/* Escape for embedding inside a single-quoted JS string in an inline onclick + attribute: JS-escape backslashes and single quotes first, then HTML-escape. + Without this a name containing ' breaks out of the string literal (XSS). */ +function jsq(s){return esc(String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'"))} function fmtBytes(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(2)+' GB'} function actionBadge(a){if(!a)return'badge-muted';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'badge-red';if(a.includes('create'))return'badge-green';if(a.includes('update'))return'badge-yellow';return'badge-accent'} function actionColor(a){if(!a)return'var(--accent)';if(a.includes('ban')||a.includes('kick')||a.includes('delete'))return'var(--red)';if(a.includes('create'))return'var(--green)';if(a.includes('update'))return'var(--yellow)';return'var(--accent)'} @@ -467,10 +471,10 @@ async function renderUsers(){ html+=''+statusLabel+''; html+=''+(banned?'Yes':'No')+''; html+='
'; - html+=''; + html+=''; html+=''; if(banned)html+=''; - else html+=''; + else html+=''; html+='
'; }); html+=''; @@ -528,8 +532,8 @@ async function renderChannels(){ html+=''+esc(type)+''; html+=''+esc(cat)+''; html+=''+(archived?'Yes':'No')+''; - const lockBtn=type==='dm'?'':''; - html+='
'+lockBtn+'
'; + const lockBtn=type==='dm'?'':''; + html+='
'+lockBtn+'
'; }); html+=''; return html; @@ -847,7 +851,7 @@ async function createBackup(){ } function openRestoreModal(name){ - openModal(''); + openModal(''); } async function confirmRestore(name){ diff --git a/Server/ws/export_test.go b/Server/ws/export_test.go index a8a10e9c..7da0195d 100644 --- a/Server/ws/export_test.go +++ b/Server/ws/export_test.go @@ -217,3 +217,8 @@ func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, } h.handleWebhookParticipantLeft(context.Background(), event) } + +// MustFullResyncForTest exposes mustFullResync for external tests. +func (h *Hub) MustFullResyncForTest(lastSeq uint64) bool { + return h.mustFullResync(lastSeq) +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index ca46e826..7e4b5e8d 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -74,6 +74,13 @@ type Hub struct { 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 @@ -557,7 +564,16 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { if c.user == nil { continue } - if roleVisible(c.user.RoleID) { + // 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(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 @@ -570,6 +586,20 @@ func (h *Hub) RefreshChannelVisibility(ch *db.Channel) { } 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 diff --git a/Server/ws/hub_test.go b/Server/ws/hub_test.go index ac56323e..6bbfebbb 100644 --- a/Server/ws/hub_test.go +++ b/Server/ws/hub_test.go @@ -954,6 +954,36 @@ func TestRefreshChannelVisibility_TargetedSends(t *testing.T) { assertNoMsgType(t, memberSend, "channel_delete") } +func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) { + hub, database := newTestHub(t) + + chID := seedTestChannel(t, database, "watermark-room") + ch, err := database.GetChannel(chID) + if err != nil || ch == nil { + t.Fatalf("GetChannel: %v", err) + } + + // No visibility change yet — resume is allowed regardless of seq. + if hub.MustFullResyncForTest(1) { + t.Error("expected replay allowed before any visibility change") + } + + hub.SeedSeq(41) + hub.RefreshChannelVisibility(ch) + + // Clients resuming from at/before the change must take the full path. + if !hub.MustFullResyncForTest(41) { + t.Error("expected forced full resync for lastSeq at the watermark") + } + if !hub.MustFullResyncForTest(10) { + t.Error("expected forced full resync for lastSeq before the watermark") + } + // Clients that saw sequenced traffic after the change may replay. + if hub.MustFullResyncForTest(42) { + t.Error("expected replay allowed for lastSeq after the watermark") + } +} + // hubTestSchema is the minimal schema needed for hub tests. var hubTestSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( diff --git a/Server/ws/serve.go b/Server/ws/serve.go index f6fc588b..5ecb6894 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -112,6 +112,16 @@ func (h *Hub) upgradeAndAuth( func (h *Hub) handleReconnect( ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64, ) 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 + } // 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(database, c.user)