feat: channel permissions UI, DM Sans font, private channel filtering, migration fix
- Rewrite ChannelSettingsModal with full tri-state permission override UI for roles and members (allow/neutral/deny per permission bit) - Switch font from Inter to self-hosted DM Sans (woff2 variable fonts) - Add client-side VIEW_CHANNEL filtering in ChannelSidebar for private channels - Broadcast isPrivate flag on channel override changes - Fix voice permission bit migration: gate behind persistent flag to prevent repeated re-runs that stripped STREAM from @everyone roles - Add speakingUserIds set to voice store for efficient user-level lookups - Clear current channel view when a channel is deleted - Move .glass-strip to @layer utilities for proper CSS specificity - Simplify avatar initials font size to proportional formula
This commit is contained in:
@@ -412,90 +412,79 @@ function migrateEveryoneRoles(db: Database.Database): void {
|
||||
|
||||
/**
|
||||
* Remove the USE_VOICE_ACTIVITY bit (was bit 25) and shift STREAM (26→25)
|
||||
* and DISCONNECT_MEMBERS (27→26) down. Idempotent: uses a sentinel flag in
|
||||
* instance_settings metadata to avoid re-running.
|
||||
* and DISCONNECT_MEMBERS (27→26) down.
|
||||
*
|
||||
* Gated behind a persistent `voice_bit_migrated` flag in instance_settings
|
||||
* because the old and new bit positions overlap (STREAM moved into the same
|
||||
* bit 25 that USE_VOICE_ACTIVITY occupied), making bit-inspection unreliable
|
||||
* as an idempotency check. The previous version of this function had exactly
|
||||
* that bug — it re-ran on every startup and silently stripped STREAM and
|
||||
* DISCONNECT_MEMBERS from every role.
|
||||
*
|
||||
* On first run with the flag: repairs @everyone roles by re-adding STREAM,
|
||||
* then sets the flag so it never runs again.
|
||||
*/
|
||||
function migrateRemoveVoiceActivityBit(db: Database.Database): void {
|
||||
// Use a pragma-style check: if STREAM is already at bit 25 in DEFAULT_EVERYONE_PERMISSIONS
|
||||
// of the @everyone roles, the migration has already run. But for robustness, use a flag column.
|
||||
// We'll check if any role still has bit 25 set AND bit 26 set (old layout had both USE_VOICE_ACTIVITY
|
||||
// and STREAM). Simplest approach: track via a one-time marker.
|
||||
const OLD_VOICE_ACTIVITY = 1n << 25n; // old USE_VOICE_ACTIVITY
|
||||
const OLD_STREAM = 1n << 26n; // old STREAM
|
||||
const OLD_DISCONNECT = 1n << 27n; // old DISCONNECT_MEMBERS
|
||||
|
||||
// Check if any role still uses the old bit layout (has bit 26 or 27 set)
|
||||
const roles = db.prepare('SELECT id, permissions FROM roles WHERE permissions IS NOT NULL').all() as { id: string; permissions: string }[];
|
||||
const overrides = db.prepare('SELECT channel_id, target_type, target_id, allow, deny FROM channel_overrides').all() as {
|
||||
channel_id: string; target_type: string; target_id: string; allow: string; deny: string;
|
||||
}[];
|
||||
|
||||
let needsMigration = false;
|
||||
for (const role of roles) {
|
||||
try {
|
||||
const p = BigInt(role.permissions);
|
||||
if ((p & OLD_STREAM) !== 0n || (p & OLD_DISCONNECT) !== 0n || (p & OLD_VOICE_ACTIVITY) !== 0n) {
|
||||
needsMigration = true;
|
||||
break;
|
||||
}
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
if (!needsMigration) {
|
||||
for (const ov of overrides) {
|
||||
try {
|
||||
const a = BigInt(ov.allow);
|
||||
const d = BigInt(ov.deny);
|
||||
if ((a & OLD_STREAM) !== 0n || (a & OLD_DISCONNECT) !== 0n || (a & OLD_VOICE_ACTIVITY) !== 0n ||
|
||||
(d & OLD_STREAM) !== 0n || (d & OLD_DISCONNECT) !== 0n || (d & OLD_VOICE_ACTIVITY) !== 0n) {
|
||||
needsMigration = true;
|
||||
break;
|
||||
}
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
// Ensure the flag column exists
|
||||
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
||||
if (!cols.some(c => c.name === 'voice_bit_migrated')) {
|
||||
db.exec('ALTER TABLE instance_settings ADD COLUMN voice_bit_migrated INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
if (!needsMigration) return;
|
||||
// Check if already migrated
|
||||
const row = db.prepare('SELECT voice_bit_migrated FROM instance_settings WHERE id = 1').get() as
|
||||
{ voice_bit_migrated: number } | undefined;
|
||||
if (row && row.voice_bit_migrated === 1) return;
|
||||
|
||||
function shiftPermBits(p: bigint): bigint {
|
||||
const hasStream = (p & OLD_STREAM) !== 0n;
|
||||
const hasDisconnect = (p & OLD_DISCONNECT) !== 0n;
|
||||
// Clear bits 25, 26, 27
|
||||
p = p & ~(OLD_VOICE_ACTIVITY | OLD_STREAM | OLD_DISCONNECT);
|
||||
// Re-set at new positions
|
||||
if (hasStream) p |= (1n << 25n); // STREAM now at 25
|
||||
if (hasDisconnect) p |= (1n << 26n); // DISCONNECT_MEMBERS now at 26
|
||||
return p;
|
||||
}
|
||||
|
||||
console.log('Migrating: Shifting permission bits (removing USE_VOICE_ACTIVITY)...');
|
||||
// The bit-shifting migration already ran (possibly many times) via the old
|
||||
// broken code. All roles are already on the new layout (STREAM=25,
|
||||
// DISCONNECT_MEMBERS=26). The damage is that repeated re-runs wiped those
|
||||
// bits. Repair what we can:
|
||||
|
||||
const STREAM_BIT = 1n << 25n;
|
||||
const updateRole = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
||||
for (const role of roles) {
|
||||
|
||||
// Repair @everyone roles: re-add STREAM where it's missing.
|
||||
// @everyone role id === space id, so join on that.
|
||||
const spaces = db.prepare('SELECT id FROM spaces').all() as { id: string }[];
|
||||
for (const space of spaces) {
|
||||
const role = db.prepare('SELECT id, permissions FROM roles WHERE id = ?').get(space.id) as
|
||||
{ id: string; permissions: string } | undefined;
|
||||
if (!role?.permissions) continue;
|
||||
try {
|
||||
const old = BigInt(role.permissions);
|
||||
const shifted = shiftPermBits(old);
|
||||
if (shifted !== old) {
|
||||
updateRole.run(shifted.toString(), role.id);
|
||||
const perms = BigInt(role.permissions);
|
||||
if ((perms & STREAM_BIT) === 0n) {
|
||||
updateRole.run((perms | STREAM_BIT).toString(), role.id);
|
||||
console.log(`Repair: Re-added STREAM to @everyone role for space ${space.id}`);
|
||||
}
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
|
||||
const updateOverride = db.prepare(
|
||||
'UPDATE channel_overrides SET allow = ?, deny = ? WHERE channel_id = ? AND target_type = ? AND target_id = ?'
|
||||
);
|
||||
for (const ov of overrides) {
|
||||
// For non-@everyone roles, warn about potentially lost bits so admins can
|
||||
// manually re-enable STREAM / DISCONNECT_MEMBERS if needed.
|
||||
const customRoles = db.prepare(
|
||||
'SELECT id, space_id, name, permissions FROM roles WHERE id NOT IN (SELECT id FROM spaces) AND permissions IS NOT NULL'
|
||||
).all() as { id: string; space_id: string; name: string; permissions: string }[];
|
||||
|
||||
let warnCount = 0;
|
||||
for (const role of customRoles) {
|
||||
try {
|
||||
const oldAllow = BigInt(ov.allow);
|
||||
const oldDeny = BigInt(ov.deny);
|
||||
const newAllow = shiftPermBits(oldAllow);
|
||||
const newDeny = shiftPermBits(oldDeny);
|
||||
if (newAllow !== oldAllow || newDeny !== oldDeny) {
|
||||
updateOverride.run(newAllow.toString(), newDeny.toString(), ov.channel_id, ov.target_type, ov.target_id);
|
||||
const perms = BigInt(role.permissions);
|
||||
if ((perms & STREAM_BIT) === 0n) {
|
||||
warnCount++;
|
||||
}
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
if (warnCount > 0) {
|
||||
console.log(
|
||||
`Repair: ${warnCount} custom role(s) may be missing STREAM/DISCONNECT_MEMBERS permissions ` +
|
||||
`due to a previous migration bug. Admins can re-enable these in Space Settings → Roles.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Migrating: Permission bit shift complete.');
|
||||
// Set flag so this never runs again
|
||||
db.prepare('UPDATE instance_settings SET voice_bit_migrated = 1 WHERE id = 1').run();
|
||||
console.log('Migrating: Voice permission bit migration flagged as complete.');
|
||||
}
|
||||
|
||||
/** Delete corrupted read_states rows where last_read_message_id is not a valid snowflake (numeric string) */
|
||||
|
||||
@@ -38,6 +38,24 @@ function rowToCategory(row: typeof schema.channelCategories.$inferSelect): Chann
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel is private by looking for a VIEW_CHANNEL deny on @everyone.
|
||||
* The @everyone role ID equals the space ID.
|
||||
*/
|
||||
function isChannelPrivate(channelId: string, spaceId: string): boolean {
|
||||
const db = getDb();
|
||||
const override = db.select().from(schema.channelOverrides).where(
|
||||
and(
|
||||
eq(schema.channelOverrides.channelId, channelId),
|
||||
eq(schema.channelOverrides.targetType, 'role'),
|
||||
eq(schema.channelOverrides.targetId, spaceId),
|
||||
)
|
||||
).get();
|
||||
if (!override) return false;
|
||||
const denyBits = BigInt(override.deny || '0');
|
||||
return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a channel override changes, notify each space member:
|
||||
* - VIEW_CHANNEL holders receive channel_updated (with their myPermissions)
|
||||
@@ -49,6 +67,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
|
||||
if (!channel) return;
|
||||
|
||||
const channelData = rowToChannel(channel);
|
||||
const priv = isChannelPrivate(channelId, spaceId);
|
||||
|
||||
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
||||
if (!spaceIds.has(spaceId)) continue;
|
||||
@@ -57,7 +76,7 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void {
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'channel_updated',
|
||||
channel: { ...channelData, myPermissions: permissionsToString(perms) },
|
||||
channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) },
|
||||
spaceId,
|
||||
});
|
||||
} else {
|
||||
@@ -184,7 +203,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'channel_created',
|
||||
channel: { ...channelData, myPermissions: permissionsToString(perms) },
|
||||
channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) },
|
||||
spaceId: id,
|
||||
});
|
||||
}
|
||||
@@ -400,13 +419,28 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
// Validate that allow/deny are valid bigint strings
|
||||
let allowBits: bigint;
|
||||
let denyBits: bigint;
|
||||
try {
|
||||
BigInt(allow || '0');
|
||||
BigInt(deny || '0');
|
||||
allowBits = BigInt(allow || '0');
|
||||
denyBits = BigInt(deny || '0');
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'allow and deny must be valid decimal integer strings', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Privilege escalation guard: non-admin users can only grant permissions they possess
|
||||
const callerPerms = computePermissions(request.userId, channel.spaceId);
|
||||
if ((callerPerms & PermissionBits.ADMINISTRATOR) === 0n) {
|
||||
const escalatedAllow = allowBits & ~callerPerms;
|
||||
if (escalatedAllow !== 0n) {
|
||||
return reply.code(403).send({ error: 'Cannot grant permissions you do not possess', statusCode: 403 });
|
||||
}
|
||||
const escalatedDeny = denyBits & ~callerPerms;
|
||||
if (escalatedDeny !== 0n) {
|
||||
return reply.code(403).send({ error: 'Cannot deny permissions you do not possess', statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert: delete existing then insert
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.channelOverrides).where(
|
||||
@@ -730,6 +764,7 @@ function broadcastChannelLayout(spaceId: string): void {
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
visibleChannels.push({
|
||||
...rowToChannel(ch),
|
||||
isPrivate: isChannelPrivate(ch.id, spaceId),
|
||||
myPermissions: permissionsToString(perms),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,13 +290,29 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Compute space-level permissions for the requesting user
|
||||
const spacePerms = computePermissions(request.userId, id);
|
||||
|
||||
// Batch-fetch all channel overrides for @everyone (role = spaceId) to determine isPrivate
|
||||
const everyoneOverrides = db.select().from(schema.channelOverrides)
|
||||
.where(and(
|
||||
eq(schema.channelOverrides.targetType, 'role'),
|
||||
eq(schema.channelOverrides.targetId, id),
|
||||
))
|
||||
.all();
|
||||
const privateChannelIds = new Set<string>();
|
||||
for (const o of everyoneOverrides) {
|
||||
const denyBits = BigInt(o.deny || '0');
|
||||
if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
privateChannelIds.add(o.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions
|
||||
const visibleChannels: (Channel & { myPermissions: string })[] = [];
|
||||
const visibleChannels: (Channel & { isPrivate: boolean; myPermissions: string })[] = [];
|
||||
for (const ch of channels) {
|
||||
const perms = computePermissions(request.userId, id, ch.id);
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
visibleChannels.push({
|
||||
...rowToChannel(ch),
|
||||
isPrivate: privateChannelIds.has(ch.id),
|
||||
myPermissions: permissionsToString(perms),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { verifyJwt } from '../utils/auth.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { eq, inArray, desc, sql } from 'drizzle-orm';
|
||||
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
|
||||
import { handleClientEvent } from './events.js';
|
||||
import { computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
|
||||
import type {
|
||||
@@ -698,6 +698,25 @@ function buildReadyPayload(userId: string): {
|
||||
arr.push(ch);
|
||||
}
|
||||
|
||||
// Batch: determine which channels are private (VIEW_CHANNEL denied on @everyone)
|
||||
// @everyone role ID equals the space ID, so we query for overrides targeting role = spaceId
|
||||
const allEveroneOverrides = batchInArray(
|
||||
spaceIds,
|
||||
ids => db.select().from(schema.channelOverrides).where(
|
||||
and(
|
||||
eq(schema.channelOverrides.targetType, 'role'),
|
||||
inArray(schema.channelOverrides.targetId, ids),
|
||||
)
|
||||
).all(),
|
||||
);
|
||||
const privateChannelIds = new Set<string>();
|
||||
for (const o of allEveroneOverrides) {
|
||||
const denyBits = BigInt(o.deny || '0');
|
||||
if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
privateChannelIds.add(o.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch: all categories for all spaces (1 query instead of N)
|
||||
const allCategories = batchInArray(
|
||||
spaceIds,
|
||||
@@ -805,6 +824,7 @@ function buildReadyPayload(userId: string): {
|
||||
topic: ch.topic,
|
||||
position: ch.position ?? 0,
|
||||
categoryId: ch.categoryId ?? null,
|
||||
isPrivate: privateChannelIds.has(ch.id),
|
||||
createdAt: ch.createdAt,
|
||||
lastMessageId: lastMsgMap.get(ch.id) ?? null,
|
||||
myPermissions: permissionsToString(chPerms),
|
||||
|
||||
Reference in New Issue
Block a user