Files
fluxer/fluxer_api/src/api/guild/services/GuildModerationService.ts
T

320 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
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 {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import type {GuildID, UserID} from '../../BrandedTypes';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import {Logger} from '../../Logger';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {GuildBan} from '../../models/GuildBan';
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../risk/IpBanExemptions';
import type {IUserRepository} from '../../user/IUserRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
const SECONDS_PER_DAY = 86_400;
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<WorkerTaskName>,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly ipInfoService: IpInfoService,
) {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
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;
},
auditLogReason?: string | null,
): Promise<void> {
const {
userId,
guildId,
targetId,
deleteMessageDays,
deleteMessageSeconds,
reason,
banDurationSeconds,
skipGuildAuditLog,
} = 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 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<string, string> | undefined =
deleteMessageDays !== undefined ? {delete_member_days: deleteMessageDays.toString()} : undefined;
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.MEMBER_BAN_ADD,
targetId: targetId,
auditLogReason: auditLogReason ?? null,
metadata,
changes: this.guildAuditLogService.computeChanges(null, this.serializeBanForAudit(ban)),
});
}
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<Array<GuildBanResponse>> {
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;
},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = 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 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<void> {
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) && (await this.shouldEnforceIpBan(userIp, ban.ipAddress))) {
throw new IpBannedFromGuildError();
}
}
if (userEmail) {
const emailBan = await this.guildRepository.getBanByEmail(guildId, userEmail);
if (emailBan) throw new BannedFromGuildError();
}
}
private async shouldEnforceIpBan(
userIp: string | null | undefined,
bannedIp: string | null | undefined,
): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
try {
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
source: 'guild.ip_ban',
reason: 'join_cgnat_guard',
});
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild IP ban match because IPInfo indicates high CGNAT blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild IP ban');
return true;
}
}
private serializeBanForAudit(ban: GuildBan): Record<string, unknown> {
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<void> {
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<string, string> | Record<string, string>;
changes?: GuildAuditLogChange | null;
createdAt?: Date;
}): Promise<void> {
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',
);
}
}
}