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
This commit is contained in:
Jannis Braun
2026-03-21 18:29:25 +01:00
parent b5ac7d3ee4
commit 637e083e9a
2 changed files with 226 additions and 10 deletions
+204 -5
View File
@@ -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<void> { export async function channelRoutes(app: FastifyInstance): Promise<void> {
// GET /api/spaces/:id/channels - List channels in a space // GET /api/spaces/:id/channels - List channels in a space
app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', { app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', {
@@ -278,12 +323,20 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
const channelData = rowToChannel(updated); const channelData = rowToChannel(updated);
// Broadcast channel_updated to members with VIEW_CHANNEL // 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, { connectionManager.sendToChannel(spaceId, id, {
type: 'channel_updated', type: 'channel_updated',
channel: channelData, channel: channelData,
spaceId, spaceId,
}); });
}
return reply.code(200).send(channelData); return reply.code(200).send(channelData);
}); });
@@ -500,6 +553,149 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// ─── 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 ───────────────────────────────────────────── // ─── Channel Category Endpoints ─────────────────────────────────────────────
// POST /api/spaces/:id/categories - Create a category // POST /api/spaces/:id/categories - Create a category
@@ -608,14 +804,14 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 }); 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, { connectionManager.sendToSpace(category.spaceId, {
type: 'category_updated', type: 'category_updated',
category: categoryData, category: updatedData,
spaceId: category.spaceId, spaceId: category.spaceId,
}); });
return reply.code(200).send(categoryData); return reply.code(200).send(updatedData);
}); });
// DELETE /api/categories/:id - Delete a category // DELETE /api/categories/:id - Delete a category
@@ -753,7 +949,10 @@ function broadcastChannelLayout(spaceId: string): void {
const allCategories = db.select().from(schema.channelCategories) const allCategories = db.select().from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, spaceId)).all(); .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()) { for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
if (!spaceIds.has(spaceId)) continue; if (!spaceIds.has(spaceId)) continue;
+17
View File
@@ -301,11 +301,28 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
.from(schema.channelCategories) .from(schema.channelCategories)
.where(eq(schema.channelCategories.spaceId, id)) .where(eq(schema.channelCategories.spaceId, id))
.all(); .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<string>();
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 => ({ const categories: ChannelCategory[] = categoryRows.map(c => ({
id: c.id, id: c.id,
spaceId: c.spaceId, spaceId: c.spaceId,
name: c.name, name: c.name,
position: c.position ?? 0, position: c.position ?? 0,
isPrivate: privateCategoryIds.has(c.id),
createdAt: c.createdAt, createdAt: c.createdAt,
})); }));