// SPDX-License-Identifier: AGPL-3.0-or-later import type {GuildID, UserID} from '@app/api/BrandedTypes'; import {isIpBanExempt} from '@app/api/ban/IpBanExemptions'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import type {GuildAuditLogChange} from '@app/api/guild/GuildAuditLogTypes'; import {mapGuildBansToResponse} from '@app/api/guild/GuildModel'; import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate'; import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement'; import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService'; import type {BanBy} from '@app/api/infrastructure/activity/Contract.generated'; import {emitGuildMemberBanned, emitGuildMemberUnbanned} from '@app/api/infrastructure/activity/ModerationEvents'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {Guild} from '@app/api/models/Guild'; import type {GuildBan} from '@app/api/models/GuildBan'; import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError'; import {BannedFromGuildError} from '@fluxer/errors/src/domains/guild/BannedFromGuildError'; import {IpBannedFromGuildError} from '@fluxer/errors/src/domains/guild/IpBannedFromGuildError'; import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import {isSameIpDecisionMatch} from '@fluxer/ip_utils/src/IpAddress'; import type {GuildBanResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService'; const SECONDS_PER_DAY = 86_400; const GUILD_MODERATION_PERMISSIONS = Permissions.ADMINISTRATOR | Permissions.BAN_MEMBERS | Permissions.KICK_MEMBERS | Permissions.MANAGE_GUILD; export class GuildModerationService { private readonly searchIndexService: GuildMemberSearchIndexService; constructor( private readonly guildRepository: IGuildRepositoryAggregate, private readonly userRepository: IUserRepository, private readonly gatewayService: IGatewayService, private readonly userCacheService: UserCacheService, private readonly workerService: IWorkerService, private readonly guildAuditLogService: GuildAuditLogService, ) { this.searchIndexService = new GuildMemberSearchIndexService(); } private async checkModerationPermission(params: { guildId: GuildID; userId: UserID; permission: bigint; }): Promise { const {guildId, userId, permission} = params; const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission}); if (!hasPermission) throw new MissingPermissionsError(); const guildData = await this.gatewayService.getGuildData({guildId, userId}); const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId}); enforceGuildMfa(permission); } async banMember( params: { userId: UserID; targetId: UserID; guildId: GuildID; deleteMessageDays?: number; deleteMessageSeconds?: number; reason?: string | null; banDurationSeconds?: number; skipGuildAuditLog?: boolean; by?: BanBy; }, auditLogReason?: string | null, ): Promise { const { userId, guildId, targetId, deleteMessageDays, deleteMessageSeconds, reason, banDurationSeconds, skipGuildAuditLog, by = 'moderator', } = params; await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); if (userId === targetId) throw new UnknownGuildMemberError(); const targetUser = await this.userRepository.findUnique(targetId); if (!targetUser) { throw new UnknownUserError(); } const targetMember = await this.guildRepository.getMember(guildId, targetId); if (targetMember) { const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId}); if (!canManage) throw new MissingPermissionsError(); } const effectiveDeleteMessageSeconds = deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined); if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) { await this.workerService.addJob('deleteUserMessagesInGuildByTime', { guildId: guildId.toString(), userId: targetId.toString(), seconds: effectiveDeleteMessageSeconds, }); } const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null; const targetEmail = targetUser.email?.toLowerCase() || null; let expiresAt: Date | null = null; if (banDurationSeconds && banDurationSeconds > 0) { expiresAt = new Date(Date.now() + banDurationSeconds * 1000); } const guildBeforeBan = await this.guildRepository.findUnique(guildId); const targetModerator = await this.isGuildModerator(guildId, targetId, guildBeforeBan, targetMember !== null); const ban = await this.guildRepository.upsertBan({ guild_id: guildId, user_id: targetId, moderator_id: userId, banned_at: new Date(), expires_at: expiresAt, reason: reason || null, ip: targetIp, email: targetEmail, }); if (!skipGuildAuditLog) { const metadata: Record | undefined = effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0 ? {delete_message_seconds: String(effectiveDeleteMessageSeconds)} : undefined; await this.recordAuditLog({ guildId, userId, action: AuditLogActionType.MEMBER_BAN_ADD, targetId: targetId, auditLogReason: auditLogReason ?? (reason || null), metadata, changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)), }); } await emitGuildMemberBanned({ guildId, userId: targetId, moderatorId: userId, by, memberCount: guildBeforeBan?.memberCount ?? 0, targetModerator, bannedAt: ban.bannedAt, expiresAt: ban.expiresAt, }); await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_BAN_ADD', data: { guild_id: guildId.toString(), user: {id: targetId.toString()}, }, }); if (targetMember) { await this.guildRepository.deleteMember(guildId, targetId); const guild = await this.guildRepository.findUnique(guildId); if (guild) { await this.guildRepository.upsertPartial( guildId, {member_count: Math.max(0, guild.memberCount - 1)}, guild.toRow(), ); } await this.dispatchGuildMemberRemove({guildId, userId: targetId}); await this.gatewayService.leaveGuild({userId: targetId, guildId}); const guildForSearch = await this.guildRepository.findUnique(guildId); if (guildForSearch) { const includeDefault = guildForSearch.membersIndexedAt != null; if (includeDefault) { void this.searchIndexService.deleteMember(guildId, targetId, {includeDefault}); } } } } async listBans(params: { userId: UserID; guildId: GuildID; requestCache: RequestCache; }): Promise> { const {userId, guildId, requestCache} = params; await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); const bans = await this.guildRepository.listBans(guildId); return await mapGuildBansToResponse(bans, this.userCacheService, requestCache); } async unbanMember( params: { userId: UserID; targetId: UserID; guildId: GuildID; by?: BanBy; }, auditLogReason?: string | null, ): Promise { const {userId, guildId, targetId, by = 'moderator'} = params; await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS}); const ban = await this.guildRepository.getBan(guildId, targetId); if (!ban) { throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED); } await this.guildRepository.deleteBan(guildId, targetId); await emitGuildMemberUnbanned({guildId, userId: targetId, moderatorId: userId, by}); await this.recordAuditLog({ guildId, userId, action: AuditLogActionType.MEMBER_BAN_REMOVE, targetId: targetId, auditLogReason: auditLogReason ?? null, changes: this.guildAuditLogService.computeChanges(this.serializeBanForAudit(ban), null), }); await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_BAN_REMOVE', data: { guild_id: guildId.toString(), user: {id: targetId.toString()}, }, }); } async checkUserBanStatus(params: {userId: UserID; guildId: GuildID}): Promise { const {userId, guildId} = params; const [bans, user] = await Promise.all([ this.guildRepository.listBans(guildId), this.userRepository.findUnique(userId), ]); const userIp = user?.lastActiveIp; const userEmail = user?.email?.toLowerCase(); for (const ban of bans) { if (ban.userId === userId) throw new BannedFromGuildError(); if (isSameIpDecisionMatch(userIp, ban.ipAddress) && !isIpBanExempt(userIp)) { throw new IpBannedFromGuildError(); } } if (userEmail) { const emailBan = await this.guildRepository.getBanByEmail(guildId, userEmail); if (emailBan) throw new BannedFromGuildError(); } } private async isGuildModerator( guildId: GuildID, targetId: UserID, guild: Guild | null, isMember: boolean, ): Promise { if (guild?.ownerId === targetId) return true; if (!isMember) return false; try { const permissions = await this.gatewayService.getUserPermissions({guildId, userId: targetId}); return (permissions & GUILD_MODERATION_PERMISSIONS) !== 0n; } catch (error) { Logger.debug({error, guildId: guildId.toString()}, 'Could not read target permissions for a guild ban'); return false; } } private serializeBanForAudit(ban: GuildBan): Record { return { user_id: ban.userId.toString(), moderator_id: ban.moderatorId.toString(), banned_at: ban.bannedAt.toISOString(), expires_at: ban.expiresAt ? ban.expiresAt.toISOString() : null, reason: ban.reason ?? null, }; } private async dispatchGuildMemberRemove({guildId, userId}: {guildId: GuildID; userId: UserID}): Promise { await this.gatewayService.dispatchGuild({ guildId, event: 'GUILD_MEMBER_REMOVE', data: {user: {id: userId.toString()}}, }); } private async recordAuditLog(params: { guildId: GuildID; userId: UserID; action: AuditLogActionType; targetId?: UserID | string | null; auditLogReason?: string | null; metadata?: Map | Record; changes?: GuildAuditLogChange | null; createdAt?: Date; }): Promise { const targetId = params.targetId === undefined || params.targetId === null ? null : typeof params.targetId === 'string' ? params.targetId : params.targetId.toString(); try { const builder = this.guildAuditLogService .createBuilder(params.guildId, params.userId) .withAction(params.action, targetId) .withReason(params.auditLogReason ?? null); if (params.metadata) { builder.withMetadata(params.metadata); } if (params.changes) { builder.withChanges(params.changes); } if (params.createdAt) { builder.withCreatedAt(params.createdAt); } await builder.commit(); } catch (error) { Logger.error( { error, guildId: params.guildId.toString(), userId: params.userId.toString(), action: params.action, targetId, }, 'Failed to record guild audit log', ); } } }