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+='
';
});
html+='';
return html;
@@ -847,7 +851,7 @@ async function createBackup(){
}
function openRestoreModal(name){
- openModal('
Restore Backup
Overwrite the current database with '+esc(name)+'? A pre-restore backup will be created. Server restart recommended after restore.
');
+ openModal('
Restore Backup
Overwrite the current database with '+esc(name)+'? A pre-restore backup will be created. Server restart recommended after restore.
');
}
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)