OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one event table rather than two logs that drift apart. The table is deliberately generic (action + target + JSON metadata) so a new action needs no migration. Writes never throw: a kick must not fail because its log entry could not be written, since the kick already happened. Leaving is recorded as a different action from being removed. The same route serves both, and a log that conflates them misleads exactly when it matters. Actor is nullable with ON DELETE SET NULL: the event outlives the account, and a log that vanished with its actor would be worthless. Reads are gated on MANAGE_SPACE rather than a new permission bit, which would default to nobody until every role was re-edited. Paging uses the snowflake id, stable even for two events in the same millisecond, and an action this build does not know still renders a row.
1469 lines
50 KiB
TypeScript
1469 lines
50 KiB
TypeScript
import path from 'path';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { eq, and, inArray } from 'drizzle-orm';
|
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
|
import { authenticate } from '../utils/auth.js';
|
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
|
import { generateSnowflake } from '../utils/snowflake.js';
|
|
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
|
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
|
import crypto from 'crypto';
|
|
import { connectionManager } from '../ws/handler.js';
|
|
import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js';
|
|
import { resizeProfileImage } from '../utils/thumbnail.js';
|
|
import { config } from '../config.js';
|
|
import type {
|
|
CreateSpaceRequest,
|
|
UpdateSpaceRequest,
|
|
JoinSpaceRequest,
|
|
UpdateMemberRequest,
|
|
Space,
|
|
Channel,
|
|
ChannelCategory,
|
|
MemberWithUser,
|
|
SpaceWithChannelsAndMembers,
|
|
Role,
|
|
} from '@backspace/shared';
|
|
import { AVATAR_COLORS } from '@backspace/shared';
|
|
import { sanitizeUser } from '../utils/sanitize.js';
|
|
import { checkVoicePermissions } from '../ws/events.js';
|
|
import { getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js';
|
|
|
|
function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
icon: row.icon,
|
|
banner: row.banner ?? null,
|
|
avatarColor: (row.avatarColor as Space['avatarColor']) ?? null,
|
|
ownerId: row.ownerId,
|
|
inviteCode: row.inviteCode,
|
|
visibility: (row.visibility ?? 'private') as Space['visibility'],
|
|
description: row.description ?? null,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
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 generateInviteCode(): string {
|
|
return crypto.randomBytes(4).toString('hex');
|
|
}
|
|
|
|
export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|
// POST /api/spaces - Create a new server
|
|
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { name, icon, banner, avatarColor, visibility, description } = request.body;
|
|
|
|
if (!name || typeof name !== 'string') {
|
|
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
|
|
}
|
|
|
|
const trimmedName = name.trim();
|
|
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
|
|
}
|
|
|
|
// Validate visibility
|
|
const validVisibilities = ['public', 'request', 'private'];
|
|
const safeVisibility = visibility && validVisibilities.includes(visibility) ? visibility : 'private';
|
|
|
|
// Validate description
|
|
const safeDescription = description ? description.trim().slice(0, 200) || null : null;
|
|
|
|
// Validate avatarColor — assign random if not provided
|
|
const safeAvatarColor = avatarColor && (AVATAR_COLORS as readonly string[]).includes(avatarColor)
|
|
? avatarColor
|
|
: AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
|
|
|
const db = getDb();
|
|
const spaceId = generateSnowflake();
|
|
const textCategoryId = generateSnowflake();
|
|
const voiceCategoryId = generateSnowflake();
|
|
const channelId = generateSnowflake();
|
|
const voiceChannelId = generateSnowflake();
|
|
const now = Date.now();
|
|
const inviteCode = generateInviteCode();
|
|
|
|
// Create server, owner membership, default categories + channels, and @everyone role atomically
|
|
db.transaction((tx) => {
|
|
tx.insert(schema.spaces).values({
|
|
id: spaceId,
|
|
name: trimmedName,
|
|
icon: icon ?? null,
|
|
banner: banner ?? null,
|
|
avatarColor: safeAvatarColor,
|
|
ownerId: request.userId,
|
|
inviteCode,
|
|
visibility: safeVisibility,
|
|
description: safeDescription,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
tx.insert(schema.spaceMembers).values({
|
|
spaceId,
|
|
userId: request.userId,
|
|
joinedAt: now,
|
|
}).run();
|
|
|
|
// Default categories
|
|
tx.insert(schema.channelCategories).values({
|
|
id: textCategoryId,
|
|
spaceId,
|
|
name: 'text-channels',
|
|
position: 0,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
tx.insert(schema.channelCategories).values({
|
|
id: voiceCategoryId,
|
|
spaceId,
|
|
name: 'voice-channels',
|
|
position: 1,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Default text channel in text-channels category
|
|
tx.insert(schema.channels).values({
|
|
id: channelId,
|
|
spaceId,
|
|
name: 'general',
|
|
type: 'text',
|
|
position: 0,
|
|
categoryId: textCategoryId,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Default voice channel in voice-channels category
|
|
tx.insert(schema.channels).values({
|
|
id: voiceChannelId,
|
|
spaceId,
|
|
name: 'voice',
|
|
type: 'voice',
|
|
position: 0,
|
|
categoryId: voiceCategoryId,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Auto-create @everyone role (id = spaceId)
|
|
tx.insert(schema.roles).values({
|
|
id: spaceId,
|
|
spaceId,
|
|
name: '@everyone',
|
|
color: '#b9bbbe',
|
|
position: 0,
|
|
permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS),
|
|
createdAt: now,
|
|
}).run();
|
|
});
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get();
|
|
if (!server) {
|
|
return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 });
|
|
}
|
|
|
|
// Register the creator in connectionManager so they receive WS broadcasts for this space
|
|
connectionManager.addUserSpace(request.userId, spaceId);
|
|
|
|
// Clean up attachment records for icon/banner — reference is now in spaces table
|
|
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(icon);
|
|
}
|
|
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(banner);
|
|
}
|
|
|
|
// Resize profile images to optimal dimensions
|
|
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(icon));
|
|
await resizeProfileImage(filePath, 'icon');
|
|
}
|
|
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(banner));
|
|
await resizeProfileImage(filePath, 'banner');
|
|
}
|
|
|
|
return reply.code(201).send(rowToSpace(server));
|
|
});
|
|
|
|
// GET /api/spaces - List user's servers
|
|
app.get('/api/spaces', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const db = getDb();
|
|
|
|
const memberships = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(eq(schema.spaceMembers.userId, request.userId))
|
|
.all();
|
|
|
|
if (memberships.length === 0) {
|
|
return reply.code(200).send([]);
|
|
}
|
|
|
|
const spaceIds = memberships.map(m => m.spaceId);
|
|
const servers = db.select()
|
|
.from(schema.spaces)
|
|
.where(inArray(schema.spaces.id, spaceIds))
|
|
.all();
|
|
|
|
return reply.code(200).send(servers.map(rowToSpace));
|
|
});
|
|
|
|
// GET /api/spaces/:id - Get server detail with channels and members
|
|
app.get<{ Params: { id: string } }>('/api/spaces/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
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 channels = db.select()
|
|
.from(schema.channels)
|
|
.where(eq(schema.channels.spaceId, id))
|
|
.all();
|
|
|
|
const roles = db.select()
|
|
.from(schema.roles)
|
|
.where(eq(schema.roles.spaceId, id))
|
|
.orderBy(schema.roles.position)
|
|
.all();
|
|
|
|
const memberRows = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(eq(schema.spaceMembers.spaceId, id))
|
|
.all();
|
|
|
|
const memberUserIds = memberRows.map(m => m.userId);
|
|
const users = memberUserIds.length > 0
|
|
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
|
: [];
|
|
|
|
const userMap = new Map(users.map(u => [u.id, u]));
|
|
|
|
const memberRoleRows = db.select()
|
|
.from(schema.memberRoles)
|
|
.where(eq(schema.memberRoles.spaceId, id))
|
|
.all();
|
|
|
|
const members: MemberWithUser[] = memberRows
|
|
.map(m => {
|
|
const user = userMap.get(m.userId);
|
|
if (!user) return null;
|
|
|
|
const assignedRoleIds = memberRoleRows
|
|
.filter(mr => mr.userId === m.userId)
|
|
.map(mr => mr.roleId);
|
|
|
|
const memberRoles = roles
|
|
.filter(r => assignedRoleIds.includes(r.id))
|
|
.map(r => ({
|
|
id: r.id,
|
|
spaceId: r.spaceId,
|
|
name: r.name,
|
|
color: r.color ?? '#b9bbbe',
|
|
position: r.position ?? 0,
|
|
createdAt: r.createdAt,
|
|
}));
|
|
|
|
return {
|
|
spaceId: m.spaceId,
|
|
userId: m.userId,
|
|
nickname: m.nickname,
|
|
joinedAt: m.joinedAt,
|
|
user: sanitizeUser(user),
|
|
roles: memberRoles,
|
|
};
|
|
})
|
|
.filter((m): m is MemberWithUser => m !== null);
|
|
|
|
// Fetch categories for this space
|
|
const categoryRows = db.select()
|
|
.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<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 => ({
|
|
id: c.id,
|
|
spaceId: c.spaceId,
|
|
name: c.name,
|
|
position: c.position ?? 0,
|
|
isPrivate: privateCategoryIds.has(c.id),
|
|
createdAt: c.createdAt,
|
|
}));
|
|
|
|
// 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 & { 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),
|
|
});
|
|
}
|
|
}
|
|
|
|
const canManageRoles = (spacePerms & PermissionBits.MANAGE_ROLES) !== 0n;
|
|
|
|
const result: SpaceWithChannelsAndMembers = {
|
|
...rowToSpace(server),
|
|
channels: visibleChannels,
|
|
categories,
|
|
members,
|
|
roles: roles.map(r => ({
|
|
id: r.id,
|
|
spaceId: r.spaceId,
|
|
name: r.name,
|
|
color: r.color ?? '#b9bbbe',
|
|
position: r.position ?? 0,
|
|
permissions: canManageRoles ? (r.permissions ?? '0') : undefined,
|
|
createdAt: r.createdAt,
|
|
})),
|
|
myPermissions: permissionsToString(spacePerms),
|
|
};
|
|
|
|
return reply.code(200).send(result);
|
|
});
|
|
|
|
// PATCH /api/spaces/:id - Update server (owner only)
|
|
app.patch<{ Params: { id: string }; Body: UpdateSpaceRequest }>('/api/spaces/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name, icon, banner, avatarColor, visibility, description } = request.body;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
|
}
|
|
|
|
const updates: Partial<typeof schema.spaces.$inferInsert> = {};
|
|
|
|
if (name !== undefined) {
|
|
const trimmedName = name.trim();
|
|
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
|
return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 });
|
|
}
|
|
updates.name = trimmedName;
|
|
}
|
|
|
|
// Track old files for cleanup after update
|
|
const oldIcon = server.icon;
|
|
const oldBanner = server.banner;
|
|
|
|
if (icon !== undefined) {
|
|
updates.icon = icon || null;
|
|
}
|
|
|
|
if (banner !== undefined) {
|
|
updates.banner = banner || null;
|
|
}
|
|
|
|
if (avatarColor !== undefined) {
|
|
if (avatarColor === '') {
|
|
updates.avatarColor = null;
|
|
} else if ((AVATAR_COLORS as readonly string[]).includes(avatarColor)) {
|
|
updates.avatarColor = avatarColor;
|
|
} else {
|
|
return reply.code(400).send({ error: 'Invalid avatar color', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
if (visibility !== undefined) {
|
|
const validVisibilities = ['public', 'request', 'private'];
|
|
if (!validVisibilities.includes(visibility)) {
|
|
return reply.code(400).send({ error: 'Visibility must be "public", "request", or "private"', statusCode: 400 });
|
|
}
|
|
updates.visibility = visibility;
|
|
}
|
|
|
|
if (description !== undefined) {
|
|
const trimmed = description.trim().slice(0, 200);
|
|
updates.description = trimmed || null;
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
|
}
|
|
|
|
db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run();
|
|
|
|
// Clean up old icon/banner files that were replaced
|
|
if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) {
|
|
deleteUploadFile(oldIcon);
|
|
deleteAttachmentByFilename(oldIcon);
|
|
}
|
|
if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) {
|
|
deleteUploadFile(oldBanner);
|
|
deleteAttachmentByFilename(oldBanner);
|
|
}
|
|
// Clean up attachment records for newly-set profile images — the reference
|
|
// now lives in the spaces table, so the attachment record is unnecessary
|
|
if (icon && typeof icon === 'string' && icon.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(icon);
|
|
}
|
|
if (banner && typeof banner === 'string' && banner.includes('/api/uploads/')) {
|
|
deleteAttachmentByFilename(banner);
|
|
}
|
|
|
|
// Resize profile images to optimal dimensions
|
|
if (icon && typeof icon === 'string' && !icon.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(icon));
|
|
await resizeProfileImage(filePath, 'icon');
|
|
}
|
|
if (banner && typeof banner === 'string' && !banner.startsWith('http')) {
|
|
const filePath = path.join(config.uploadDir, path.basename(banner));
|
|
await resizeProfileImage(filePath, 'banner');
|
|
}
|
|
|
|
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!updated) {
|
|
return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 });
|
|
}
|
|
|
|
const spaceData = rowToSpace(updated);
|
|
|
|
// Broadcast space_updated to all space members
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'space_updated',
|
|
space: spaceData,
|
|
});
|
|
|
|
recordAuditEvent({
|
|
spaceId: id,
|
|
actorId: request.userId,
|
|
action: 'space.update',
|
|
targetType: 'space',
|
|
targetId: id,
|
|
metadata: { fields: Object.keys(updates) },
|
|
});
|
|
|
|
return reply.code(200).send(spaceData);
|
|
});
|
|
|
|
// DELETE /api/spaces/:id - Delete server (owner only)
|
|
app.delete<{ Params: { id: string } }>('/api/spaces/:id', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!isSpaceOwner(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 });
|
|
}
|
|
|
|
// Collect all attachment files before cascade-deleting DB records
|
|
const channelIds = db.select({ id: schema.channels.id })
|
|
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
|
|
|
|
let attachmentRows: { filename: string }[] = [];
|
|
if (channelIds.length > 0) {
|
|
const messageIds = db.select({ id: schema.messages.id })
|
|
.from(schema.messages).where(inArray(schema.messages.channelId, channelIds)).all().map(m => m.id);
|
|
if (messageIds.length > 0) {
|
|
attachmentRows = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments).where(inArray(schema.attachments.messageId, messageIds)).all();
|
|
}
|
|
}
|
|
|
|
// Capture space icon/banner before deletion
|
|
const spaceIcon = server.icon;
|
|
const spaceBanner = server.banner;
|
|
|
|
// Delete all channels (messages cascade), members, folder refs, read states, then space atomically
|
|
db.transaction((tx) => {
|
|
// Clean up read_states for all channels in this space (no FK cascade — channelId is plain text)
|
|
if (channelIds.length > 0) {
|
|
tx.delete(schema.readStates).where(inArray(schema.readStates.channelId, channelIds)).run();
|
|
}
|
|
tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run();
|
|
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run();
|
|
tx.delete(schema.spaceFolderMembers).where(eq(schema.spaceFolderMembers.spaceId, id)).run();
|
|
tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run();
|
|
});
|
|
|
|
// Clean up all attachment files from disk
|
|
deleteAttachmentFiles(attachmentRows);
|
|
|
|
// Clean up space icon/banner files
|
|
if (spaceIcon && !spaceIcon.startsWith('http')) deleteUploadFile(spaceIcon);
|
|
if (spaceBanner && !spaceBanner.startsWith('http')) deleteUploadFile(spaceBanner);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// POST /api/spaces/:id/invite - Generate invite code (admin+)
|
|
app.post<{ Params: { id: string } }>('/api/spaces/:id/invite', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.CREATE_INVITE)) {
|
|
// Owners and instance admins always pass hasPermission, so anyone who lands
|
|
// here is either a non-member or a member without CREATE_INVITE. Give the
|
|
// non-member a clearer "go join first" message instead of a permission error.
|
|
if (!isMember(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'Space membership required', statusCode: 403 });
|
|
}
|
|
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 });
|
|
}
|
|
|
|
// Request-only spaces are approval-gated and never joinable by invite code
|
|
// (see the join endpoints), so they have no usable invite links. Refuse to
|
|
// hand one out rather than mint a code that would dead-end at the join guard.
|
|
if (server.visibility === 'request') {
|
|
return reply.code(403).send({ error: 'Request-only spaces do not use invite links; entry is by join request', statusCode: 403 });
|
|
}
|
|
|
|
// Return existing invite code if one exists, otherwise generate a new one
|
|
if (server.inviteCode) {
|
|
return reply.code(200).send({ inviteCode: server.inviteCode });
|
|
}
|
|
|
|
const inviteCode = generateInviteCode();
|
|
db.update(schema.spaces).set({ inviteCode }).where(eq(schema.spaces.id, id)).run();
|
|
|
|
return reply.code(200).send({ inviteCode });
|
|
});
|
|
|
|
// POST /api/spaces/:id/join - Join server by invite code
|
|
app.post<{ Params: { id: string }; Body: JoinSpaceRequest }>('/api/spaces/:id/join', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { inviteCode } = request.body;
|
|
|
|
if (!inviteCode || typeof inviteCode !== 'string') {
|
|
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (server.inviteCode !== inviteCode) {
|
|
return reply.code(400).send({ error: 'Invalid invite code', statusCode: 400 });
|
|
}
|
|
|
|
if (isBanned(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
|
}
|
|
|
|
if (isMember(id, request.userId)) {
|
|
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
|
}
|
|
|
|
// Request-only spaces are gated by manager approval: entry must go through
|
|
// POST /request-join + approval, never a bearer invite code. (Private spaces
|
|
// remain invite-joinable — that is their only entry path; public too.)
|
|
if (server.visibility === 'request') {
|
|
return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 });
|
|
}
|
|
|
|
const now = Date.now();
|
|
db.insert(schema.spaceMembers).values({
|
|
spaceId: id,
|
|
userId: request.userId,
|
|
joinedAt: now,
|
|
}).run();
|
|
|
|
// Register the user in connectionManager so they receive WS broadcasts for this server
|
|
connectionManager.addUserSpace(request.userId, id);
|
|
|
|
// Broadcast member_joined to existing server members
|
|
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (joiningUser) {
|
|
const memberPayload: MemberWithUser = {
|
|
spaceId: id,
|
|
userId: request.userId,
|
|
nickname: null,
|
|
joinedAt: now,
|
|
user: sanitizeUser(joiningUser),
|
|
roles: [],
|
|
};
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'member_joined',
|
|
spaceId: id,
|
|
member: memberPayload,
|
|
});
|
|
}
|
|
|
|
return reply.code(200).send(rowToSpace(server));
|
|
});
|
|
|
|
// POST /api/spaces/join - Join server by invite code (no server ID needed)
|
|
app.post<{ Body: JoinSpaceRequest }>('/api/spaces/join', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { inviteCode } = request.body;
|
|
|
|
if (!inviteCode || typeof inviteCode !== 'string') {
|
|
return reply.code(400).send({ error: 'Invite code is required', statusCode: 400 });
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, inviteCode)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
|
|
}
|
|
|
|
if (isBanned(server.id, request.userId)) {
|
|
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
|
}
|
|
|
|
if (isMember(server.id, request.userId)) {
|
|
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
|
}
|
|
|
|
// Request-only spaces are gated by manager approval (see POST /:id/join).
|
|
if (server.visibility === 'request') {
|
|
return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 });
|
|
}
|
|
|
|
const now = Date.now();
|
|
db.insert(schema.spaceMembers).values({
|
|
spaceId: server.id,
|
|
userId: request.userId,
|
|
joinedAt: now,
|
|
}).run();
|
|
|
|
// Register the user in connectionManager so they receive WS broadcasts for this server
|
|
connectionManager.addUserSpace(request.userId, server.id);
|
|
|
|
// Broadcast member_joined to existing server members
|
|
const joiningUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
|
if (joiningUser) {
|
|
const memberPayload: MemberWithUser = {
|
|
spaceId: server.id,
|
|
userId: request.userId,
|
|
nickname: null,
|
|
joinedAt: now,
|
|
user: sanitizeUser(joiningUser),
|
|
roles: [],
|
|
};
|
|
connectionManager.sendToSpace(server.id, {
|
|
type: 'member_joined',
|
|
spaceId: server.id,
|
|
member: memberPayload,
|
|
});
|
|
}
|
|
|
|
return reply.code(200).send(rowToSpace(server));
|
|
});
|
|
|
|
// GET /api/spaces/:id/members - List server members
|
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/members', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
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 memberRows = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(eq(schema.spaceMembers.spaceId, id))
|
|
.all();
|
|
|
|
const memberUserIds = memberRows.map(m => m.userId);
|
|
const users = memberUserIds.length > 0
|
|
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
|
: [];
|
|
|
|
const userMap = new Map(users.map(u => [u.id, u]));
|
|
|
|
const roles = db.select()
|
|
.from(schema.roles)
|
|
.where(eq(schema.roles.spaceId, id))
|
|
.orderBy(schema.roles.position)
|
|
.all();
|
|
|
|
const memberRoleRows = db.select()
|
|
.from(schema.memberRoles)
|
|
.where(eq(schema.memberRoles.spaceId, id))
|
|
.all();
|
|
|
|
const members: MemberWithUser[] = memberRows
|
|
.map(m => {
|
|
const user = userMap.get(m.userId);
|
|
if (!user) return null;
|
|
|
|
const assignedRoleIds = memberRoleRows
|
|
.filter(mr => mr.userId === m.userId)
|
|
.map(mr => mr.roleId);
|
|
|
|
const assignedRoles = roles
|
|
.filter(r => assignedRoleIds.includes(r.id))
|
|
.map(r => ({
|
|
id: r.id,
|
|
spaceId: r.spaceId,
|
|
name: r.name,
|
|
color: r.color ?? '#b9bbbe',
|
|
position: r.position ?? 0,
|
|
createdAt: r.createdAt,
|
|
}));
|
|
|
|
return {
|
|
spaceId: m.spaceId,
|
|
userId: m.userId,
|
|
nickname: m.nickname,
|
|
joinedAt: m.joinedAt,
|
|
user: sanitizeUser(user),
|
|
roles: assignedRoles,
|
|
};
|
|
})
|
|
.filter((m): m is MemberWithUser => m !== null);
|
|
|
|
return reply.code(200).send(members);
|
|
});
|
|
|
|
// PATCH /api/spaces/:id/members/:uid - Update member roles
|
|
app.patch<{ Params: { id: string; uid: string }; Body: UpdateMemberRequest }>('/api/spaces/:id/members/:uid', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, uid } = request.params;
|
|
const { roleIds } = request.body;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
if (uid === request.userId) {
|
|
return reply.code(400).send({ error: 'You cannot change your own roles', statusCode: 400 });
|
|
}
|
|
|
|
if (!Array.isArray(roleIds)) {
|
|
return reply.code(400).send({ error: 'roleIds must be an array of role IDs', statusCode: 400 });
|
|
}
|
|
|
|
// Cannot modify the server owner's roles unless you are the owner
|
|
if (isSpaceOwner(id, uid) && !isSpaceOwner(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'Only the space owner can modify their own roles', statusCode: 403 });
|
|
}
|
|
|
|
const member = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(and(
|
|
eq(schema.spaceMembers.spaceId, id),
|
|
eq(schema.spaceMembers.userId, uid),
|
|
))
|
|
.get();
|
|
|
|
if (!member) {
|
|
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
|
|
}
|
|
|
|
// Validate all roleIds belong to this server and are not @everyone
|
|
if (roleIds.length > 0) {
|
|
const spaceRoles = db.select()
|
|
.from(schema.roles)
|
|
.where(eq(schema.roles.spaceId, id))
|
|
.all();
|
|
|
|
const spaceRoleIds = new Set(spaceRoles.map(r => r.id));
|
|
|
|
for (const roleId of roleIds) {
|
|
if (!spaceRoleIds.has(roleId)) {
|
|
return reply.code(400).send({ error: `Role ${roleId} does not belong to this space`, statusCode: 400 });
|
|
}
|
|
if (roleId === id) {
|
|
return reply.code(400).send({ error: '@everyone role is implicit and cannot be assigned', statusCode: 400 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Atomically replace member's role assignments
|
|
db.transaction((tx) => {
|
|
// Remove all existing role assignments for this member in this server
|
|
tx.delete(schema.memberRoles)
|
|
.where(and(
|
|
eq(schema.memberRoles.spaceId, id),
|
|
eq(schema.memberRoles.userId, uid),
|
|
))
|
|
.run();
|
|
|
|
// Insert new role assignments
|
|
for (const roleId of roleIds) {
|
|
tx.insert(schema.memberRoles).values({
|
|
spaceId: id,
|
|
userId: uid,
|
|
roleId,
|
|
}).run();
|
|
}
|
|
});
|
|
|
|
// Force target user's client to re-sync with their new permissions
|
|
connectionManager.pushReadyPayload(uid);
|
|
checkVoicePermissions(id);
|
|
|
|
// Build response with populated roles
|
|
const updatedMember = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(and(
|
|
eq(schema.spaceMembers.spaceId, id),
|
|
eq(schema.spaceMembers.userId, uid),
|
|
))
|
|
.get();
|
|
|
|
if (!updatedMember) {
|
|
return reply.code(500).send({ error: 'Failed to update member', statusCode: 500 });
|
|
}
|
|
|
|
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
|
|
if (!user) {
|
|
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
|
|
}
|
|
|
|
const updatedRoleRows = db.select()
|
|
.from(schema.memberRoles)
|
|
.where(and(
|
|
eq(schema.memberRoles.spaceId, id),
|
|
eq(schema.memberRoles.userId, uid),
|
|
))
|
|
.all();
|
|
|
|
const updatedRoleIds = updatedRoleRows.map(r => r.roleId);
|
|
const allRoles = db.select()
|
|
.from(schema.roles)
|
|
.where(eq(schema.roles.spaceId, id))
|
|
.orderBy(schema.roles.position)
|
|
.all();
|
|
|
|
const memberRoles = allRoles
|
|
.filter(r => updatedRoleIds.includes(r.id))
|
|
.map(r => ({
|
|
id: r.id,
|
|
spaceId: r.spaceId,
|
|
name: r.name,
|
|
color: r.color ?? '#b9bbbe',
|
|
position: r.position ?? 0,
|
|
createdAt: r.createdAt,
|
|
}));
|
|
|
|
const result: MemberWithUser = {
|
|
spaceId: updatedMember.spaceId,
|
|
userId: updatedMember.userId,
|
|
nickname: updatedMember.nickname,
|
|
joinedAt: updatedMember.joinedAt,
|
|
user: sanitizeUser(user),
|
|
roles: memberRoles,
|
|
};
|
|
|
|
return reply.code(200).send(result);
|
|
});
|
|
|
|
// DELETE /api/spaces/:id/members/:uid - Kick member (owner) or leave (self)
|
|
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/members/:uid', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, uid } = request.params;
|
|
const db = getDb();
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
const isSelf = uid === request.userId;
|
|
const isOwnerUser = isSpaceOwner(id, request.userId);
|
|
const canKick = hasPermission(request.userId, id, PermissionBits.KICK_MEMBERS);
|
|
|
|
if (!isSelf && !canKick) {
|
|
return reply.code(403).send({ error: 'Missing KICK_MEMBERS permission', statusCode: 403 });
|
|
}
|
|
|
|
// Owner cannot leave their own server - they must delete it
|
|
if (isSelf && isOwnerUser) {
|
|
return reply.code(400).send({ error: 'Space owner cannot leave. Transfer ownership or delete the space.', statusCode: 400 });
|
|
}
|
|
|
|
const member = db.select()
|
|
.from(schema.spaceMembers)
|
|
.where(and(
|
|
eq(schema.spaceMembers.spaceId, id),
|
|
eq(schema.spaceMembers.userId, uid),
|
|
))
|
|
.get();
|
|
|
|
if (!member) {
|
|
return reply.code(404).send({ error: 'Member not found', statusCode: 404 });
|
|
}
|
|
|
|
// Cannot kick the owner
|
|
if (isSpaceOwner(id, uid)) {
|
|
return reply.code(400).send({ error: 'Cannot remove the space owner', statusCode: 400 });
|
|
}
|
|
|
|
db.delete(schema.spaceMembers)
|
|
.where(and(
|
|
eq(schema.spaceMembers.spaceId, id),
|
|
eq(schema.spaceMembers.userId, uid),
|
|
))
|
|
.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();
|
|
|
|
// Clean up read_states for the departing user in this space's channels
|
|
const spaceChannelIds = db.select({ id: schema.channels.id })
|
|
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
|
|
if (spaceChannelIds.length > 0) {
|
|
db.delete(schema.readStates).where(and(
|
|
eq(schema.readStates.userId, uid),
|
|
inArray(schema.readStates.channelId, spaceChannelIds),
|
|
)).run();
|
|
}
|
|
|
|
// Broadcast member_left event
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'member_left',
|
|
spaceId: id,
|
|
userId: uid,
|
|
});
|
|
|
|
recordAuditEvent({
|
|
spaceId: id,
|
|
actorId: request.userId,
|
|
// Leaving on your own is not the same event as being removed by someone
|
|
// else, and a log that conflates the two misleads exactly when it matters.
|
|
action: request.userId === uid ? 'member.leave' : 'member.kick',
|
|
targetType: 'user',
|
|
targetId: uid,
|
|
});
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// Role Management
|
|
|
|
// POST /api/spaces/:id/roles - Create a new role
|
|
app.post<{ Params: { id: string }; Body: { name: string; color?: string; permissions?: string } }>('/api/spaces/:id/roles', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { name, color, permissions } = request.body;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
// Validate permissions string is a valid bigint if provided
|
|
let permStr: string;
|
|
if (permissions !== undefined && permissions !== null) {
|
|
try {
|
|
BigInt(permissions);
|
|
permStr = permissions;
|
|
} catch {
|
|
return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
|
|
}
|
|
} else {
|
|
// Default to @everyone baseline so new roles start functional
|
|
permStr = permissionsToString(DEFAULT_EVERYONE_PERMISSIONS);
|
|
}
|
|
|
|
// Trim and validate name
|
|
const roleName = (name || 'new role').trim() || 'new role';
|
|
|
|
// Check for case-insensitive duplicate name within the space
|
|
const rawDb = getRawDb();
|
|
const duplicate = rawDb.prepare(
|
|
'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ?'
|
|
).get(id, roleName);
|
|
if (duplicate) {
|
|
return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 });
|
|
}
|
|
|
|
const roleId = generateSnowflake();
|
|
db.insert(schema.roles).values({
|
|
id: roleId,
|
|
spaceId: id,
|
|
name: roleName,
|
|
color: color || '#b9bbbe',
|
|
position: 0,
|
|
permissions: permStr,
|
|
createdAt: Date.now(),
|
|
}).run();
|
|
|
|
const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
|
|
|
// Broadcast updated state to all space members
|
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
|
for (const m of memberRows) {
|
|
connectionManager.pushReadyPayload(m.userId);
|
|
}
|
|
checkVoicePermissions(id);
|
|
|
|
recordAuditEvent({
|
|
spaceId: id,
|
|
actorId: request.userId,
|
|
action: 'role.create',
|
|
targetType: 'role',
|
|
// roleId is the value just inserted; `role` is a read-back the compiler
|
|
// cannot prove returned a row.
|
|
targetId: roleId,
|
|
metadata: { name: role?.name ?? null },
|
|
});
|
|
|
|
return reply.code(201).send(role);
|
|
});
|
|
|
|
// PATCH /api/spaces/:id/roles/:roleId - Update a role
|
|
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number; permissions?: string } }>('/api/spaces/:id/roles/:roleId', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, roleId } = request.params;
|
|
const { name, color, position, permissions } = request.body;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
const updates: Partial<typeof schema.roles.$inferInsert> = {};
|
|
if (name !== undefined) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
return reply.code(400).send({ error: 'Role name cannot be empty', statusCode: 400 });
|
|
}
|
|
// Check for case-insensitive duplicate name within the space
|
|
const rawDb = getRawDb();
|
|
const duplicate = rawDb.prepare(
|
|
'SELECT id FROM roles WHERE space_id = ? AND name COLLATE NOCASE = ? AND id != ?'
|
|
).get(id, trimmed, roleId);
|
|
if (duplicate) {
|
|
return reply.code(409).send({ error: 'A role with this name already exists', statusCode: 409 });
|
|
}
|
|
updates.name = trimmed;
|
|
}
|
|
if (color !== undefined) updates.color = color;
|
|
if (position !== undefined) updates.position = position;
|
|
|
|
if (permissions !== undefined) {
|
|
try {
|
|
BigInt(permissions);
|
|
updates.permissions = permissions;
|
|
} catch {
|
|
return reply.code(400).send({ error: 'Invalid permissions value', statusCode: 400 });
|
|
}
|
|
}
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
|
}
|
|
|
|
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
|
const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
|
|
|
|
// Broadcast updated state to all space members
|
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
|
for (const m of memberRows) {
|
|
connectionManager.pushReadyPayload(m.userId);
|
|
}
|
|
checkVoicePermissions(id);
|
|
|
|
recordAuditEvent({
|
|
spaceId: id,
|
|
actorId: request.userId,
|
|
action: 'role.update',
|
|
targetType: 'role',
|
|
targetId: roleId,
|
|
metadata: { name: updated?.name ?? null },
|
|
});
|
|
|
|
return reply.code(200).send(updated);
|
|
});
|
|
|
|
// DELETE /api/spaces/:id/roles/:roleId - Delete a role
|
|
app.delete<{ Params: { id: string; roleId: string } }>('/api/spaces/:id/roles/:roleId', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, roleId } = request.params;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
// Cannot delete @everyone role
|
|
if (roleId === id) {
|
|
return reply.code(400).send({ error: 'Cannot delete the @everyone role', statusCode: 400 });
|
|
}
|
|
|
|
// Delete channel overrides referencing this role
|
|
db.delete(schema.channelOverrides).where(
|
|
and(eq(schema.channelOverrides.targetType, 'role'), eq(schema.channelOverrides.targetId, roleId))
|
|
).run();
|
|
|
|
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.spaceId, id))).run();
|
|
|
|
// Broadcast updated state to all space members
|
|
const memberRows = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).all();
|
|
for (const m of memberRows) {
|
|
connectionManager.pushReadyPayload(m.userId);
|
|
}
|
|
checkVoicePermissions(id);
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// POST /api/spaces/:id/members/:uid/roles - Add role to member
|
|
app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/spaces/:id/members/:uid/roles', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, uid } = request.params;
|
|
const { roleId } = request.body;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
db.insert(schema.memberRoles).values({
|
|
spaceId: id,
|
|
userId: uid,
|
|
roleId,
|
|
}).run();
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// DELETE /api/spaces/:id/members/:uid/roles/:roleId - Remove role from member
|
|
app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/spaces/:id/members/:uid/roles/:roleId', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, uid, roleId } = request.params;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_ROLES)) {
|
|
return reply.code(403).send({ error: 'Missing MANAGE_ROLES permission', statusCode: 403 });
|
|
}
|
|
|
|
db.delete(schema.memberRoles).where(and(
|
|
eq(schema.memberRoles.spaceId, id),
|
|
eq(schema.memberRoles.userId, uid),
|
|
eq(schema.memberRoles.roleId, roleId)
|
|
)).run();
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// PATCH /api/spaces/:id/transfer-ownership — Transfer space ownership
|
|
app.patch<{ Params: { id: string }; Body: { newOwnerId: string } }>('/api/spaces/:id/transfer-ownership', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { newOwnerId } = request.body;
|
|
const db = getDb();
|
|
|
|
if (!newOwnerId || typeof newOwnerId !== 'string') {
|
|
return reply.code(400).send({ error: 'newOwnerId is required', statusCode: 400 });
|
|
}
|
|
|
|
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!server) {
|
|
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
|
}
|
|
|
|
if (!isSpaceOwner(id, request.userId)) {
|
|
return reply.code(403).send({ error: 'Only the space owner can transfer ownership', statusCode: 403 });
|
|
}
|
|
|
|
if (newOwnerId === request.userId) {
|
|
return reply.code(400).send({ error: 'You are already the owner', statusCode: 400 });
|
|
}
|
|
|
|
// Verify new owner is a member
|
|
if (!isMember(id, newOwnerId)) {
|
|
return reply.code(400).send({ error: 'New owner must be a member of the space', statusCode: 400 });
|
|
}
|
|
|
|
db.update(schema.spaces).set({ ownerId: newOwnerId }).where(eq(schema.spaces.id, id)).run();
|
|
|
|
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
|
if (!updated) {
|
|
return reply.code(500).send({ error: 'Failed to transfer ownership', statusCode: 500 });
|
|
}
|
|
|
|
const spaceData = rowToSpace(updated);
|
|
|
|
// Broadcast space_updated so all clients see the new owner
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'space_updated',
|
|
space: spaceData,
|
|
});
|
|
|
|
recordAuditEvent({
|
|
spaceId: id,
|
|
actorId: request.userId,
|
|
action: 'space.transfer_ownership',
|
|
targetType: 'user',
|
|
targetId: newOwnerId,
|
|
});
|
|
|
|
return reply.code(200).send(spaceData);
|
|
});
|
|
|
|
// GET /api/spaces/invite/:code/preview — Public invite preview (no auth)
|
|
app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => {
|
|
const { code } = request.params;
|
|
const snapshot = getLocalInviteSnapshot(code);
|
|
if (!snapshot) {
|
|
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
|
|
}
|
|
return reply.code(200).send(snapshot);
|
|
});
|
|
|
|
// ─── Ban Management ───────────────────────────────────────────────────────
|
|
|
|
// GET /api/spaces/:id/bans - List bans
|
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/bans', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
|
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
|
}
|
|
|
|
const banRows = db.select().from(schema.bans)
|
|
.where(eq(schema.bans.spaceId, id))
|
|
.all();
|
|
|
|
if (banRows.length === 0) return reply.code(200).send([]);
|
|
|
|
const userIds = [...new Set(banRows.map(b => b.userId))];
|
|
const bannedByIds = [...new Set(banRows.map(b => b.bannedBy))];
|
|
const allUserIds = [...new Set([...userIds, ...bannedByIds].filter((id): id is string => id !== null))];
|
|
const users = allUserIds.length > 0
|
|
? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
|
|
: [];
|
|
const userMap = new Map(users.map(u => [u.id, u]));
|
|
|
|
const bans = banRows.map(b => {
|
|
const user = userMap.get(b.userId);
|
|
const moderator = b.bannedBy ? userMap.get(b.bannedBy) : undefined;
|
|
return {
|
|
spaceId: b.spaceId,
|
|
userId: b.userId,
|
|
reason: b.reason,
|
|
bannedBy: b.bannedBy,
|
|
createdAt: b.createdAt,
|
|
user: user ? sanitizeUser(user) : null,
|
|
moderator: moderator ? sanitizeUser(moderator) : null,
|
|
};
|
|
});
|
|
|
|
return reply.code(200).send(bans);
|
|
});
|
|
|
|
// POST /api/spaces/:id/bans - Ban a member
|
|
app.post<{ Params: { id: string }; Body: { userId: string; reason?: string } }>('/api/spaces/:id/bans', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id } = request.params;
|
|
const { userId: targetId, reason } = request.body;
|
|
const db = getDb();
|
|
|
|
if (!targetId || typeof targetId !== 'string') {
|
|
return reply.code(400).send({ error: 'userId is required', statusCode: 400 });
|
|
}
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
|
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
|
}
|
|
|
|
// Cannot ban the space owner
|
|
if (isSpaceOwner(id, targetId)) {
|
|
return reply.code(400).send({ error: 'Cannot ban the space owner', statusCode: 400 });
|
|
}
|
|
|
|
// Cannot ban yourself
|
|
if (targetId === request.userId) {
|
|
return reply.code(400).send({ error: 'Cannot ban yourself', statusCode: 400 });
|
|
}
|
|
|
|
// Check if already banned
|
|
if (isBanned(id, targetId)) {
|
|
return reply.code(409).send({ error: 'User is already banned', statusCode: 409 });
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Fetch channel IDs before the transaction for read_states cleanup
|
|
const banChannelIds = db.select({ id: schema.channels.id })
|
|
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
|
|
|
|
db.transaction((tx) => {
|
|
// Insert ban record
|
|
tx.insert(schema.bans).values({
|
|
spaceId: id,
|
|
userId: targetId,
|
|
reason: reason?.trim() || null,
|
|
bannedBy: request.userId,
|
|
createdAt: now,
|
|
}).run();
|
|
|
|
// Remove member from space
|
|
tx.delete(schema.spaceMembers).where(and(
|
|
eq(schema.spaceMembers.spaceId, id),
|
|
eq(schema.spaceMembers.userId, targetId),
|
|
)).run();
|
|
|
|
// Remove member's role assignments
|
|
tx.delete(schema.memberRoles).where(and(
|
|
eq(schema.memberRoles.spaceId, id),
|
|
eq(schema.memberRoles.userId, targetId),
|
|
)).run();
|
|
|
|
// Clean up read_states for the banned user in this space's channels
|
|
if (banChannelIds.length > 0) {
|
|
tx.delete(schema.readStates).where(and(
|
|
eq(schema.readStates.userId, targetId),
|
|
inArray(schema.readStates.channelId, banChannelIds),
|
|
)).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
|
|
connectionManager.sendToSpace(id, {
|
|
type: 'member_left',
|
|
spaceId: id,
|
|
userId: targetId,
|
|
});
|
|
|
|
// Notify the banned user
|
|
connectionManager.sendToUser(targetId, {
|
|
type: 'member_banned',
|
|
spaceId: id,
|
|
reason: reason?.trim() || null,
|
|
});
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
|
|
// DELETE /api/spaces/:id/bans/:uid - Unban a user
|
|
app.delete<{ Params: { id: string; uid: string } }>('/api/spaces/:id/bans/:uid', {
|
|
preHandler: authenticate,
|
|
}, async (request, reply) => {
|
|
const { id, uid } = request.params;
|
|
const db = getDb();
|
|
|
|
if (!hasPermission(request.userId, id, PermissionBits.BAN_MEMBERS)) {
|
|
return reply.code(403).send({ error: 'Missing BAN_MEMBERS permission', statusCode: 403 });
|
|
}
|
|
|
|
const result = db.delete(schema.bans).where(and(
|
|
eq(schema.bans.spaceId, id),
|
|
eq(schema.bans.userId, uid),
|
|
)).run();
|
|
|
|
if (result.changes === 0) {
|
|
return reply.code(404).send({ error: 'Ban not found', statusCode: 404 });
|
|
}
|
|
|
|
return reply.code(200).send({ success: true });
|
|
});
|
|
}
|