fix: persist server mute/deafen state across reloads and prevent client bypass

Server-side: add DB persistence for voice restrictions (schema, migration,
ready payload, cleanup on leave). Client-side: fix four bugs that wiped or
bypassed server restriction state — leaveVoice() no longer clears global
restriction Sets, voice_state_update leave no longer drops amber icons,
toggleMic/toggleDeafen now guard against server restrictions, and force-mute/
deafen uses direct setState instead of fragile toggle calls.
This commit is contained in:
Jannis Braun
2026-03-09 18:36:17 +01:00
parent f6cdfe993f
commit c2ddfe0cb7
7 changed files with 236 additions and 28 deletions
+12
View File
@@ -142,6 +142,18 @@ export function runMigrations(db: Database.Database): void {
);
`);
// Ensure voice_restrictions table exists (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS voice_restrictions (
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
restriction_type TEXT NOT NULL,
moderator_id TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
PRIMARY KEY (space_id, user_id, restriction_type)
);
`);
// ─── RBAC Migration: Ensure @everyone roles exist for all spaces ─────────
migrateEveryoneRoles(db);
+10
View File
@@ -217,3 +217,13 @@ export const joinRequests = sqliteTable('join_requests', {
createdAt: integer('created_at').notNull(),
decidedAt: integer('decided_at'),
});
export const voiceRestrictions = sqliteTable('voice_restrictions', {
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
restrictionType: text('restriction_type').notNull(), // 'mute' | 'deafen'
moderatorId: text('moderator_id').notNull().references(() => users.id),
createdAt: integer('created_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.spaceId, table.userId, table.restrictionType] }),
}));
+14
View File
@@ -768,6 +768,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
))
.run();
// Clean up any voice restrictions for the removed member
db.delete(schema.voiceRestrictions).where(
and(
eq(schema.voiceRestrictions.spaceId, id),
eq(schema.voiceRestrictions.userId, uid),
)
).run();
// Broadcast member_left event
connectionManager.sendToSpace(id, {
type: 'member_left',
@@ -1038,6 +1046,12 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
eq(schema.memberRoles.spaceId, id),
eq(schema.memberRoles.userId, targetId),
)).run();
// Clean up any voice restrictions for the banned member
tx.delete(schema.voiceRestrictions).where(and(
eq(schema.voiceRestrictions.spaceId, id),
eq(schema.voiceRestrictions.userId, targetId),
)).run();
});
// Broadcast member_left event so other clients update their member list
+70
View File
@@ -476,6 +476,36 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
isScreenSharing: status.isScreenSharing,
});
}
// Load persistent voice restrictions for this user in this space
const db = getDb();
const restrictions = db.select()
.from(schema.voiceRestrictions)
.where(and(
eq(schema.voiceRestrictions.spaceId, spaceId),
eq(schema.voiceRestrictions.userId, userId),
))
.all();
for (const r of restrictions) {
if (r.restrictionType === 'mute') {
connectionManager.setServerMuted(userId, true);
connectionManager.sendToSpace(spaceId, {
type: 'voice_server_muted',
userId,
channelId,
muted: true,
});
} else if (r.restrictionType === 'deafen') {
connectionManager.setServerDeafened(userId, true);
connectionManager.sendToSpace(spaceId, {
type: 'voice_server_deafened',
userId,
channelId,
deafened: true,
});
}
}
}
function handleVoiceLeave(userId: string): void {
@@ -1092,6 +1122,26 @@ function handleVoiceServerMute(event: Record<string, unknown>, userId: string):
connectionManager.setServerMuted(targetUserId, muted);
// Persist to DB
const db = getDb();
if (muted) {
db.insert(schema.voiceRestrictions).values({
spaceId: meta.spaceId,
userId: targetUserId,
restrictionType: 'mute',
moderatorId: userId,
createdAt: Date.now(),
}).onConflictDoNothing().run();
} else {
db.delete(schema.voiceRestrictions).where(
and(
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
eq(schema.voiceRestrictions.userId, targetUserId),
eq(schema.voiceRestrictions.restrictionType, 'mute'),
)
).run();
}
// Broadcast to all space members
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_muted',
@@ -1129,6 +1179,26 @@ function handleVoiceServerDeafen(event: Record<string, unknown>, userId: string)
connectionManager.setServerDeafened(targetUserId, deafened);
// Persist to DB
const db = getDb();
if (deafened) {
db.insert(schema.voiceRestrictions).values({
spaceId: meta.spaceId,
userId: targetUserId,
restrictionType: 'deafen',
moderatorId: userId,
createdAt: Date.now(),
}).onConflictDoNothing().run();
} else {
db.delete(schema.voiceRestrictions).where(
and(
eq(schema.voiceRestrictions.spaceId, meta.spaceId),
eq(schema.voiceRestrictions.userId, targetUserId),
eq(schema.voiceRestrictions.restrictionType, 'deafen'),
)
).run();
}
connectionManager.sendToSpace(meta.spaceId, {
type: 'voice_server_deafened',
userId: targetUserId,
+13
View File
@@ -890,6 +890,19 @@ function buildReadyPayload(userId: string): {
}
}
// Also include the connecting user's own DB-persisted restrictions
// (covers reconnect after disconnect timeout cleared in-memory state)
const myRestrictions = db.select()
.from(schema.voiceRestrictions)
.where(eq(schema.voiceRestrictions.userId, userId))
.all();
for (const r of myRestrictions) {
const existing = serverVoiceStates[userId] ?? { serverMuted: false, serverDeafened: false };
if (r.restrictionType === 'mute') existing.serverMuted = true;
if (r.restrictionType === 'deafen') existing.serverDeafened = true;
serverVoiceStates[userId] = existing;
}
// Fetch read states for unread tracking
const readStateRows = db.select()
.from(schema.readStates)