424 lines
11 KiB
TypeScript
424 lines
11 KiB
TypeScript
import { Response, Router } from 'express';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { ServerPayload } from '../cqrs/types';
|
|
import {
|
|
getAllPublicServers,
|
|
getServerById,
|
|
getUserById,
|
|
upsertServer,
|
|
deleteServer,
|
|
getPendingRequestsForServer
|
|
} from '../cqrs';
|
|
import {
|
|
banServerUser,
|
|
buildSignalingUrl,
|
|
createServerInvite,
|
|
joinServerWithAccess,
|
|
leaveServerUser,
|
|
passwordHashForInput,
|
|
ServerAccessError,
|
|
kickServerUser,
|
|
ensureServerMembership,
|
|
unbanServerUser
|
|
} from '../services/server-access.service';
|
|
import {
|
|
buildAppInviteUrl,
|
|
buildBrowserInviteUrl,
|
|
buildInviteUrl,
|
|
getRequestOrigin
|
|
} from './invite-utils';
|
|
|
|
const router = Router();
|
|
|
|
function normalizeRole(role: unknown): string | null {
|
|
return typeof role === 'string' ? role.trim().toLowerCase() : null;
|
|
}
|
|
|
|
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
|
return !!role && allowedRoles.includes(role);
|
|
}
|
|
|
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
|
const owner = await getUserById(server.ownerId);
|
|
const { passwordHash, ...publicServer } = server;
|
|
|
|
return {
|
|
...publicServer,
|
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
|
ownerName: owner?.displayName,
|
|
sourceUrl,
|
|
userCount: server.currentUsers
|
|
};
|
|
}
|
|
|
|
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
|
|
} = 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 ?? [],
|
|
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,
|
|
...updates
|
|
} = req.body;
|
|
const existing = await getServerById(id);
|
|
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
|
|
const normalizedRole = normalizeRole(actingRole);
|
|
|
|
if (!existing)
|
|
return res.status(404).json({ error: 'Server not found' });
|
|
|
|
if (
|
|
existing.ownerId !== authenticatedOwnerId &&
|
|
!isAllowedRole(normalizedRole, ['host', 'admin'])
|
|
) {
|
|
return res.status(403).json({ error: 'Not authorized' });
|
|
}
|
|
|
|
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
|
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
|
const server: ServerPayload = {
|
|
...existing,
|
|
...updates,
|
|
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, actorRole, 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 (
|
|
server.ownerId !== actorUserId &&
|
|
!isAllowedRole(normalizeRole(actorRole), [
|
|
'host',
|
|
'admin',
|
|
'moderator'
|
|
])
|
|
) {
|
|
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, actorRole, 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 (
|
|
server.ownerId !== actorUserId &&
|
|
!isAllowedRole(normalizeRole(actorRole), [
|
|
'host',
|
|
'admin',
|
|
'moderator'
|
|
])
|
|
) {
|
|
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, actorRole, 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 (
|
|
server.ownerId !== actorUserId &&
|
|
!isAllowedRole(normalizeRole(actorRole), [
|
|
'host',
|
|
'admin',
|
|
'moderator'
|
|
])
|
|
) {
|
|
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;
|