import { Response, Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { ServerChannelPayload, ServerPayload } from '../cqrs/types'; import { getAllPublicServers, getServerById, getUserById, upsertServer, deleteServer, getPendingRequestsForServer } from '../cqrs'; import { banServerUser, buildSignalingUrl, createServerInvite, joinServerWithAccess, leaveServerUser, passwordHashForInput, ServerAccessError, kickServerUser, ensureServerMembership, unbanServerUser, countServerMemberships } from '../services/server-access.service'; import { buildAppInviteUrl, buildBrowserInviteUrl, buildInviteUrl, getRequestOrigin } from './invite-utils'; import { canManageServerUpdate, canModerateServerMember, resolveServerPermission } from '../services/server-permissions.service'; const router = Router(); function channelNameKey(type: ServerChannelPayload['type'], name: string): string { return `${type}:${name.toLocaleLowerCase()}`; } function normalizeServerChannels(value: unknown): ServerChannelPayload[] { if (!Array.isArray(value)) { return []; } const seen = new Set(); const seenNames = new Set(); const channels: ServerChannelPayload[] = []; for (const [index, channel] of value.entries()) { if (!channel || typeof channel !== 'object') { continue; } const id = typeof channel.id === 'string' ? channel.id.trim() : ''; const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : ''; const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null; const position = typeof channel.position === 'number' ? channel.position : index; const nameKey = type ? channelNameKey(type, name) : ''; if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) { continue; } seen.add(id); seenNames.add(nameKey); channels.push({ id, name, type, position }); } return channels; } async function enrichServer(server: ServerPayload, sourceUrl?: string) { const owner = await getUserById(server.ownerId); const userCount = await countServerMemberships(server.id); const { passwordHash, ...publicServer } = server; return { ...publicServer, hasPassword: server.hasPassword ?? !!passwordHash, ownerName: owner?.displayName, sourceUrl, currentUsers: userCount, userCount }; } function sendAccessError(error: unknown, res: Response) { if (error instanceof ServerAccessError) { res.status(error.status).json({ error: error.message, errorCode: error.code }); return; } console.error('Unhandled server access error:', error); res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' }); } async function buildInviteResponse(invite: { id: string; createdAt: number; expiresAt: number; createdBy: string; createdByDisplayName: string | null; serverId: string; }, server: ServerPayload, signalOrigin: string) { return { id: invite.id, serverId: invite.serverId, createdAt: invite.createdAt, expiresAt: invite.expiresAt, inviteUrl: buildInviteUrl(signalOrigin, invite.id), browserUrl: buildBrowserInviteUrl(signalOrigin, invite.id), appUrl: buildAppInviteUrl(signalOrigin, invite.id), sourceUrl: signalOrigin, createdBy: invite.createdBy, createdByDisplayName: invite.createdByDisplayName ?? undefined, isExpired: invite.expiresAt <= Date.now(), server: await enrichServer(server, signalOrigin) }; } router.get('/', async (req, res) => { const { q, tags, limit = 20, offset = 0 } = req.query; let results = await getAllPublicServers(); if (q) { const search = String(q).toLowerCase(); results = results.filter(server => server.name.toLowerCase().includes(search) || server.description?.toLowerCase().includes(search) ); } if (tags) { const tagList = String(tags).split(','); results = results.filter(server => tagList.some(tag => server.tags.includes(tag))); } const total = results.length; results = results.slice(Number(offset), Number(offset) + Number(limit)); const enrichedResults = await Promise.all(results.map((server) => enrichServer(server))); res.json({ servers: enrichedResults, total, limit: Number(limit), offset: Number(offset) }); }); router.post('/', async (req, res) => { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, password, tags, channels } = req.body; if (!name || !ownerId || !ownerPublicKey) return res.status(400).json({ error: 'Missing required fields' }); const passwordHash = passwordHashForInput(password); const server: ServerPayload = { id: clientId || uuidv4(), name, description, ownerId, ownerPublicKey, hasPassword: !!passwordHash, passwordHash, isPrivate: isPrivate ?? false, maxUsers: maxUsers ?? 0, currentUsers: 0, tags: tags ?? [], channels: normalizeServerChannels(channels), createdAt: Date.now(), lastSeen: Date.now() }; await upsertServer(server); await ensureServerMembership(server.id, ownerId); res.status(201).json(await enrichServer(server, getRequestOrigin(req))); }); router.put('/:id', async (req, res) => { const { id } = req.params; const { currentOwnerId, actingRole, password, hasPassword: _ignoredHasPassword, passwordHash: _ignoredPasswordHash, channels, ...updates } = req.body; const existing = await getServerById(id); const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId; if (!existing) return res.status(404).json({ error: 'Server not found' }); if (!authenticatedOwnerId) { return res.status(400).json({ error: 'Missing currentOwnerId' }); } if (!canManageServerUpdate(existing, authenticatedOwnerId, { ...updates, channels, password, actingRole })) { return res.status(403).json({ error: 'Not authorized' }); } const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password'); const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels'); const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null); const server: ServerPayload = { ...existing, ...updates, channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels, hasPassword: !!nextPasswordHash, passwordHash: nextPasswordHash, lastSeen: Date.now() }; await upsertServer(server); res.json(await enrichServer(server, getRequestOrigin(req))); }); router.post('/:id/join', async (req, res) => { const { id: serverId } = req.params; const { userId, password, inviteId } = req.body; if (!userId) { return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' }); } try { const result = await joinServerWithAccess({ serverId, userId: String(userId), password: typeof password === 'string' ? password : undefined, inviteId: typeof inviteId === 'string' ? inviteId : undefined }); const origin = getRequestOrigin(req); res.json({ success: true, signalingUrl: buildSignalingUrl(origin), joinedBefore: result.joinedBefore, via: result.via, server: await enrichServer(result.server, origin) }); } catch (error) { sendAccessError(error, res); } }); router.post('/:id/invites', async (req, res) => { const { id: serverId } = req.params; const { requesterUserId, requesterDisplayName } = req.body; if (!requesterUserId) { return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' }); } const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } try { const invite = await createServerInvite( serverId, String(requesterUserId), typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined ); res.status(201).json(await buildInviteResponse(invite, server, getRequestOrigin(req))); } catch (error) { sendAccessError(error, res); } }); router.post('/:id/moderation/kick', async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId } = req.body; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (!targetUserId) { return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); } if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } await kickServerUser(serverId, String(targetUserId)); res.json({ ok: true }); }); router.post('/:id/moderation/ban', async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (!targetUserId) { return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' }); } if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } await banServerUser({ serverId, userId: String(targetUserId), banId: typeof banId === 'string' ? banId : undefined, bannedBy: String(actorUserId || ''), displayName: typeof displayName === 'string' ? displayName : undefined, reason: typeof reason === 'string' ? reason : undefined, expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined }); res.json({ ok: true }); }); router.post('/:id/moderation/unban', async (req, res) => { const { id: serverId } = req.params; const { actorUserId, banId, targetUserId } = req.body; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) { return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' }); } await unbanServerUser({ serverId, banId: typeof banId === 'string' ? banId : undefined, userId: typeof targetUserId === 'string' ? targetUserId : undefined }); res.json({ ok: true }); }); router.post('/:id/leave', async (req, res) => { const { id: serverId } = req.params; const { userId } = req.body; const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (!userId) { return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' }); } await leaveServerUser(serverId, String(userId)); res.json({ ok: true }); }); router.post('/:id/heartbeat', async (req, res) => { const { id } = req.params; const { currentUsers } = req.body; const existing = await getServerById(id); if (!existing) return res.status(404).json({ error: 'Server not found' }); const server: ServerPayload = { ...existing, lastSeen: Date.now(), currentUsers: typeof currentUsers === 'number' ? currentUsers : existing.currentUsers }; await upsertServer(server); res.json({ ok: true }); }); router.delete('/:id', async (req, res) => { const { id } = req.params; const { ownerId } = req.body; const existing = await getServerById(id); if (!existing) return res.status(404).json({ error: 'Server not found' }); if (existing.ownerId !== ownerId) return res.status(403).json({ error: 'Not authorized' }); await deleteServer(id); res.json({ ok: true }); }); router.get('/:id/requests', async (req, res) => { const { id: serverId } = req.params; const { ownerId } = req.query; const server = await getServerById(serverId); if (!server) return res.status(404).json({ error: 'Server not found' }); if (server.ownerId !== ownerId) return res.status(403).json({ error: 'Not authorized' }); const requests = await getPendingRequestsForServer(serverId); res.json({ requests }); }); router.get('/:id', async (req, res) => { const { id } = req.params; const server = await getServerById(id); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } res.json(await enrichServer(server, getRequestOrigin(req))); }); export default router;