The sidebar's visibleChannels filter is keyed on the channelPermissions Map. Creating a channel raced two state updates: the optimistic create (added to channels with no permission entry) and the channel_created WS event (the only thing that set the permission). When the optimistic add won the race, the WS handler hit its dedup guard, skipped setChannels, and set the permission by mutating the Map in place — no new reference, so visibleChannels never recomputed and the channel stayed hidden until loadSpace rebuilt the maps (i.e. leaving and returning to the space). Centralize the logic in a new upsertChannel store action that replaces channels and channelPermissions with fresh references, used by both the create path and the channel_created handler. Also return the creator's computed myPermissions (and isPrivate) from POST so the channel renders immediately from the response, independent of WS timing. Adds spaceStore.upsertChannel.test.ts covering the reference-identity regression and the optimistic-reconcile path.
988 lines
36 KiB
TypeScript
988 lines
36 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { eq, and, inArray } from 'drizzle-orm';
|
|
import { getDb, schema } from '../db/index.js';
|
|
import { authenticate } from '../utils/auth.js';
|
|
import { generateSnowflake } from '../utils/snowflake.js';
|
|
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
|
import { permissionsToString } from '@backspace/shared/src/permissions.js';
|
|
import { connectionManager } from '../ws/handler.js';
|
|
import { checkVoicePermissions } from '../ws/events.js';
|
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
|
import type {
|
|
CreateChannelRequest,
|
|
UpdateChannelRequest,
|
|
Channel,
|
|
ChannelCategory,
|
|
} from '@backspace/shared';
|
|
|
|
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
|
return {
|
|
id: row.id,
|
|
spaceId: row.spaceId,
|
|
name: row.name,
|
|
type: row.type as Channel['type'],
|
|
topic: row.topic,
|
|
position: row.position ?? 0,
|
|
categoryId: row.categoryId ?? null,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
function rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory {
|
|
return {
|
|
id: row.id,
|
|
spaceId: row.spaceId,
|
|
name: row.name,
|
|
position: row.position ?? 0,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
* - Non-viewers receive channel_deleted to remove the channel from their UI
|
|
*/
|
|
function broadcastOverrideChange(spaceId: string, channelId: string): void {
|
|
const db = getDb();
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
|
|
if (!channel) return;
|
|
|
|
const channelData = rowToChannel(channel);
|
|
const priv = isChannelPrivate(channelId, spaceId);
|
|
|
|
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
|
if (!spaceIds.has(spaceId)) continue;
|
|
|
|
const perms = computePermissions(userId, spaceId, channelId);
|
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'channel_updated',
|
|
channel: { ...channelData, isPrivate: priv, myPermissions: permissionsToString(perms) },
|
|
spaceId,
|
|
});
|
|
} else {
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'channel_deleted',
|
|
channelId,
|
|
spaceId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
// GET /api/spaces/:id/channels - List channels in a space
|
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/channels', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!space) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!isMember(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'You are not a member of this space', statusCode: 403 });
|
|
}
|
|
|
|
const allChannels = db.select()
|
|
.from(schema.channels)
|
|
.where(eq(schema.channels.spaceId, id))
|
|
.all();
|
|
|
|
// Filter by VIEW_CHANNEL permission per channel
|
|
const visibleChannels = allChannels.filter(ch => {
|
|
const perms = computePermissions(request.userId, id, ch.id);
|
|
return (perms & PermissionBits.VIEW_CHANNEL) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
|
});
|
|
|
|
// Sort by position
|
|
visibleChannels.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
|
|
return reply.code(200).send(visibleChannels.map(rowToChannel));
|
|
});
|
|
|
|
// POST /api/spaces/:id/channels - Create a channel (admin+)
|
|
app.post<{ Params: { id: string }; Body: CreateChannelRequest }>('/api/spaces/:id/channels', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name, type, topic, categoryId } = request.body;
|
|
const db = getDb();
|
|
|
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!space) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
|
}
|
|
|
|
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
|
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
|
}
|
|
|
|
if (!type || !['text', 'voice'].includes(type)) {
|
|
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
|
|
}
|
|
|
|
// Validate categoryId if provided
|
|
let validCategoryId: string | null = null;
|
|
if (categoryId) {
|
|
const cat = db.select().from(schema.channelCategories)
|
|
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id)))
|
|
.get();
|
|
if (!cat) {
|
|
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
|
}
|
|
validCategoryId = categoryId;
|
|
}
|
|
|
|
// Get max position for ordering
|
|
const existingChannels = db.select()
|
|
.from(schema.channels)
|
|
.where(eq(schema.channels.spaceId, id))
|
|
.all();
|
|
|
|
const maxPosition = existingChannels.reduce((max, ch) => Math.max(max, ch.position ?? 0), -1);
|
|
|
|
const channelId = generateSnowflake();
|
|
const now = Date.now();
|
|
|
|
db.insert(schema.channels).values({
|
|
id: channelId,
|
|
spaceId: id,
|
|
name: trimmedName,
|
|
type,
|
|
topic: topic?.trim() || null,
|
|
position: maxPosition + 1,
|
|
categoryId: validCategoryId,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, channelId)).get();
|
|
if (!channel) {
|
|
return reply.code(500).send({ error: 'Failed to create channel', statusCode: 500 });
|
|
}
|
|
|
|
const channelData = rowToChannel(channel);
|
|
|
|
// Broadcast channel_created with per-user permissions
|
|
// (same pattern as broadcastOverrideChange — permissions are per-user
|
|
// so we must compute individually rather than broadcast uniformly)
|
|
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
|
if (!spaceIds.has(id)) continue;
|
|
const perms = computePermissions(userId, id, channelId);
|
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'channel_created',
|
|
channel: { ...channelData, isPrivate: false, myPermissions: permissionsToString(perms) },
|
|
spaceId: id,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Return the channel with the creator's computed permissions (same shape as
|
|
// the channel_created WS event) so the client can render it immediately
|
|
// without waiting for the broadcast to round-trip.
|
|
const creatorPerms = computePermissions(request.userId, id, channelId);
|
|
return reply.code(201).send({
|
|
...channelData,
|
|
isPrivate: false,
|
|
myPermissions: permissionsToString(creatorPerms),
|
|
});
|
|
});
|
|
|
|
// PATCH /api/channels/:id - Update a channel (admin+)
|
|
app.patch<{ Params: { id: string }; Body: UpdateChannelRequest }>('/api/channels/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name, topic, position, categoryId } = request.body;
|
|
const db = getDb();
|
|
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!channel) {
|
|
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
|
}
|
|
|
|
const spaceId = channel.spaceId;
|
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
|
|
|
if (name !== undefined) {
|
|
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
|
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
|
}
|
|
updates.name = trimmedName;
|
|
}
|
|
|
|
if (topic !== undefined) {
|
|
updates.topic = topic.trim() || null;
|
|
}
|
|
|
|
if (position !== undefined) {
|
|
if (typeof position !== 'number' || position < 0) {
|
|
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
|
|
}
|
|
updates.position = position;
|
|
}
|
|
|
|
if (categoryId !== undefined) {
|
|
if (categoryId === null) {
|
|
updates.categoryId = null;
|
|
} else {
|
|
const cat = db.select().from(schema.channelCategories)
|
|
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId)))
|
|
.get();
|
|
if (!cat) {
|
|
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
|
}
|
|
updates.categoryId = categoryId;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
|
}
|
|
|
|
db.update(schema.channels).set(updates).where(eq(schema.channels.id, id)).run();
|
|
|
|
const updated = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!updated) {
|
|
return reply.code(500).send({ error: 'Failed to update channel', statusCode: 500 });
|
|
}
|
|
|
|
const channelData = rowToChannel(updated);
|
|
|
|
// 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);
|
|
});
|
|
|
|
// DELETE /api/channels/:id - Delete a channel (admin+)
|
|
app.delete<{ Params: { id: string } }>('/api/channels/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!channel) {
|
|
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
|
}
|
|
|
|
const spaceId = channel.spaceId;
|
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_CHANNELS, id)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
// Disconnect voice users before deletion
|
|
const participants = connectionManager.getRoomParticipants(id);
|
|
if (participants.size > 0) {
|
|
for (const participantId of Array.from(participants)) {
|
|
connectionManager.leaveRoom(id, participantId);
|
|
connectionManager.clearVoiceUserStatus(participantId);
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'voice_state_update', channelId: id, userId: participantId, action: 'leave',
|
|
});
|
|
connectionManager.sendToUser(participantId, {
|
|
type: 'voice_disconnected', userId: participantId, channelId: id,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Collect viewers BEFORE deleting (overrides CASCADE-delete with the channel)
|
|
const viewerIds: string[] = [];
|
|
for (const [uid, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
|
if (spaceIds.has(spaceId)) {
|
|
const perms = computePermissions(uid, spaceId, id);
|
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
|
viewerIds.push(uid);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect attachment filenames BEFORE cascade deletes DB records
|
|
const channelMsgIds = db.select({ id: schema.messages.id })
|
|
.from(schema.messages).where(eq(schema.messages.channelId, id)).all().map(m => m.id);
|
|
|
|
let attachmentRows: { filename: string }[] = [];
|
|
if (channelMsgIds.length > 0) {
|
|
attachmentRows = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments).where(inArray(schema.attachments.messageId, channelMsgIds)).all();
|
|
}
|
|
|
|
// Clean up read_states (no FK, rows would be orphaned)
|
|
db.delete(schema.readStates).where(eq(schema.readStates.channelId, id)).run();
|
|
|
|
// Delete messages in channel (attachments cascade), then channel
|
|
db.delete(schema.messages).where(eq(schema.messages.channelId, id)).run();
|
|
db.delete(schema.channels).where(eq(schema.channels.id, id)).run();
|
|
|
|
// Delete attachment files from disk
|
|
deleteAttachmentFiles(attachmentRows);
|
|
|
|
// Broadcast channel_deleted only to users who could see the channel
|
|
const deleteEvent = { type: 'channel_deleted' as const, channelId: id, spaceId };
|
|
for (const uid of viewerIds) {
|
|
connectionManager.sendToUser(uid, deleteEvent);
|
|
}
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// ─── Channel Override Endpoints ───────────────────────────────────────────
|
|
|
|
// GET /api/channels/:id/overrides - List channel permission overrides
|
|
app.get<{ Params: { id: string } }>('/api/channels/:id/overrides', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!channel) {
|
|
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
const overrides = db.select().from(schema.channelOverrides)
|
|
.where(eq(schema.channelOverrides.channelId, id))
|
|
.all();
|
|
|
|
return reply.code(200).send(overrides.map(o => ({
|
|
channelId: o.channelId,
|
|
targetType: o.targetType,
|
|
targetId: o.targetId,
|
|
allow: o.allow,
|
|
deny: o.deny,
|
|
})));
|
|
});
|
|
|
|
// PUT /api/channels/:id/overrides - Create or update a channel override
|
|
app.put<{
|
|
Params: { id: string };
|
|
Body: { targetType: string; targetId: string; allow: string; deny: string };
|
|
}>('/api/channels/: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 channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!channel) {
|
|
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
// Validate that allow/deny are valid bigint strings
|
|
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: 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(
|
|
and(
|
|
eq(schema.channelOverrides.channelId, id),
|
|
eq(schema.channelOverrides.targetType, targetType),
|
|
eq(schema.channelOverrides.targetId, targetId),
|
|
)
|
|
).run();
|
|
|
|
tx.insert(schema.channelOverrides).values({
|
|
channelId: id,
|
|
targetType,
|
|
targetId,
|
|
allow: allow || '0',
|
|
deny: deny || '0',
|
|
}).run();
|
|
});
|
|
|
|
// Notify all space members of the permission change
|
|
broadcastOverrideChange(channel.spaceId, id);
|
|
checkVoicePermissions(channel.spaceId);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// DELETE /api/channels/:id/overrides/:targetType/:targetId - Remove a channel override
|
|
app.delete<{ Params: { id: string; targetType: string; targetId: string } }>(
|
|
'/api/channels/:id/overrides/:targetType/:targetId',
|
|
{ preHandler: authenticate },
|
|
async (request, reply) => {
|
|
const { id, targetType, targetId } = request.params;
|
|
const db = getDb();
|
|
|
|
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
|
if (!channel) {
|
|
return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, channel.spaceId, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
db.delete(schema.channelOverrides).where(
|
|
and(
|
|
eq(schema.channelOverrides.channelId, id),
|
|
eq(schema.channelOverrides.targetType, targetType),
|
|
eq(schema.channelOverrides.targetId, targetId),
|
|
)
|
|
).run();
|
|
|
|
// Notify all space members of the permission change
|
|
broadcastOverrideChange(channel.spaceId, id);
|
|
checkVoicePermissions(channel.spaceId);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
},
|
|
);
|
|
|
|
// ─── 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
|
|
app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name } = request.body;
|
|
const db = getDb();
|
|
|
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!space) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
return reply.code(400).send({ error: 'Category name is required', statusCode: 400 });
|
|
}
|
|
|
|
const trimmedName = name.trim();
|
|
if (trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 });
|
|
}
|
|
|
|
const existing = db.select().from(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.spaceId, id))
|
|
.all();
|
|
const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1);
|
|
|
|
const categoryId = generateSnowflake();
|
|
const now = Date.now();
|
|
|
|
db.insert(schema.channelCategories).values({
|
|
id: categoryId,
|
|
spaceId: id,
|
|
name: trimmedName,
|
|
position: maxPos + 1,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
const category = db.select().from(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.id, categoryId)).get();
|
|
if (!category) {
|
|
return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 });
|
|
}
|
|
|
|
const categoryData = rowToCategory(category);
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'category_created',
|
|
category: categoryData,
|
|
spaceId: id,
|
|
});
|
|
|
|
return reply.code(201).send(categoryData);
|
|
});
|
|
|
|
// PATCH /api/categories/:id - Update a category
|
|
app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name, position } = request.body;
|
|
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_CHANNELS)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
const updates: Partial<typeof schema.channelCategories.$inferInsert> = {};
|
|
|
|
if (name !== undefined) {
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName || trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 });
|
|
}
|
|
updates.name = trimmedName;
|
|
}
|
|
|
|
if (position !== undefined) {
|
|
if (typeof position !== 'number' || position < 0) {
|
|
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
|
|
}
|
|
updates.position = position;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
|
}
|
|
|
|
db.update(schema.channelCategories).set(updates)
|
|
.where(eq(schema.channelCategories.id, id)).run();
|
|
|
|
const updated = db.select().from(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.id, id)).get();
|
|
if (!updated) {
|
|
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 });
|
|
}
|
|
|
|
const updatedData = { ...rowToCategory(updated), isPrivate: isCategoryPrivate(id, category.spaceId) };
|
|
connectionManager.sendToSpace(category.spaceId, {
|
|
type: 'category_updated',
|
|
category: updatedData,
|
|
spaceId: category.spaceId,
|
|
});
|
|
|
|
return reply.code(200).send(updatedData);
|
|
});
|
|
|
|
// DELETE /api/categories/:id - Delete a category
|
|
app.delete<{ Params: { id: string } }>('/api/categories/:id', {
|
|
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 (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
const spaceId = category.spaceId;
|
|
|
|
db.transaction((tx) => {
|
|
// Null out categoryId on all channels in this category
|
|
tx.update(schema.channels).set({ categoryId: null })
|
|
.where(eq(schema.channels.categoryId, id)).run();
|
|
// Delete the category
|
|
tx.delete(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.id, id)).run();
|
|
});
|
|
|
|
// Broadcast category deletion
|
|
connectionManager.sendToSpace(spaceId, {
|
|
type: 'category_deleted',
|
|
categoryId: id,
|
|
spaceId,
|
|
});
|
|
|
|
// Also broadcast updated layout so channels reflect null categoryId
|
|
broadcastChannelLayout(spaceId);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories
|
|
app.patch<{
|
|
Params: { id: string };
|
|
Body: {
|
|
channels: Array<{ id: string; position: number; categoryId: string | null }>;
|
|
categories: Array<{ id: string; position: number }>;
|
|
};
|
|
}>('/api/spaces/:id/channel-layout', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { channels: channelUpdates, categories: categoryUpdates } = request.body;
|
|
const db = getDb();
|
|
|
|
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!space) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
|
}
|
|
|
|
if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) {
|
|
return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 });
|
|
}
|
|
|
|
// Validate all channel IDs belong to this space
|
|
const spaceChannels = db.select().from(schema.channels)
|
|
.where(eq(schema.channels.spaceId, id)).all();
|
|
const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id));
|
|
for (const ch of channelUpdates) {
|
|
if (!spaceChannelIds.has(ch.id)) {
|
|
return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 });
|
|
}
|
|
if (typeof ch.position !== 'number' || ch.position < 0) {
|
|
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
// Validate all category IDs belong to this space
|
|
const spaceCategories = db.select().from(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.spaceId, id)).all();
|
|
const spaceCategoryIds = new Set(spaceCategories.map(c => c.id));
|
|
for (const cat of categoryUpdates) {
|
|
if (!spaceCategoryIds.has(cat.id)) {
|
|
return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 });
|
|
}
|
|
if (typeof cat.position !== 'number' || cat.position < 0) {
|
|
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
// Validate category references in channels
|
|
for (const ch of channelUpdates) {
|
|
if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) {
|
|
return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
// Apply all updates in a transaction
|
|
db.transaction((tx) => {
|
|
for (const ch of channelUpdates) {
|
|
tx.update(schema.channels)
|
|
.set({ position: ch.position, categoryId: ch.categoryId })
|
|
.where(eq(schema.channels.id, ch.id))
|
|
.run();
|
|
}
|
|
for (const cat of categoryUpdates) {
|
|
tx.update(schema.channelCategories)
|
|
.set({ position: cat.position })
|
|
.where(eq(schema.channelCategories.id, cat.id))
|
|
.run();
|
|
}
|
|
});
|
|
|
|
// Broadcast the updated layout to all space members with per-user channel filtering
|
|
broadcastChannelLayout(id);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Broadcast updated channel layout to all space members.
|
|
* Each user gets only the channels they can view (VIEW_CHANNEL check).
|
|
*/
|
|
function broadcastChannelLayout(spaceId: string): void {
|
|
const db = getDb();
|
|
const allChannels = db.select().from(schema.channels)
|
|
.where(eq(schema.channels.spaceId, spaceId)).all();
|
|
const allCategories = db.select().from(schema.channelCategories)
|
|
.where(eq(schema.channelCategories.spaceId, spaceId)).all();
|
|
|
|
const categoryData = allCategories.map(c => ({
|
|
...rowToCategory(c),
|
|
isPrivate: isCategoryPrivate(c.id, spaceId),
|
|
}));
|
|
|
|
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
|
if (!spaceIds.has(spaceId)) continue;
|
|
|
|
const visibleChannels: Channel[] = [];
|
|
for (const ch of allChannels) {
|
|
const perms = computePermissions(userId, spaceId, ch.id);
|
|
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
|
visibleChannels.push({
|
|
...rowToChannel(ch),
|
|
isPrivate: isChannelPrivate(ch.id, spaceId),
|
|
myPermissions: permissionsToString(perms),
|
|
});
|
|
}
|
|
}
|
|
|
|
connectionManager.sendToUser(userId, {
|
|
type: 'channel_layout_updated',
|
|
spaceId,
|
|
channels: visibleChannels,
|
|
categories: categoryData,
|
|
});
|
|
}
|
|
}
|