POST /api/dm/space-invite was hanging 5s and returning invite_invalid for any local-space invite. fetchSpaceInviteSnapshot was being called against our own public domain from inside the backspace container, which fails (Docker NAT loopback) and aborts on timeout. Add getLocalInviteSnapshot — reads the snapshot directly from the DB — and branch in dm.ts so local invites bypass the HTTP roundtrip entirely. Cross-instance invites still go through fetchSpaceInviteSnapshot with its existing SSRF guard. Also refactor the GET /api/spaces/invite/:code/preview handler to use the same helper, keeping the snapshot shape in one place. Tests assert fetchSpaceInviteSnapshot is NOT called for the local case (critical regression guard) and that the cross-instance path still hits the HTTP fetch.
1396 lines
47 KiB
TypeScript
1396 lines
47 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 { 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,
|
|
});
|
|
|
|
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)) {
|
|
return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', 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 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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,
|
|
});
|
|
|
|
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 });
|
|
});
|
|
}
|