From 637e083e9aa1ee271a234252c64923c4ff920a8a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:29:25 +0100 Subject: [PATCH] feat: add category override API endpoints, fix channel categoryId reassignment, include isPrivate on categories - Add GET/PUT/DELETE /api/categories/:id/overrides endpoints with privilege escalation guard matching existing channel override pattern - Add isCategoryPrivate() and broadcastCategoryOverrideChange() helpers that cascade visibility changes to all channels in a category - Fix PATCH /api/channels/:id to recompute permissions via broadcastOverrideChange when categoryId changes (different category overrides may apply) - Include isPrivate flag on categories in GET /api/spaces/:id payload using batch-fetched @everyone overrides - Include isPrivate on categories in broadcastChannelLayout and PATCH /api/categories/:id broadcasts --- packages/server/src/routes/channels.ts | 219 +++++++++++++++++++++++-- packages/server/src/routes/spaces.ts | 17 ++ 2 files changed, 226 insertions(+), 10 deletions(-) diff --git a/packages/server/src/routes/channels.ts b/packages/server/src/routes/channels.ts index 158f8e42..98590b67 100644 --- a/packages/server/src/routes/channels.ts +++ b/packages/server/src/routes/channels.ts @@ -89,6 +89,51 @@ function broadcastOverrideChange(spaceId: string, channelId: string): void { } } +/** + * Check if a category is private by looking for VIEW_CHANNEL deny on @everyone. + */ +function isCategoryPrivate(categoryId: string, spaceId: string): boolean { + const db = getDb(); + const override = db.select().from(schema.categoryOverrides).where( + and( + eq(schema.categoryOverrides.categoryId, categoryId), + eq(schema.categoryOverrides.targetType, 'role'), + eq(schema.categoryOverrides.targetId, spaceId), + ) + ).get(); + if (!override) return false; + const denyBits = BigInt(override.deny || '0'); + return (denyBits & PermissionBits.VIEW_CHANNEL) !== 0n; +} + +/** + * When a category's overrides change, re-evaluate visibility for all channels + * in that category and send channel_updated/channel_deleted per user. + * Also broadcasts category_updated with isPrivate for the lock icon. + */ +function broadcastCategoryOverrideChange(spaceId: string, categoryId: string): void { + const db = getDb(); + + const channelsInCategory = db.select().from(schema.channels) + .where(and(eq(schema.channels.spaceId, spaceId), eq(schema.channels.categoryId, categoryId))) + .all(); + + for (const ch of channelsInCategory) { + broadcastOverrideChange(spaceId, ch.id); + } + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, categoryId)).get(); + if (category) { + const isPrivate = isCategoryPrivate(categoryId, spaceId); + connectionManager.sendToSpace(spaceId, { + type: 'category_updated', + category: { ...rowToCategory(category), isPrivate }, + spaceId, + }); + } +} + export async function channelRoutes(app: FastifyInstance): Promise { // GET /api/spaces/:id/channels - List channels in a space app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', { @@ -278,12 +323,20 @@ export async function channelRoutes(app: FastifyInstance): Promise { const channelData = rowToChannel(updated); - // Broadcast channel_updated to members with VIEW_CHANNEL - connectionManager.sendToChannel(spaceId, id, { - type: 'channel_updated', - channel: channelData, - spaceId, - }); + // If categoryId changed, permissions may have changed due to different category overrides + if (categoryId !== undefined) { + broadcastOverrideChange(spaceId, id); + if (channel.type === 'voice') { + checkVoicePermissions(spaceId); + } + } else { + // Simple broadcast for non-permission-affecting changes + connectionManager.sendToChannel(spaceId, id, { + type: 'channel_updated', + channel: channelData, + spaceId, + }); + } return reply.code(200).send(channelData); }); @@ -500,6 +553,149 @@ export async function channelRoutes(app: FastifyInstance): Promise { }, ); + // ─── Category Override Endpoints ───────────────────────────────────────── + + // GET /api/categories/:id/overrides + app.get<{ Params: { id: string } }>('/api/categories/:id/overrides', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!category) { + return reply.code(404).send({ error: 'Category not found', statusCode: 404 }); + } + + if (!isMember(category.spaceId, request.userId)) { + return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 }); + } + + if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); + } + + const overrides = db.select().from(schema.categoryOverrides) + .where(eq(schema.categoryOverrides.categoryId, id)) + .all(); + + return reply.code(200).send(overrides.map(o => ({ + categoryId: o.categoryId, + targetType: o.targetType, + targetId: o.targetId, + allow: o.allow, + deny: o.deny, + }))); + }); + + // PUT /api/categories/:id/overrides + app.put<{ + Params: { id: string }; + Body: { targetType: string; targetId: string; allow: string; deny: string }; + }>('/api/categories/:id/overrides', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { targetType, targetId, allow, deny } = request.body; + const db = getDb(); + + if (!targetType || !['role', 'member'].includes(targetType)) { + return reply.code(400).send({ error: 'targetType must be "role" or "member"', statusCode: 400 }); + } + if (!targetId || typeof targetId !== 'string') { + return reply.code(400).send({ error: 'targetId is required', statusCode: 400 }); + } + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!category) { + return reply.code(404).send({ error: 'Category not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); + } + + let allowBits: bigint; + let denyBits: bigint; + try { + 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 (matches channel override pattern) + const callerPerms = computePermissions(request.userId, category.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 }); + } + } + + db.transaction((tx) => { + tx.delete(schema.categoryOverrides).where( + and( + eq(schema.categoryOverrides.categoryId, id), + eq(schema.categoryOverrides.targetType, targetType), + eq(schema.categoryOverrides.targetId, targetId), + ) + ).run(); + + tx.insert(schema.categoryOverrides).values({ + categoryId: id, + targetType, + targetId, + allow: allow || '0', + deny: deny || '0', + }).run(); + }); + + broadcastCategoryOverrideChange(category.spaceId, id); + checkVoicePermissions(category.spaceId); + + return reply.code(200).send({ success: true }); + }); + + // DELETE /api/categories/:id/overrides/:targetType/:targetId + app.delete<{ Params: { id: string; targetType: string; targetId: string } }>( + '/api/categories/:id/overrides/:targetType/:targetId', + { preHandler: authenticate }, + async (request, reply) => { + const { id, targetType, targetId } = request.params; + const db = getDb(); + + const category = db.select().from(schema.channelCategories) + .where(eq(schema.channelCategories.id, id)).get(); + if (!category) { + return reply.code(404).send({ error: 'Category not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_ROLES)) { + return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 }); + } + + db.delete(schema.categoryOverrides).where( + and( + eq(schema.categoryOverrides.categoryId, id), + eq(schema.categoryOverrides.targetType, targetType), + eq(schema.categoryOverrides.targetId, targetId), + ) + ).run(); + + broadcastCategoryOverrideChange(category.spaceId, id); + checkVoicePermissions(category.spaceId); + + return reply.code(200).send({ success: true }); + }, + ); + // ─── Channel Category Endpoints ───────────────────────────────────────────── // POST /api/spaces/:id/categories - Create a category @@ -608,14 +804,14 @@ export async function channelRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 }); } - const categoryData = rowToCategory(updated); + const updatedData = { ...rowToCategory(updated), isPrivate: isCategoryPrivate(id, category.spaceId) }; connectionManager.sendToSpace(category.spaceId, { type: 'category_updated', - category: categoryData, + category: updatedData, spaceId: category.spaceId, }); - return reply.code(200).send(categoryData); + return reply.code(200).send(updatedData); }); // DELETE /api/categories/:id - Delete a category @@ -753,7 +949,10 @@ function broadcastChannelLayout(spaceId: string): void { const allCategories = db.select().from(schema.channelCategories) .where(eq(schema.channelCategories.spaceId, spaceId)).all(); - const categoryData = allCategories.map(rowToCategory); + const categoryData = allCategories.map(c => ({ + ...rowToCategory(c), + isPrivate: isCategoryPrivate(c.id, spaceId), + })); for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) { if (!spaceIds.has(spaceId)) continue; diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 4bdbc355..8f726366 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -301,11 +301,28 @@ export async function spaceRoutes(app: FastifyInstance): Promise { .from(schema.channelCategories) .where(eq(schema.channelCategories.spaceId, id)) .all(); + + // Batch-fetch category overrides for @everyone to determine isPrivate + const catEveryoneOverrides = db.select().from(schema.categoryOverrides) + .where(and( + eq(schema.categoryOverrides.targetType, 'role'), + eq(schema.categoryOverrides.targetId, id), + )) + .all(); + const privateCategoryIds = new Set(); + for (const o of catEveryoneOverrides) { + const denyBits = BigInt(o.deny || '0'); + if ((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n) { + privateCategoryIds.add(o.categoryId); + } + } + const categories: ChannelCategory[] = categoryRows.map(c => ({ id: c.id, spaceId: c.spaceId, name: c.name, position: c.position ?? 0, + isPrivate: privateCategoryIds.has(c.id), createdAt: c.createdAt, }));