545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
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<string>();
|
|
const seenNames = new Set<string>();
|
|
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;
|