feat: bans system, voice moderation, and federated space settings fixes
Add ban/unban functionality with BansPanel in space settings, voice moderation context menu (mute/deafen/disconnect), and fix federated space settings panels to use origin-aware API client. Show domain indicators for federated members in MembersPanel.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { AccessToken, TrackSource } from 'livekit-server-sdk';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { getChannelSpaceId, hasPermission, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import { getChannelSpaceId, hasPermission, computePermissions, isDmMember, PermissionBits } from '../utils/permissions.js';
|
||||
import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@backspace/shared';
|
||||
|
||||
export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -18,6 +18,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Determine room name based on channel type
|
||||
let roomName: string;
|
||||
|
||||
// Default: full publish (DM calls always get full permissions)
|
||||
let canSpeak = true;
|
||||
let canStream = true;
|
||||
|
||||
if (dmChannelId && typeof dmChannelId === 'string') {
|
||||
// DM call token
|
||||
if (!isDmMember(dmChannelId, request.userId)) {
|
||||
@@ -33,6 +37,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!hasPermission(request.userId, spaceId, PermissionBits.CONNECT, channelId)) {
|
||||
return reply.code(403).send({ error: 'Missing CONNECT permission', statusCode: 403 });
|
||||
}
|
||||
// Check SPEAK and STREAM permissions for granular token grants
|
||||
const perms = computePermissions(request.userId, spaceId, channelId);
|
||||
canSpeak = (perms & PermissionBits.SPEAK) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
canStream = (perms & PermissionBits.STREAM) !== 0n || (perms & PermissionBits.ADMINISTRATOR) !== 0n;
|
||||
roomName = channelId;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 });
|
||||
@@ -45,10 +53,21 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
ttl: '1h',
|
||||
});
|
||||
|
||||
// Build canPublishSources based on permissions
|
||||
const canPublishSources: TrackSource[] = [];
|
||||
if (canSpeak) {
|
||||
canPublishSources.push(TrackSource.MICROPHONE);
|
||||
canPublishSources.push(TrackSource.CAMERA);
|
||||
}
|
||||
if (canStream) {
|
||||
canPublishSources.push(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
|
||||
}
|
||||
|
||||
token.addGrant({
|
||||
room: roomName,
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canPublish: canSpeak || canStream,
|
||||
canPublishSources,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
});
|
||||
|
||||
@@ -260,6 +260,11 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'Missing SEND_MESSAGES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (attachmentIds && attachmentIds.length > 0 &&
|
||||
!hasPermission(request.userId, spaceId, PermissionBits.ATTACH_FILES, id)) {
|
||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
|
||||
(!attachmentIds || attachmentIds.length === 0)) {
|
||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.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';
|
||||
@@ -411,6 +411,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -463,6 +467,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
@@ -931,4 +939,144 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// ─── 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])];
|
||||
const users = 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 = userMap.get(b.bannedBy);
|
||||
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();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// 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 });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user