454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
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<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) });
|
|
});
|
|
|
|
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;
|