// SPDX-License-Identifier: AGPL-3.0-or-later import type {ApiContext} from '@app/api/ApiContext'; import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'; import { describeReporterNotice, type ReporterResolutionNotifier, } from '@app/api/admin/services/ReporterResolutionNotifier'; import { type ChannelID, createReportID, createUserID, type GuildID, type ReportID, type UserID, } from '@app/api/BrandedTypes'; import {Config} from '@app/api/Config'; import type {ChannelRepository} from '@app/api/channel/ChannelRepository'; import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers'; import { adminMessageResponseAccess, createMessageResponseDataService, } from '@app/api/channel/services/message/MessageResponseDataService'; import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope'; import type {MessageAttachment} from '@app/api/database/types/MessageTypes'; import type {IARReportNoteRow} from '@app/api/database/types/ReportTypes'; import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository'; import type {IStorageService} from '@app/api/infrastructure/IStorageService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import {Logger} from '@app/api/Logger'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry'; import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog'; import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels'; import type {ReportRepository} from '@app/api/report/ReportRepository'; import type {ReportService} from '@app/api/report/ReportService'; import {getReportSearchService} from '@app/api/SearchFactory'; import {isHiddenPartial} from '@app/api/user/ProfileVisibility'; import {formatUserTag} from '@app/api/user/UserTag'; import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils'; import {AdminACLs} from '@fluxer/constants/src/AdminACLs'; import {DELETED_USER_DISCRIMINATOR, DELETED_USER_USERNAME} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError'; import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes'; import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas'; import type { AdminReportReasonsResponse, ReportItemOutcome, SearchReportsRequest, UpdateReportRequest, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import type {ReportProfileAssetSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {seconds} from 'itty-time'; interface AdminReportServiceDeps { apiContext: ApiContext; reportService: ReportService; reportRepository: ReportRepository; guildRepository: GuildRepository; channelRepository: ChannelRepository; storageService: IStorageService; auditService: AdminAuditService; userCacheService: UserCacheService; reporterResolutionNotifier: ReporterResolutionNotifier; } interface StaffReportResolution { adminUserId: UserID; publicComment: string | null; auditLogReason: string | null; notifyReporter: boolean; resolution: UpdateReportRequest['resolution']; outcome?: ReportItemOutcome; duplicateOf?: ReportID; } function refuse(path: 'outcome' | 'duplicate_of'): never { throw InputValidationError.fromCode(path, ValidationErrorCodes.REPORT_OUTCOME_INVALID); } function staffOutcome( resolution: UpdateReportRequest['resolution'], outcome: ReportItemOutcome | undefined, duplicateOf: ReportID | undefined, ): ReportItemOutcome | null { if (resolution !== 'duplicate' && duplicateOf !== undefined) refuse('duplicate_of'); switch (resolution) { case 'no_violation': if (outcome !== undefined && outcome !== 'no_violation') refuse('outcome'); return 'no_violation'; case 'actioned': if (outcome === undefined || outcome === 'no_violation') refuse('outcome'); return outcome; case 'duplicate': if (outcome !== undefined) refuse('outcome'); if (duplicateOf === undefined) refuse('duplicate_of'); return null; } } interface ReportLookupCache { channelNsfwByChannelId: Map; guildNsfwLevelByGuildId: Map; reporterEmailByReporterId: Map>; } function createReportLookupCache(): ReportLookupCache { return { channelNsfwByChannelId: new Map(), guildNsfwLevelByGuildId: new Map(), reporterEmailByReporterId: new Map(), }; } function mapUserPartialBotFlag(user: UserPartialResponse): boolean | null { if (user.bot) { return true; } const isUnresolved = user.username === DELETED_USER_USERNAME && user.discriminator === DELETED_USER_DISCRIMINATOR.toString().padStart(4, '0'); return isUnresolved ? null : false; } function mapMissingAttachmentToResponse(attachment: MessageAttachment) { return { id: attachment.attachment_id.toString(), filename: String(attachment.filename), nsfw: attachment.nsfw ?? null, content_type: attachment.content_type ?? null, width: attachment.width ?? null, height: attachment.height ?? null, size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null, }; } function toReportNoteResponse(note: IARReportNoteRow): ReportNoteResponse { return { note_id: note.note_id.toString(), author_id: note.author_id.toString(), body: note.body, created_at: note.created_at.toISOString(), }; } function mapReportReasonFields(report: IARSubmission) { const reason = report.reason ? findReportReason(report.reason) : null; const flow = report.flowSteps && report.flowRevision && report.flowSurface ? describeReportFlowAnswers({ revisionHash: report.flowRevision, surface: report.flowSurface, locale: report.flowLocale, steps: report.flowSteps, }) : null; return { reason: report.reason, reason_label: reason?.label ?? report.reason, reason_highest_priority: reason?.highestPriority ?? null, flow, reporter_good_faith_confirmed: report.reporterGoodFaithConfirmed, }; } export class AdminReportService { constructor(private readonly deps: AdminReportServiceDeps) {} async listReports(status: number, acls: ReadonlySet, limit?: number, offset?: number) { const {reportService} = this.deps; const requestedLimit = limit || 50; const currentOffset = offset || 0; const {reports, total} = await reportService.listReportsByStatus(status, requestedLimit, currentOffset); const requestCache = createRequestCache(); const reportLookupCache = createReportLookupCache(); const reportResponses = await Promise.all( reports.map((report: IARSubmission) => this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache), ), ); return { reports: reportResponses, total, offset: currentOffset, limit: requestedLimit, }; } listReportReasons(): AdminReportReasonsResponse { return { reasons: listReportReasons().map((reason) => ({ key: reason.key, label: reason.label, highest_priority: reason.highestPriority, legacy_category_message: reason.legacyCategories.message, legacy_category_user: reason.legacyCategories.user, legacy_category_guild: reason.legacyCategories.guild, })), }; } async getReport(reportId: ReportID, acls: ReadonlySet) { const {reportService} = this.deps; const report = await reportService.getReport(reportId); const requestCache = createRequestCache(); const reportLookupCache = createReportLookupCache(); const [response, notes] = await Promise.all([ this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache), this.deps.reportRepository.listNotes(reportId), ]); return {...response, notes: notes.map(toReportNoteResponse)}; } async listNotes(reportId: ReportID): Promise { await this.deps.reportService.getReport(reportId); const notes = await this.deps.reportRepository.listNotes(reportId); return {notes: notes.map(toReportNoteResponse)}; } async addNote(reportId: ReportID, body: string, authorId: UserID): Promise { await this.deps.reportService.getReport(reportId); const note: IARReportNoteRow = { report_id: reportId, note_id: await this.deps.apiContext.services.snowflake.generate(), author_id: authorId, body, created_at: new Date(), }; await this.deps.reportRepository.addNote(note); return toReportNoteResponse(note); } async resolveReport( reportId: ReportID, { adminUserId, publicComment, auditLogReason, notifyReporter, resolution, outcome, duplicateOf, }: StaffReportResolution, ) { const {reportService, auditService, reporterResolutionNotifier} = this.deps; const reporterOutcome = staffOutcome(resolution, outcome, duplicateOf) ?? (await this.duplicatedOutcome(reportId, duplicateOf)); const sentComment = notifyReporter ? publicComment : null; const internalComment = notifyReporter ? null : publicComment; const resolvedReport = await reportService.resolveReport(reportId, adminUserId, sentComment, auditLogReason, { outcome: resolution, resolvedBy: 'staff', reporterOutcome, }); const notice = reporterOutcome ? await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, sentComment, reporterOutcome) : null; await auditService.createAuditLog({ adminUserId, targetType: 'report', targetId: BigInt(reportId), action: 'resolve_report', auditLogReason, metadata: new Map([ ['report_id', reportId.toString()], ['report_type', resolvedReport.reportType.toString()], ['notify_reporter', notifyReporter ? 'true' : 'false'], ...(internalComment ? [['internal_comment', internalComment] as [string, string]] : []), ...(notice ? describeReporterNotice(notice) : [['reporter_notice_skipped', 'duplicate_outcome_unknown'] as [string, string]]), ['resolution', resolution], ...(duplicateOf ? [['duplicate_of', duplicateOf.toString()] as [string, string]] : []), ...(reporterOutcome ? [['outcome', reporterOutcome] as [string, string]] : []), ]), }); return { report_id: resolvedReport.reportId.toString(), status: resolvedReport.status, resolved_at: resolvedReport.resolvedAt?.toISOString() ?? null, public_comment: resolvedReport.publicComment, }; } private async duplicatedOutcome( reportId: ReportID, duplicateOf: ReportID | undefined, ): Promise { if (duplicateOf === undefined) return null; if (duplicateOf === reportId) refuse('duplicate_of'); const original = await this.deps.reportService.getReport(duplicateOf); return original.reporterOutcome; } async searchReports(data: SearchReportsRequest, acls: ReadonlySet) { const reportSearchService = getReportSearchService(); if (!reportSearchService) { throw new FeatureTemporarilyDisabledError(); } const filters: ReportSearchFilters = {}; if (data.reporter_id !== undefined) { filters.reporterId = data.reporter_id.toString(); } if (data.status !== undefined) { filters.status = data.status; } if (data.report_type !== undefined) { filters.reportType = data.report_type; } if (data.category !== undefined) { filters.category = data.category; } if (data.reason !== undefined) { filters.reason = data.reason; } if (data.reported_user_id !== undefined) { filters.reportedUserId = data.reported_user_id.toString(); } if (data.reported_webhook_id !== undefined) { filters.reportedWebhookId = data.reported_webhook_id.toString(); } if (data.reported_guild_id !== undefined) { filters.reportedGuildId = data.reported_guild_id.toString(); } if (data.reported_channel_id !== undefined) { filters.reportedChannelId = data.reported_channel_id.toString(); } if (data.guild_context_id !== undefined) { filters.guildContextId = data.guild_context_id.toString(); } if (data.resolved_by_admin_id !== undefined) { filters.resolvedByAdminId = data.resolved_by_admin_id.toString(); } if (data.sort_by) { filters.sortBy = data.sort_by; } if (data.sort_order) { filters.sortOrder = data.sort_order; } const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, { limit: data.limit, offset: data.offset, }); const requestCache = createRequestCache(); const reportLookupCache = createReportLookupCache(); const reportIds = hits.map((hit) => createReportID(BigInt(hit.id))); const loaded = await this.loadReportsInSearchOrder(reportIds); const orderedReports = loaded.filter((report): report is IARSubmission => report !== null); const orphanedReportIds = reportIds.filter((_, index) => loaded[index] === null).map((id) => id.toString()); if (orphanedReportIds.length > 0) { Logger.warn( {orphanedReportIds}, 'Report search index lists reports that are no longer stored, run refresh_search_index reports', ); } const reports = await Promise.all( orderedReports.map((report) => this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache)), ); return { reports, total, offset: data.offset, limit: data.limit, }; } private loadReportsInSearchOrder(reportIds: Array): Promise> { return Promise.all( reportIds.map(async (reportId) => { try { return await this.deps.reportService.getReport(reportId); } catch (error) { if (error instanceof UnknownReportError) { return null; } throw error; } }), ); } private async mapReportToResponse( report: IARSubmission, includeContext: boolean, requestCache: RequestCache, acls: ReadonlySet, reportLookupCache: ReportLookupCache, ) { const reporterInfo = await this.buildUserTag(report.reporterId, requestCache); const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache); const canViewReporterPii = acls.has(AdminACLs.REPORT_VIEW_REPORTER_PII) || acls.has(AdminACLs.WILDCARD); const reportedGuildNsfwLevel = report.reportedGuildNsfw !== null ? report.reportedGuildNsfw ? 3 : 0 : await this.getGuildNsfwLevelForContext( report.reportedChannelId, report.reportedGuildId ?? report.guildContextId ?? null, reportLookupCache, ); const reportedChannelNsfw = report.reportedChannelEffectiveNsfw !== null ? report.reportedChannelEffectiveNsfw : await this.getChannelNsfwState(report.reportedChannelId, reportLookupCache); const baseResponse = { report_id: report.reportId.toString(), reporter_id: report.reporterId?.toString() ?? null, reporter_tag: reporterInfo?.tag ?? null, reporter_username: reporterInfo?.username ?? null, reporter_global_name: reporterInfo?.global_name ?? null, reporter_discriminator: reporterInfo?.discriminator ?? null, reporter_email: canViewReporterPii ? await this.getReporterEmail(report, reportLookupCache) : null, reporter_full_legal_name: canViewReporterPii ? report.reporterFullLegalName : null, reporter_country_of_residence: canViewReporterPii ? report.reporterCountryOfResidence : null, reported_at: report.reportedAt.toISOString(), status: report.status, report_type: report.reportType, category: report.category, ...mapReportReasonFields(report), additional_info: report.additionalInfo, reported_user_id: report.reportedUserId?.toString() ?? null, reported_user_tag: reportedUserInfo?.tag ?? null, reported_user_username: reportedUserInfo?.username ?? null, reported_user_global_name: reportedUserInfo?.global_name ?? null, reported_user_discriminator: reportedUserInfo?.discriminator ?? null, reported_user_avatar_hash: report.reportedUserAvatarHash, reported_user_bot: reportedUserInfo?.bot ?? null, reported_webhook_id: report.reportedWebhookId?.toString() ?? null, reported_webhook_name: report.reportedWebhookName, reported_webhook_avatar_hash: report.reportedWebhookAvatarHash, reported_webhook_default_name: report.reportedWebhookDefaultName, reported_webhook_default_avatar_hash: report.reportedWebhookDefaultAvatarHash, reported_webhook_type: report.reportedWebhookType, reported_webhook_application_id: report.reportedWebhookApplicationId?.toString() ?? null, reported_webhook_channel_id: report.reportedWebhookChannelId?.toString() ?? null, reported_webhook_guild_id: report.reportedWebhookGuildId?.toString() ?? null, reported_webhook_created_at: report.reportedWebhookCreatedAt?.toISOString() ?? null, ...mapWebhookCreatorFields(report), reported_guild_id: report.reportedGuildId?.toString() ?? null, reported_guild_name: report.reportedGuildName, reported_guild_icon_hash: report.reportedGuildIconHash, reported_message_id: report.reportedMessageId?.toString() ?? null, reported_channel_id: report.reportedChannelId?.toString() ?? null, reported_channel_name: report.reportedChannelName, reported_channel_nsfw: reportedChannelNsfw, reported_guild_invite_code: report.reportedGuildInviteCode, reported_guild_nsfw_level: reportedGuildNsfwLevel, reported_guild_nsfw: report.reportedGuildNsfw, reported_guild_content_warning_level: report.reportedGuildContentWarningLevel, reported_guild_content_warning_text: report.reportedGuildContentWarningText, reported_channel_nsfw_override: report.reportedChannelNsfwOverride, reported_channel_content_warning_level: report.reportedChannelContentWarningLevel, reported_channel_content_warning_text: report.reportedChannelContentWarningText, reported_channel_effective_nsfw: report.reportedChannelEffectiveNsfw, reported_channel_effective_content_warning_level: report.reportedChannelEffectiveContentWarningLevel, reported_channel_effective_content_warning_text: report.reportedChannelEffectiveContentWarningText, resolved_at: report.resolvedAt?.toISOString() ?? null, resolved_by_admin_id: report.resolvedByAdminId?.toString() ?? null, public_comment: report.publicComment, }; if (!includeContext) { return baseResponse; } const authorBotFlags = await this.getContextAuthorBotFlags(report, requestCache); const messageContext = ( await Promise.all( (report.messageContext ?? []).map((message) => this.mapReportMessageContextToResponse( message, report.reportedChannelId ?? null, report.reportedGuildId ?? report.guildContextId ?? null, reportLookupCache, authorBotFlags, ), ), ) ).filter((message) => message !== null); const messageResponses = await this.getLiveMessageResponsesForContext(report); const mutualDmChannelId = await this.getMutualDmChannelId(report); return { ...baseResponse, mutual_dm_channel_id: mutualDmChannelId, message_context: messageContext, message_responses: messageResponses, reported_profile_snapshot: await this.mapProfileSnapshotToResponse(report), legal_hold_until: report.legalHoldUntil?.toISOString() ?? null, legal_hold_reason: report.legalHoldReason, }; } private getReporterEmail(report: IARSubmission, reportLookupCache: ReportLookupCache): Promise { const reporterId = report.reporterId; if (!reporterId) { return Promise.resolve(report.reporterEmail); } const key = reporterId.toString(); const cached = reportLookupCache.reporterEmailByReporterId.get(key); if (cached) { return cached; } const email = this.deps.apiContext.services.users.findUnique(reporterId).then((user) => user?.email ?? null); reportLookupCache.reporterEmailByReporterId.set(key, email); return email; } private async getContextAuthorBotFlags( report: IARSubmission, requestCache: RequestCache, ): Promise> { const authorIds = [ ...new Set((report.messageContext ?? []).flatMap((message) => (message.authorId ? [message.authorId] : []))), ]; if (authorIds.length === 0) { return new Map(); } try { const partials = await this.deps.userCacheService.getUserPartialResponses(authorIds, requestCache); return new Map([...partials].map(([userId, partial]) => [userId.toString(), mapUserPartialBotFlag(partial)])); } catch (error) { Logger.warn({reportId: report.reportId.toString(), error}, 'Failed to resolve author bot flags for report'); return new Map(); } } private async mapProfileSnapshotToResponse(report: IARSubmission) { const snapshot = report.reportedProfileSnapshot; if (!snapshot) { return null; } const asset = (value: ReportProfileAssetSnapshot | null) => this.mapProfileSnapshotAsset(report.reportId, value); const {user, member, guild} = snapshot; return { captured_at: snapshot.captured_at, user: user ? { id: user.id, username: user.username, discriminator: user.discriminator === null ? null : user.discriminator.toString().padStart(4, '0'), global_name: user.global_name, bio: user.bio, pronouns: user.pronouns, avatar: await asset(user.avatar), banner: await asset(user.banner), } : null, member: member ? { guild_id: member.guild_id, nick: member.nick, bio: member.bio, pronouns: member.pronouns, joined_at: member.joined_at, avatar: await asset(member.avatar), banner: await asset(member.banner), } : null, guild: guild ? { id: guild.id, name: guild.name, vanity_url_code: guild.vanity_url_code, icon: await asset(guild.icon), banner: await asset(guild.banner), splash: await asset(guild.splash), } : null, }; } private async mapProfileSnapshotAsset( reportId: ReportID, asset: ReportProfileAssetSnapshot | null, ): Promise<{hash: string; url: string | null} | null> { if (!asset) { return null; } if (!asset.key) { return {hash: asset.hash, url: null}; } try { const url = await this.deps.storageService.getPresignedDownloadURL({ bucket: Config.s3.buckets.reports, key: asset.key, expiresIn: seconds('5 minutes'), }); return {hash: asset.hash, url}; } catch (error) { Logger.error( {error, reportId: reportId.toString(), key: asset.key}, 'Failed to generate presigned URL for report profile asset', ); return {hash: asset.hash, url: null}; } } private async getLiveMessageResponsesForContext(report: IARSubmission): Promise> { const contexts = report.messageContext ?? []; if (contexts.length === 0) return []; const responses = await Promise.all( contexts.map(async (message) => { const channelId = message.channelId ?? report.reportedChannelId; if (!channelId) return null; try { const access = await adminMessageResponseAccess(this.deps.channelRepository, channelId); return await createMessageResponseDataService().getMessage({ userId: createUserID(0n), channelId, messageId: message.messageId, access, }); } catch (error) { Logger.warn( { error, reportId: report.reportId.toString(), channelId: channelId.toString(), messageId: message.messageId.toString(), }, 'Failed to resolve live admin report message response', ); return null; } }), ); return responses.filter((message): message is MessageResponse => message !== null); } private async getMutualDmChannelId(report: IARSubmission): Promise { if (report.reportType !== 1 || !report.reporterId || !report.reportedUserId) { return null; } const {users: userRepository} = this.deps.apiContext.services; const mutualDmChannel = await userRepository.findExistingDmState(report.reporterId, report.reportedUserId); return mutualDmChannel ? mutualDmChannel.id.toString() : null; } private async mapReportMessageContextToResponse( message: IARMessageContext, fallbackChannelId: ChannelID | null, fallbackGuildId: GuildID | null, reportLookupCache: ReportLookupCache, authorBotFlags: Map, ) { const channelId = message.channelId ?? fallbackChannelId; if (!channelId) { return null; } const authorId = (message.authorId ?? message.webhookId ?? 0n).toString(); const channelNsfw = await this.getChannelNsfwState(channelId, reportLookupCache); const guildNsfwLevel = await this.getGuildNsfwLevelForContext(channelId, fallbackGuildId, reportLookupCache); const attachments = message.attachments && message.attachments.length > 0 ? ( await Promise.all( message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)), ) ).filter( ( attachment, ): attachment is { id: string; filename: string; url: string; nsfw: boolean | null; content_type: string | null; width: number | null; height: number | null; size: number | null; } => attachment !== null, ) : []; return { id: message.messageId.toString(), channel_id: channelId.toString(), channel_nsfw: channelNsfw, channel_content_warning_level: null, channel_content_warning_text: null, guild_id: fallbackGuildId ? fallbackGuildId.toString() : null, guild_nsfw_level: guildNsfwLevel, guild_nsfw: null, guild_content_warning_level: null, guild_content_warning_text: null, content: message.content ?? '', timestamp: message.timestamp.toISOString(), attachments, author_id: authorId, author_username: message.authorUsername, author_global_name: null, author_discriminator: message.authorDiscriminator.toString().padStart(4, '0'), author_avatar: message.authorAvatarHash, webhook_id: message.webhookId?.toString() ?? null, author_bot: message.authorId ? (authorBotFlags.get(authorId) ?? null) : null, missing_attachments: message.missingAttachments .filter((attachment) => attachment.attachment_id != null && attachment.filename) .map(mapMissingAttachmentToResponse), }; } private async getChannelNsfwState( channelId: ChannelID | null, reportLookupCache: ReportLookupCache, ): Promise { if (!channelId) { return null; } const channelIdString = channelId.toString(); if (reportLookupCache.channelNsfwByChannelId.has(channelIdString)) { return reportLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null; } const channel = await this.deps.channelRepository.channelData.findUnique(channelId); const scope = channel ? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.channelData.findUnique(id)) : null; const channelNsfw = scope?.isNsfw ?? null; reportLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw); return channelNsfw; } private async getGuildNsfwLevel( guildId: GuildID | null, reportLookupCache: ReportLookupCache, ): Promise { if (!guildId) { return null; } const guildIdString = guildId.toString(); if (reportLookupCache.guildNsfwLevelByGuildId.has(guildIdString)) { return reportLookupCache.guildNsfwLevelByGuildId.get(guildIdString) ?? null; } const guild = await this.deps.guildRepository.findUnique(guildId); const guildNsfwLevel = guild?.nsfwLevel ?? null; reportLookupCache.guildNsfwLevelByGuildId.set(guildIdString, guildNsfwLevel); return guildNsfwLevel; } private async getGuildNsfwLevelForContext( channelId: ChannelID | null, fallbackGuildId: GuildID | null, reportLookupCache: ReportLookupCache, ): Promise { if (fallbackGuildId) { return this.getGuildNsfwLevel(fallbackGuildId, reportLookupCache); } if (!channelId) { return null; } const channel = await this.deps.channelRepository.channelData.findUnique(channelId); if (!channel?.guildId) { return null; } return this.getGuildNsfwLevel(channel.guildId, reportLookupCache); } private async mapReportAttachmentToResponse( attachment: MessageAttachment, channelId: ChannelID | null, ): Promise<{ id: string; filename: string; url: string; nsfw: boolean | null; content_type: string | null; width: number | null; height: number | null; size: number | null; } | null> { if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) { return null; } const {storageService} = this.deps; const attachmentId = attachment.attachment_id; const filename = String(attachment.filename); const key = makeAttachmentCdnKey(channelId, attachmentId, filename); try { const url = await storageService.getPresignedDownloadURL({ bucket: Config.s3.buckets.reports, key, expiresIn: seconds('5 minutes'), }); return { id: attachment.attachment_id.toString(), filename, url, nsfw: attachment.nsfw ?? null, content_type: attachment.content_type ?? null, width: attachment.width ?? null, height: attachment.height ?? null, size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null, }; } catch (error) { Logger.error( {error, attachmentId, filename, channelId}, 'Failed to generate presigned URL for report attachment', ); } return null; } private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise { if (!userId) { return null; } try { const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache); const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null; const user = stored ? { ...cached, username: stored.username, global_name: stored.globalName, discriminator: stored.discriminator.toString(), bot: stored.isBot, } : cached; const discriminator = user.discriminator?.padStart(4, '0') ?? '0000'; return { tag: formatUserTag({ username: user.username, discriminator: Number.parseInt(discriminator, 10), isBot: user.bot ?? false, }), username: user.username, global_name: user.global_name ?? null, discriminator, bot: mapUserPartialBotFlag(user), }; } catch (error) { Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report'); return null; } } } function mapWebhookCreatorFields(report: IARSubmission) { const creatorDiscriminator = report.reportedWebhookCreatorDiscriminator; const discriminator = creatorDiscriminator === null ? null : creatorDiscriminator.toString().padStart(4, '0'); const username = report.reportedWebhookCreatorUsername; const creatorId = report.reportedWebhookCreatorId; return { reported_webhook_creator_id: creatorId?.toString() ?? null, reported_webhook_creator_tag: username !== null && creatorDiscriminator !== null ? formatUserTag({ username, discriminator: creatorDiscriminator, isBot: creatorId !== null && creatorId.toString() === report.reportedWebhookApplicationId?.toString(), }) : null, reported_webhook_creator_username: username, reported_webhook_creator_global_name: report.reportedWebhookCreatorGlobalName, reported_webhook_creator_discriminator: discriminator, reported_webhook_creator_avatar_hash: report.reportedWebhookCreatorAvatarHash, }; } interface UserTagInfo { tag: string; username: string; global_name: string | null; discriminator: string; bot: boolean | null; }