401 lines
11 KiB
TypeScript
401 lines
11 KiB
TypeScript
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<ServerPayload> {
|
|
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<void> {
|
|
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<ServerEntity | null> {
|
|
return await getServerRepository().findOne({ where: { id: serverId } });
|
|
}
|
|
|
|
export async function getActiveServerBan(serverId: string, userId: string): Promise<ServerBanEntity | null> {
|
|
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<boolean> {
|
|
return !!(await getActiveServerBan(serverId, userId));
|
|
}
|
|
|
|
export async function findServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity | null> {
|
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
|
}
|
|
|
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
|
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<void> {
|
|
await getMembershipRepository().delete({ serverId, userId });
|
|
}
|
|
|
|
export async function assertCanCreateInvite(serverId: string, requesterUserId: string): Promise<ServerEntity> {
|
|
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<ServerInviteEntity> {
|
|
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<JoinServerAccessResult> {
|
|
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<void> {
|
|
await removeServerMembership(serverId, userId);
|
|
}
|
|
|
|
export async function leaveServerUser(serverId: string, userId: string): Promise<void> {
|
|
await removeServerMembership(serverId, userId);
|
|
}
|
|
|
|
export async function banServerUser(options: BanServerUserOptions): Promise<ServerBanEntity> {
|
|
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<void> {
|
|
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 });
|
|
}
|
|
}
|