// SPDX-License-Identifier: AGPL-3.0-or-later import type {ApiContext} from '@app/api/ApiContext'; import { createChannelID, createWebhookID, type EmojiID, type GuildID, type StickerID, type UserID, } from '@app/api/BrandedTypes'; import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; import type {ChannelService} from '@app/api/channel/services/ChannelService'; import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers'; import {isTainted, type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate'; import {resolveExpressionSourceGuild} from '@app/api/guild/ExpressionSourceGuild'; import { collectAuditLogChannelIds, collectGuildAuditLogUserIds, isNoopGuildAuditLog, isThreadScopedAuditLog, mapGuildAuditLogEntry, maskThreadAuditLog, type StoredGuildAuditLogEntryResponse, THREAD_AUDIT_LOG_ACTION_TYPES, } from '@app/api/guild/GuildAuditLogEntryMapper'; import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService'; import {withGuildUpdateLock} from '@app/api/guild/GuildUpdateLock'; import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository'; import {GuildChannelService} from '@app/api/guild/services/GuildChannelService'; import {GuildContentService} from '@app/api/guild/services/GuildContentService'; import {GuildDataService} from '@app/api/guild/services/GuildDataService'; import {GuildMemberService} from '@app/api/guild/services/GuildMemberService'; import {getGuildAuth} from '@app/api/guild/services/GuildMfaEnforcement'; import {GuildModerationService} from '@app/api/guild/services/GuildModerationService'; import {GuildRoleService} from '@app/api/guild/services/GuildRoleService'; import {GuildSearchService} from '@app/api/guild/services/GuildSearchService'; import type {AvatarService} from '@app/api/infrastructure/AvatarService'; import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService'; import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue'; import type {IGatewayService} from '@app/api/infrastructure/IGatewayService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {InviteRepository} from '@app/api/invite/InviteRepository'; import type {LimitConfigService} from '@app/api/limits/LimitConfigService'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {GuildAuditLog} from '@app/api/models/GuildAuditLog'; import type {Webhook} from '@app/api/models/Webhook'; import type {UserRepository} from '@app/api/user/repositories/UserRepository'; import type {WebhookRepository} from '@app/api/webhook/WebhookRepository'; import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants'; 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 {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError'; import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas'; import type { AuditLogWebhookResponse, GuildAuditLogListResponse, } from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas'; import type { GuildEmojiMetadataResponse, GuildExpressionSourceGuildResponse, GuildStickerMetadataResponse, } from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas'; import type {GuildUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas'; import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {ICacheService} from '@pkgs/cache/src/ICacheService'; interface StoredAuditLogWebhookResponse extends Omit { type: number; } interface StoredGuildAuditLogListResponse extends Omit { audit_log_entries: Array; webhooks: Array; } export class GuildService { public readonly data: GuildDataService; public readonly members: GuildMemberService; public readonly roles: GuildRoleService; public readonly moderation: GuildModerationService; public readonly content: GuildContentService; public readonly channels: GuildChannelService; public readonly search: GuildSearchService; private readonly guildRepository: GuildRepository; private readonly cacheService: ICacheService; private readonly userCacheService: UserCacheService; private readonly webhookRepository: WebhookRepository; private readonly guildAuditLogService: GuildAuditLogService; private readonly gatewayService: IGatewayService; private readonly userRepository: UserRepository; constructor( apiContext: ApiContext, guildRepository: GuildRepository, private readonly channelRepository: ChannelRepository, inviteRepository: InviteRepository, channelService: ChannelService, userCacheService: UserCacheService, entityAssetService: EntityAssetService, avatarService: AvatarService, assetDeletionQueue: IAssetDeletionQueue, webhookRepository: WebhookRepository, guildAuditLogService: GuildAuditLogService, limitConfigService: LimitConfigService, ) { const { cache: cacheService, gateway: gatewayService, users: userRepository, worker: workerService, snowflake: snowflakeService, rateLimit: rateLimitService, } = apiContext.services; this.gatewayService = gatewayService; this.guildRepository = guildRepository; this.cacheService = cacheService; this.userCacheService = userCacheService; this.webhookRepository = webhookRepository; this.guildAuditLogService = guildAuditLogService; this.userRepository = userRepository; this.data = new GuildDataService( guildRepository, channelRepository, inviteRepository, channelService, gatewayService, entityAssetService, userRepository, snowflakeService, webhookRepository, guildAuditLogService, limitConfigService, workerService, ); this.members = new GuildMemberService( guildRepository, channelService, userCacheService, gatewayService, entityAssetService, userRepository, rateLimitService, guildAuditLogService, limitConfigService, ); this.roles = new GuildRoleService( guildRepository, snowflakeService, cacheService, gatewayService, guildAuditLogService, limitConfigService, userRepository, ); this.moderation = new GuildModerationService( guildRepository, userRepository, gatewayService, userCacheService, workerService, guildAuditLogService, ); this.content = new GuildContentService( guildRepository, userCacheService, gatewayService, avatarService, snowflakeService, guildAuditLogService, assetDeletionQueue, limitConfigService, ); this.channels = new GuildChannelService( channelRepository, guildRepository, userCacheService, gatewayService, cacheService, snowflakeService, guildAuditLogService, limitConfigService, userRepository, ); this.search = new GuildSearchService( channelRepository, userCacheService, gatewayService, userRepository, workerService, ); } async updateGuild( params: { userId: UserID; guildId: GuildID; data: GuildUpdateRequest; requestCache: RequestCache; }, auditLogReason?: string | null, ): Promise { const {guildId, requestCache} = params; const {guild, previousFeatures, updatedFeatures} = await this.withUpdateLock(guildId, () => { requestCache.guilds.delete(guildId); return this.data.operations.updateGuild(params, auditLogReason); }); if ( previousFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) && !updatedFeatures.has(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ) { await this.channels.sanitizeTextChannelNames({guildId, requestCache}); } return guild; } withUpdateLock(guildId: GuildID, fn: () => Promise): Promise { return withGuildUpdateLock(this.cacheService, guildId, fn); } async getEmojiMetadata(emojiId: EmojiID): Promise { const emoji = await this.guildRepository.getEmojiById(emojiId); if (!emoji) throw new UnknownGuildEmojiError(); const guild = await this.data.operations.getGuildSystem(emoji.guildId); return { id: emoji.id.toString(), guild_id: guild.id.toString(), name: emoji.name, animated: emoji.isAnimated, allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED), }; } async getStickerMetadata(stickerId: StickerID): Promise { const sticker = await this.guildRepository.getStickerById(stickerId); if (!sticker) throw new UnknownGuildStickerError(); const guild = await this.data.operations.getGuildSystem(sticker.guildId); return { id: sticker.id.toString(), guild_id: guild.id.toString(), name: sticker.name, animated: sticker.animated, allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED), }; } async getEmojiSource(emojiId: EmojiID, userId: UserID): Promise { const emoji = await this.guildRepository.getEmojiById(emojiId); if (!emoji) throw new UnknownGuildEmojiError(); return this.resolveExpressionSourceGuild(emoji.guildId, userId); } async getStickerSource(stickerId: StickerID, userId: UserID): Promise { const sticker = await this.guildRepository.getStickerById(stickerId); if (!sticker) throw new UnknownGuildStickerError(); return this.resolveExpressionSourceGuild(sticker.guildId, userId); } private resolveExpressionSourceGuild(guildId: GuildID, userId: UserID): Promise { return resolveExpressionSourceGuild({ loadGuild: () => this.gatewayService.getGuildData({guildId, userId, skipMembershipCheck: true}), isMember: async () => (await this.gatewayService.getGuildMember({guildId, userId})).success, }); } async listGuildAuditLogs(params: { userId: UserID; viewer: ThreadViewer; guildId: GuildID; requestCache: RequestCache; limit?: number; beforeLogId?: bigint; afterLogId?: bigint; filterUserId?: UserID; actionType?: AuditLogActionType; }): Promise { const {userId, guildId} = params; const [hasPermission, guild] = await Promise.all([ this.gatewayService.checkPermission({ guildId, userId, permission: Permissions.VIEW_AUDIT_LOG, }), this.guildRepository.findUnique(guildId), ]); if (!guild) { throw new UnknownGuildError(); } if (!hasPermission) { throw new MissingPermissionsError(); } return this.fetchGuildAuditLogs(params); } async fetchGuildAuditLogs(params: { guildId: GuildID; requestCache: RequestCache; limit?: number; beforeLogId?: bigint; afterLogId?: bigint; filterUserId?: UserID; actionType?: AuditLogActionType; viewer?: ThreadViewer; }): Promise { const {guildId, requestCache, limit = 50, beforeLogId, afterLogId, filterUserId, actionType} = params; const threadsVisible = params.viewer === undefined || viewerActive(params.viewer, guildId); const hideThreadEntries = !threadsVisible && (await isTainted(guildId)); if (beforeLogId !== undefined && afterLogId !== undefined) { throw InputValidationError.fromCode('before', ValidationErrorCodes.CANNOT_SPECIFY_BOTH_BEFORE_AND_AFTER); } const effectiveLimit = Math.max(1, Math.min(limit, 100)); const shouldBatch = actionType === undefined && !filterUserId; let processedLogs: Array = []; let currentBeforeLogId = beforeLogId; let currentAfterLogId = afterLogId; while (processedLogs.length < effectiveLimit) { const fetchLimit = Math.min(effectiveLimit * 2, 200); const logs = await this.guildRepository.listAuditLogs({ guildId, limit: fetchLimit, beforeLogId: currentBeforeLogId, afterLogId: currentAfterLogId, userId: filterUserId, actionType, }); if (logs.length === 0) { break; } const batchedLogs = shouldBatch ? (await this.guildAuditLogService.batchConsecutiveMessageDeleteLogs(guildId, logs)).processedLogs : logs; const visibleLogs = hideThreadEntries ? (await this.dropThreadScopedAuditLogs(batchedLogs)).map(maskThreadAuditLog) : batchedLogs; for (const log of visibleLogs) { if (processedLogs.length < effectiveLimit && !isNoopGuildAuditLog(log.actionType, log.changes)) { processedLogs.push(log); } } if (logs.length < fetchLimit) { break; } const lastLog = logs[logs.length - 1]; if (afterLogId !== undefined) { currentAfterLogId = lastLog.logId; } else { currentBeforeLogId = lastLog.logId; } } processedLogs = processedLogs.slice(0, effectiveLimit); const userIdSet = new Set(); for (const log of processedLogs) { for (const referencedUserId of collectGuildAuditLogUserIds(log)) { userIdSet.add(referencedUserId); } } const [userPartials, webhookRecords] = await Promise.all([ this.userCacheService.getUserPartialResponses(Array.from(userIdSet), requestCache), this.loadAuditLogWebhooks(processedLogs), ]); const entries = processedLogs.map((log) => mapGuildAuditLogEntry(log)); const users = Array.from(userPartials.values()); const webhooks = this.buildAuditLogWebhookResponses( hideThreadEntries ? await this.dropForumWebhooks(webhookRecords.webhooks) : webhookRecords.webhooks, ); const response: StoredGuildAuditLogListResponse = { audit_log_entries: entries, users, webhooks, }; if (params.viewer !== undefined && threadsVisible) { response.threads = await this.loadAuditLogThreads(processedLogs); } return response; } private async dropThreadScopedAuditLogs(logs: Array): Promise> { const candidateIds = [...new Set(logs.flatMap((log) => collectAuditLogChannelIds(log)))].filter((id) => /^\d{1,20}$/.test(id), ); const channels = candidateIds.length > 0 ? await this.channelRepository.channelData.listChannels(candidateIds.map((id) => createChannelID(BigInt(id)))) : []; const gatedIds = new Set( channels .filter((channel) => THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) .map((channel) => channel.id.toString()), ); return logs.filter((log) => !isThreadScopedAuditLog(log, gatedIds)); } private async dropForumWebhooks(webhooks: Array): Promise> { const channelIds = [...new Set(webhooks.flatMap((webhook) => (webhook.channelId ? [webhook.channelId] : [])))]; if (channelIds.length === 0) return webhooks; const forumIds = new Set( (await this.channelRepository.channelData.listChannels(channelIds)) .filter((channel) => channel.isThreadOnly()) .map((channel) => channel.id), ); return webhooks.filter((webhook) => !webhook.channelId || !forumIds.has(webhook.channelId)); } private async loadAuditLogThreads(logs: Array): Promise> { const threadIds = [ ...new Set( logs .filter((log) => THREAD_AUDIT_LOG_ACTION_TYPES.has(log.actionType) && log.targetId) .map((log) => log.targetId!), ), ] .filter((id) => /^\d{1,20}$/.test(id)) .map((id) => createChannelID(BigInt(id))); if (threadIds.length === 0) return []; const [channels, states, stats] = await Promise.all([ this.channelRepository.channelData.listChannels(threadIds), this.channelRepository.threads.getStates(threadIds), this.channelRepository.threads.getStatsMany(threadIds), ]); const stateById = new Map(states.map((state) => [state.threadId.toString(), state])); const parents = await this.channelRepository.channelData.listChannels([ ...new Set(states.map((state) => state.parentId)), ]); const parentTypeById = new Map(parents.map((parent) => [parent.id.toString(), parent.type])); return channels.flatMap((channel) => { const state = stateById.get(channel.id.toString()); const threadStats = stats.get(channel.id); if (!state || !threadStats || !channel.isThread()) return []; return [ mapThreadToResponse({ channel, state, stats: threadStats, parentType: parentTypeById.get(state.parentId.toString()) ?? null, }), ]; }); } private async loadAuditLogWebhooks(logs: Array): Promise<{ webhooks: Array; }> { const webhookIds = new Set(); for (const log of logs) { if (this.isWebhookAction(log.actionType) && log.targetId) { webhookIds.add(log.targetId); } } if (webhookIds.size === 0) { return {webhooks: []}; } const webhookPromises = Array.from(webhookIds, (id) => { try { return this.webhookRepository.findUnique(createWebhookID(BigInt(id))); } catch { return Promise.resolve(null); } }); const results = await Promise.all(webhookPromises); const foundWebhooks = results.filter((webhook): webhook is Webhook => webhook !== null); return {webhooks: foundWebhooks}; } private buildAuditLogWebhookResponses(webhooks: Array): Array { return webhooks.map((webhook) => ({ id: webhook.id.toString(), type: webhook.type, guild_id: webhook.guildId?.toString() ?? null, channel_id: webhook.channelId?.toString() ?? null, name: webhook.name, avatar_hash: webhook.avatarHash, })); } private isWebhookAction(actionType: AuditLogActionType): boolean { return ( actionType === AuditLogActionType.WEBHOOK_CREATE || actionType === AuditLogActionType.WEBHOOK_UPDATE || actionType === AuditLogActionType.WEBHOOK_DELETE ); } async getGuildAuthenticated(params: {userId: UserID; guildId: GuildID}) { return getGuildAuth(this.gatewayService, this.userRepository, params); } }