import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { getDataSource } from '../db/database'; import { ServerBanEntity, ServerEntity, ServerInviteEntity, ServerMembershipEntity } from '../entities'; import { rowToServer } from '../cqrs/mappers'; import { loadServerRelationsMap } from '../cqrs/relations'; import { ServerPayload } from '../cqrs/types'; export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000; export type JoinAccessVia = 'membership' | 'password' | 'invite' | 'public'; export interface JoinServerAccessResult { joinedBefore: boolean; server: ServerPayload; via: JoinAccessVia; } export interface BanServerUserOptions { banId?: string; bannedBy: string; displayName?: string; expiresAt?: number; reason?: string; serverId: string; userId: string; } export class ServerAccessError extends Error { constructor( readonly status: number, readonly code: string, message: string ) { super(message); this.name = 'ServerAccessError'; } } function getServerRepository() { return getDataSource().getRepository(ServerEntity); } function getMembershipRepository() { return getDataSource().getRepository(ServerMembershipEntity); } function getInviteRepository() { return getDataSource().getRepository(ServerInviteEntity); } function getBanRepository() { return getDataSource().getRepository(ServerBanEntity); } async function toServerPayload(server: ServerEntity): Promise { const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]); return rowToServer(server, relationsByServerId.get(server.id)); } function normalizePassword(password?: string | null): string | null { const normalized = password?.trim() ?? ''; return normalized.length > 0 ? normalized : null; } function isServerOwner(server: ServerEntity, userId: string): boolean { return server.ownerId === userId; } export function hashServerPassword(password: string): string { return crypto.createHash('sha256').update(password) .digest('hex'); } export function passwordHashForInput(password?: string | null): string | null { const normalized = normalizePassword(password); return normalized ? hashServerPassword(normalized) : null; } export function buildSignalingUrl(origin: string): string { return origin.replace(/^http/i, 'ws'); } export async function pruneExpiredServerAccessArtifacts(now: number = Date.now()): Promise { await getInviteRepository() .createQueryBuilder() .delete() .where('expiresAt <= :now', { now }) .execute(); await getBanRepository() .createQueryBuilder() .delete() .where('expiresAt IS NOT NULL AND expiresAt <= :now', { now }) .execute(); } export async function getServerRecord(serverId: string): Promise { return await getServerRepository().findOne({ where: { id: serverId } }); } export async function getActiveServerBan(serverId: string, userId: string): Promise { const banRepo = getBanRepository(); const ban = await banRepo.findOne({ where: { serverId, userId } }); if (!ban) return null; if (ban.expiresAt && ban.expiresAt <= Date.now()) { await banRepo.delete({ id: ban.id }); return null; } return ban; } export async function isServerUserBanned(serverId: string, userId: string): Promise { return !!(await getActiveServerBan(serverId, userId)); } export async function findServerMembership(serverId: string, userId: string): Promise { return await getMembershipRepository().findOne({ where: { serverId, userId } }); } export async function ensureServerMembership(serverId: string, userId: string): Promise { const repo = getMembershipRepository(); const now = Date.now(); const existing = await repo.findOne({ where: { serverId, userId } }); if (existing) { existing.lastAccessAt = now; await repo.save(existing); return existing; } const entity = repo.create({ id: uuidv4(), serverId, userId, joinedAt: now, lastAccessAt: now }); await repo.save(entity); return entity; } export async function removeServerMembership(serverId: string, userId: string): Promise { await getMembershipRepository().delete({ serverId, userId }); } export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise { const server = await getServerRecord(serverId); if (!server) { throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found'); } if (await isServerUserBanned(serverId, requesterUserId)) { throw new ServerAccessError(403, 'BANNED', 'Banned users cannot create invites'); } const membership = await findServerMembership(serverId, requesterUserId); if (server.ownerId !== requesterUserId && !membership) { throw new ServerAccessError(403, 'NOT_MEMBER', 'Only joined users can create invites'); } return server; } export async function createServerInvite( serverId: string, createdBy: string, createdByDisplayName?: string ): Promise { await assertCanCreateInvite(serverId, createdBy); const repo = getInviteRepository(); const now = Date.now(); const invite = repo.create({ id: uuidv4(), serverId, createdBy, createdByDisplayName: createdByDisplayName ?? null, createdAt: now, expiresAt: now + SERVER_INVITE_EXPIRY_MS }); await repo.save(invite); return invite; } export async function getActiveServerInvite( inviteId: string ): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> { await pruneExpiredServerAccessArtifacts(); const invite = await getInviteRepository().findOne({ where: { id: inviteId } }); if (!invite) { return null; } if (invite.expiresAt <= Date.now()) { await getInviteRepository().delete({ id: invite.id }); return null; } const server = await getServerRecord(invite.serverId); if (!server) { return null; } return { invite, server: await toServerPayload(server) }; } export async function joinServerWithAccess(options: { inviteId?: string; password?: string; serverId: string; userId: string; }): Promise { await pruneExpiredServerAccessArtifacts(); const server = await getServerRecord(options.serverId); if (!server) { throw new ServerAccessError(404, 'SERVER_NOT_FOUND', 'Server not found'); } if (await isServerUserBanned(server.id, options.userId)) { throw new ServerAccessError(403, 'BANNED', 'Banned users cannot join this server'); } if (isServerOwner(server, options.userId)) { const existingMembership = await findServerMembership(server.id, options.userId); await ensureServerMembership(server.id, options.userId); return { joinedBefore: !!existingMembership, server: await toServerPayload(server), via: 'membership' }; } if (options.inviteId) { const inviteBundle = await getActiveServerInvite(options.inviteId); if (!inviteBundle || inviteBundle.server.id !== server.id) { throw new ServerAccessError(410, 'INVITE_EXPIRED', 'Invite link has expired or is invalid'); } const existingMembership = await findServerMembership(server.id, options.userId); await ensureServerMembership(server.id, options.userId); return { joinedBefore: !!existingMembership, server: await toServerPayload(server), via: 'invite' }; } const membership = await findServerMembership(server.id, options.userId); if (membership) { await ensureServerMembership(server.id, options.userId); return { joinedBefore: true, server: await toServerPayload(server), via: 'membership' }; } if (server.passwordHash) { const passwordHash = passwordHashForInput(options.password); if (!passwordHash || passwordHash !== server.passwordHash) { throw new ServerAccessError(403, 'PASSWORD_REQUIRED', 'Password required to join this server'); } await ensureServerMembership(server.id, options.userId); return { joinedBefore: false, server: await toServerPayload(server), via: 'password' }; } if (server.isPrivate) { throw new ServerAccessError(403, 'PRIVATE_SERVER', 'Private servers require an invite link'); } await ensureServerMembership(server.id, options.userId); return { joinedBefore: false, server: await toServerPayload(server), via: 'public' }; } export async function authorizeWebSocketJoin(serverId: string, userId: string): Promise<{ allowed: boolean; reason?: string }> { await pruneExpiredServerAccessArtifacts(); const server = await getServerRecord(serverId); if (!server) { return { allowed: false, reason: 'SERVER_NOT_FOUND' }; } if (await isServerUserBanned(serverId, userId)) { return { allowed: false, reason: 'BANNED' }; } if (isServerOwner(server, userId)) { await ensureServerMembership(serverId, userId); return { allowed: true }; } const membership = await findServerMembership(serverId, userId); if (membership) { await ensureServerMembership(serverId, userId); return { allowed: true }; } if (!server.isPrivate && !server.passwordHash) { await ensureServerMembership(serverId, userId); return { allowed: true }; } return { allowed: false, reason: server.isPrivate ? 'PRIVATE_SERVER' : 'PASSWORD_REQUIRED' }; } export async function kickServerUser(serverId: string, userId: string): Promise { await removeServerMembership(serverId, userId); } export async function leaveServerUser(serverId: string, userId: string): Promise { await removeServerMembership(serverId, userId); } export async function banServerUser(options: BanServerUserOptions): Promise { await removeServerMembership(options.serverId, options.userId); const repo = getBanRepository(); const existing = await repo.findOne({ where: { serverId: options.serverId, userId: options.userId } }); if (existing) { await repo.delete({ id: existing.id }); } const entity = repo.create({ id: options.banId ?? uuidv4(), serverId: options.serverId, userId: options.userId, bannedBy: options.bannedBy, displayName: options.displayName ?? null, reason: options.reason ?? null, expiresAt: options.expiresAt ?? null, createdAt: Date.now() }); await repo.save(entity); return entity; } export async function unbanServerUser(options: { banId?: string; serverId: string; userId?: string }): Promise { const repo = getBanRepository(); if (options.banId) { await repo.delete({ id: options.banId, serverId: options.serverId }); } if (options.userId) { await repo.delete({ serverId: options.serverId, userId: options.userId }); } }