import { Response, Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { ServerChannelPayload, ServerPayload } from '../cqrs/types'; import { getAllPublicServers, getFeaturedServers, getTrendingServers, 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'; import { getAuthenticatedUserId, requireAuth, rejectSpoofedUserId } from '../middleware/require-auth'; 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) }); }); function parseDiscoveryLimit(value: unknown, fallback: number): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return Math.min(Math.floor(parsed), 50); } // NOTE: `/featured` and `/trending` must be registered before `/:id`, // otherwise Express resolves them as a server id. router.get('/featured', async (req, res) => { const limit = parseDiscoveryLimit(req.query.limit, 12); const servers = await getFeaturedServers(limit); const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server))); res.json({ servers: enrichedResults, total: enrichedResults.length, limit }); }); router.get('/trending', async (req, res) => { const limit = parseDiscoveryLimit(req.query.limit, 12); const servers = await getTrendingServers(limit); const enrichedResults = await Promise.all(servers.map((server) => enrichServer(server))); res.json({ servers: enrichedResults, total: enrichedResults.length, limit }); }); router.post('/', requireAuth, async (req, res) => { const { id: clientId, name, description, ownerId, ownerPublicKey, isPrivate, maxUsers, password, tags, channels, icon, iconUpdatedAt } = req.body; if (!name || !ownerId || !ownerPublicKey) return res.status(400).json({ error: 'Missing required fields' }); if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) { return; } const authenticatedOwnerId = getAuthenticatedUserId(req); const passwordHash = passwordHashForInput(password); const server: ServerPayload = { id: clientId || uuidv4(), name, description, ownerId: authenticatedOwnerId, ownerPublicKey, hasPassword: !!passwordHash, passwordHash, isPrivate: isPrivate ?? false, maxUsers: maxUsers ?? 0, currentUsers: 0, icon: typeof icon === 'string' ? icon : undefined, iconUpdatedAt: typeof iconUpdatedAt === 'number' ? iconUpdatedAt : undefined, tags: tags ?? [], channels: normalizeServerChannels(channels), createdAt: Date.now(), lastSeen: Date.now() }; await upsertServer(server); await ensureServerMembership(server.id, authenticatedOwnerId); res.status(201).json(await enrichServer(server, getRequestOrigin(req))); }); router.put('/:id', requireAuth, 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 = getAuthenticatedUserId(req); if (!existing) return res.status(404).json({ error: 'Server not found' }); if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) { return res.status(400).json({ error: 'currentOwnerId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } 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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { userId, password, inviteId } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); if (userId && userId !== authenticatedUserId) { return res.status(400).json({ error: 'userId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } try { const result = await joinServerWithAccess({ serverId, userId: authenticatedUserId, 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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { requesterUserId, requesterDisplayName } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); if (requesterUserId && requesterUserId !== authenticatedUserId) { return res.status(400).json({ error: 'requesterUserId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } 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, authenticatedUserId, 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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); 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 (actorUserId && actorUserId !== authenticatedUserId) { return res.status(400).json({ error: 'actorUserId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } if (!canModerateServerMember(server, authenticatedUserId, 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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); 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 (actorUserId && actorUserId !== authenticatedUserId) { return res.status(400).json({ error: 'actorUserId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } if (!canModerateServerMember(server, authenticatedUserId, 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: authenticatedUserId, 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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { actorUserId, banId, targetUserId } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (actorUserId && actorUserId !== authenticatedUserId) { return res.status(400).json({ error: 'actorUserId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } if (!resolveServerPermission(server, authenticatedUserId, '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', requireAuth, async (req, res) => { const { id: serverId } = req.params; const { userId } = req.body; const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) { return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' }); } if (userId && userId !== authenticatedUserId) { return res.status(400).json({ error: 'userId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } await leaveServerUser(serverId, authenticatedUserId); res.json({ ok: true }); }); router.post('/:id/heartbeat', requireAuth, 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', requireAuth, async (req, res) => { const { id } = req.params; const { ownerId } = req.body; const authenticatedOwnerId = getAuthenticatedUserId(req); const existing = await getServerById(id); if (!existing) return res.status(404).json({ error: 'Server not found' }); if (ownerId && ownerId !== authenticatedOwnerId) { return res.status(400).json({ error: 'ownerId must match the authenticated user', errorCode: 'USER_ID_MISMATCH' }); } if (existing.ownerId !== authenticatedOwnerId) return res.status(403).json({ error: 'Not authorized' }); await deleteServer(id); res.json({ ok: true }); }); router.get('/:id/requests', requireAuth, async (req, res) => { const { id: serverId } = req.params; const authenticatedUserId = getAuthenticatedUserId(req); const server = await getServerById(serverId); if (!server) return res.status(404).json({ error: 'Server not found' }); if (!resolveServerPermission(server, authenticatedUserId, 'manageServer')) 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;