Files
fluxer/fluxer_api/src/api/csam/NcmecSubmissionService.ts
T

1038 lines
37 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminArchiveService} from '@app/api/admin/services/AdminArchiveService';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {
type AttachmentID,
type ChannelID,
createAttachmentID,
createChannelID,
createMessageID,
createUserID,
type MessageID,
type ReportID,
type UserID,
} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
import {
collectMessageAttachments,
makeAttachmentCdnKey,
makeAttachmentCdnUrl,
purgeMessageAttachments,
} from '@app/api/channel/services/message/MessageHelpers';
import type {NcmecApiClient} from '@app/api/csam/NcmecReporter';
import {buildNcmecFileDetailsXml, buildNcmecReportXml} from '@app/api/csam/NcmecReporter';
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {AttachmentUploadTraceByAttachmentRow} from '@app/api/database/types/AttachmentUploadTypes';
import type {NcmecAttachmentSubmissionRow, NcmecUserWorkflowRow} from '@app/api/database/types/CsamTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import {Logger} from '@app/api/Logger';
import type {Embed} from '@app/api/models/Embed';
import type {EmbedMedia} from '@app/api/models/EmbedMedia';
import type {Message} from '@app/api/models/Message';
import type {User} from '@app/api/models/User';
import type {IARMessageContext, IARSubmission} from '@app/api/report/IReportRepository';
import type {ReportRepository} from '@app/api/report/ReportRepository';
import {deleteMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {NcmecAlreadySubmittedError} from '@fluxer/errors/src/domains/moderation/NcmecAlreadySubmittedError';
import {NcmecSubmissionFailedError} from '@fluxer/errors/src/domains/moderation/NcmecSubmissionFailedError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import type {NcmecSubmissionStatus} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
export interface NcmecAttachmentStatusResponse {
status: NcmecSubmissionStatus;
ncmec_report_id: string | null;
submitted_at: string | null;
submitted_by_admin_id: string | null;
failure_reason: string | null;
}
interface NcmecAttachmentSubmitResult {
success: true;
ncmec_report_id: string;
audit_log_reason: string;
}
interface NcmecUserUpdatePropagator {
propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void>;
}
interface SubmitAttachmentToNcmecInput {
channelId: ChannelID;
messageId: MessageID;
attachmentId: AttachmentID;
filename: string;
reporterFullName: string;
adminUserId: UserID;
sourceReportId?: ReportID | null;
}
interface ResolvedAttachment {
channelId: ChannelID;
messageId: MessageID;
attachmentId: AttachmentID;
filename: string;
contentType: string | null;
reportedAt: Date;
userId: UserID | null;
sourceReportId: ReportID | null;
cdnBucket: string;
storageKey: string;
cdnUrl: string;
}
interface ReportedUserSnapshot {
screenName: string | null;
displayName: string | null;
email: string | null;
emailVerified: boolean;
dateOfBirth: string | null;
lastActiveIp: string | null;
}
interface NcmecSubmissionServiceDeps {
reportRepository: ReportRepository;
ncmecApi: NcmecApiClient;
ncmecRepository: NcmecRepository;
attachmentUploadTraceRepository: AttachmentUploadTraceRepository;
storageService: IStorageService;
channelRepository: IChannelRepository;
userRepository: IUserRepository;
guildRepository: IGuildRepositoryAggregate;
gatewayService: IGatewayService;
userCacheService: UserCacheService;
adminArchiveService: AdminArchiveService;
adminAuditService: AdminAuditService;
purgeQueue: IPurgeQueue;
workerService: IWorkerService<WorkerTaskName>;
deletionQueue: KVAccountDeletionQueueService;
updatePropagator?: NcmecUserUpdatePropagator;
}
const NCMEC_DELETION_GRACE_DAYS = 60;
const DEFAULT_FINALIZE_TASK_DELAY_MS = ms('15 seconds');
const DEFAULT_MAX_FINALIZE_REQUEUES = 240;
function getFinalizeDelayMs(): number {
const raw = (
Config.ncmec as {
finalizeDelayMs?: number;
}
).finalizeDelayMs;
return Number.isFinite(raw) && raw! > 0 ? raw! : DEFAULT_FINALIZE_TASK_DELAY_MS;
}
function getMaxFinalizeRequeues(): number {
const raw = (
Config.ncmec as {
maxFinalizeRequeues?: number;
}
).maxFinalizeRequeues;
return Number.isFinite(raw) && raw! > 0 ? raw! : DEFAULT_MAX_FINALIZE_REQUEUES;
}
export class NcmecSubmissionService {
private readonly updatePropagator: NcmecUserUpdatePropagator;
constructor(private readonly deps: NcmecSubmissionServiceDeps) {
this.updatePropagator =
deps.updatePropagator ??
new AdminUserUpdatePropagator({
userCacheService: deps.userCacheService,
userRepository: deps.userRepository,
guildRepository: deps.guildRepository,
gatewayService: deps.gatewayService,
});
}
async assertChildSafetyReport(reportId: ReportID): Promise<IARSubmission> {
const report = await this.deps.reportRepository.getReport(reportId);
if (!report || report.category !== CATEGORY_CHILD_SAFETY) {
throw new UnknownReportError();
}
return report;
}
async getAttachmentStatus(attachmentId: AttachmentID): Promise<NcmecAttachmentStatusResponse> {
const submission = await this.deps.ncmecRepository.getAttachmentSubmission(attachmentId);
return toAttachmentStatusResponse(submission);
}
async getAttachmentStatuses(attachmentIds: Array<AttachmentID>): Promise<Map<string, NcmecAttachmentStatusResponse>> {
const ids = [...new Set(attachmentIds.map((id) => id.toString()))].map((value) =>
createAttachmentID(BigInt(value)),
);
const rows = await Promise.all(ids.map((id) => this.getAttachmentStatus(id)));
const result = new Map<string, NcmecAttachmentStatusResponse>();
for (let i = 0; i < ids.length; i++) {
result.set(ids[i]!.toString(), rows[i]!);
}
return result;
}
async getUserPriorReportIds(userIds: Array<UserID>): Promise<Map<string, Array<string>>> {
const unique = [...new Set(userIds.map((id) => id.toString()))];
const out = new Map<string, Array<string>>();
await Promise.all(
unique.map(async (value) => {
const workflow = await this.deps.ncmecRepository.getUserWorkflow(createUserID(BigInt(value)));
if (workflow?.previous_report_ids && workflow.previous_report_ids.size > 0) {
out.set(value, [...workflow.previous_report_ids].sort());
}
}),
);
return out;
}
async submitAttachmentToNcmec(input: SubmitAttachmentToNcmecInput): Promise<NcmecAttachmentSubmitResult> {
const existing = await this.deps.ncmecRepository.getAttachmentSubmission(input.attachmentId);
if (existing?.status === 'submitted' || existing?.status === 'submitting') {
throw new NcmecAlreadySubmittedError();
}
const attachment = await this.resolveAttachment(input);
const adminUser = await this.deps.userRepository.findUnique(input.adminUserId);
const reporterEmail = requireReporterEmail((Config.ncmec.reporterEmail?.trim() || adminUser?.email) ?? null);
const reportedUser = await this.snapshotReportedUser(attachment.userId);
const attachmentUploadTrace = await this.deps.attachmentUploadTraceRepository.getByAttachmentId(
attachment.attachmentId,
);
const existingWorkflow = attachment.userId
? await this.deps.ncmecRepository.getUserWorkflow(attachment.userId)
: null;
const priorReportIds = [...(existingWorkflow?.previous_report_ids ?? new Set<string>())].sort();
const contextLabel = `${attachment.channelId.toString()}/${attachment.attachmentId.toString()}`;
const reportAdditionalInfo = buildAdditionalInfo({
channelId: attachment.channelId,
messageId: attachment.messageId,
attachmentId: attachment.attachmentId,
userId: attachment.userId,
sourceReportId: attachment.sourceReportId,
priorReportIds,
reportedUser,
attachmentUploadTrace,
});
const fileAdditionalInfo = reportAdditionalInfo;
const reportXml = buildNcmecReportXml({
attachmentUrl: attachment.cdnUrl,
reportedAt: attachment.reportedAt,
reporterFullName: input.reporterFullName,
reporterEmail,
reportedUser: {
id: attachment.userId !== null ? BigInt(attachment.userId) : null,
screenName: reportedUser.screenName,
displayName: reportedUser.displayName,
espService: 'Fluxer',
permanentlyDisabledAt: existingWorkflow?.deleted_at ?? null,
person: buildReportedUserPerson(reportedUser),
ipCaptureEvents: buildReportedUserIpCaptureEvents(attachmentUploadTrace),
},
priorNcmecReportIds: priorReportIds,
additionalInfo: reportAdditionalInfo,
});
await this.writeAttachmentSubmission({
existing,
attachment,
status: 'submitting',
ncmecReportId: null,
ncmecFileId: null,
reporterFullName: input.reporterFullName,
adminUserId: input.adminUserId,
failureReason: null,
});
let ncmecReportId: string | null = null;
try {
ncmecReportId = await this.deps.ncmecApi.submitReport(reportXml);
const upload = await this.deps.ncmecApi.uploadEvidence(
ncmecReportId,
await this.readAttachmentBytes(attachment),
attachment.filename,
);
await this.deps.ncmecApi.submitFileDetails(
buildNcmecFileDetailsXml({
reportId: ncmecReportId,
fileId: upload.fileId,
filename: attachment.filename,
uploadedToEspTimestamp: getBestEffortUploadTimestamp(attachmentUploadTrace),
fileViewedByEsp: true,
ipCaptureEvent: buildBestEffortUploadIpCaptureEvent(attachmentUploadTrace),
additionalInfo: fileAdditionalInfo,
}),
);
await this.deps.ncmecApi.finish(ncmecReportId);
await this.writeAttachmentSubmission({
existing,
attachment,
status: 'submitted',
ncmecReportId,
ncmecFileId: upload.fileId,
reporterFullName: input.reporterFullName,
adminUserId: input.adminUserId,
failureReason: null,
});
const auditLogReason = formatNcmecAuditLogReason(ncmecReportId, contextLabel);
if (attachment.userId !== null) {
await this.deps.adminAuditService.createAuditLog({
adminUserId: input.adminUserId,
targetType: 'user',
targetId: BigInt(attachment.userId),
action: 'NCMEC Report',
auditLogReason,
metadata: buildAuditMetadata({attachment, reportedUser, attachmentUploadTrace}),
});
const workflow = await this.advanceUserWorkflow({
userId: attachment.userId,
adminUserId: input.adminUserId,
ncmecReportId,
auditLogReason,
contextLabel,
existingWorkflow,
});
await this.deps.workerService.addJob(
'finalizeNcmecAttachmentReport',
{attachmentId: attachment.attachmentId.toString(), requeueCount: 0},
{jobKey: finalizeJobKey(attachment.attachmentId), runAt: new Date(Date.now() + getFinalizeDelayMs())},
);
Logger.info(
{
attachmentId: attachment.attachmentId.toString(),
userId: attachment.userId.toString(),
ncmecReportId,
archiveId: workflow.archive_id?.toString() ?? null,
},
'NCMEC attachment report submitted',
);
} else {
await this.deps.adminAuditService.createAuditLog({
adminUserId: input.adminUserId,
targetType: 'report',
targetId: BigInt(attachment.sourceReportId ?? input.attachmentId),
action: 'NCMEC Report',
auditLogReason,
metadata: buildAuditMetadata({attachment, reportedUser, attachmentUploadTrace}),
});
Logger.info(
{
attachmentId: attachment.attachmentId.toString(),
sourceReportId: attachment.sourceReportId?.toString() ?? null,
ncmecReportId,
},
'NCMEC attachment report submitted (legacy report, unknown author — skipped ban/archive)',
);
}
return {success: true, ncmec_report_id: ncmecReportId, audit_log_reason: auditLogReason};
} catch (error) {
if (ncmecReportId) {
try {
await this.deps.ncmecApi.retract(ncmecReportId);
} catch (retractError) {
Logger.warn(
{error: retractError, attachmentId: input.attachmentId.toString(), ncmecReportId},
'Failed to retract failed NCMEC attachment submission',
);
}
}
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
await this.writeAttachmentSubmission({
existing,
attachment,
status: 'failed',
ncmecReportId: null,
ncmecFileId: null,
reporterFullName: input.reporterFullName,
adminUserId: input.adminUserId,
failureReason: rawMessage,
});
Logger.error(
{
error,
attachmentId: input.attachmentId.toString(),
channelId: input.channelId.toString(),
messageId: input.messageId.toString(),
},
'NCMEC attachment report submission failed',
);
throw new NcmecSubmissionFailedError(sanitizeFailureMessage(rawMessage));
}
}
async finalizeAttachmentReport(attachmentId: AttachmentID, requeueCount = 0): Promise<void> {
const submission = await this.deps.ncmecRepository.getAttachmentSubmission(attachmentId);
if (!submission || submission.status !== 'submitted' || submission.content_deleted_at) {
return;
}
if (submission.user_id === null) {
Logger.warn({attachmentId: attachmentId.toString()}, 'Skipping finalize: submission has no user_id');
return;
}
const submissionUserId = submission.user_id;
const workflow = await this.deps.ncmecRepository.getUserWorkflow(createUserID(submissionUserId));
if (!workflow) {
Logger.warn({attachmentId: attachmentId.toString()}, 'Missing NCMEC user workflow during finalization');
return;
}
const maxRequeues = getMaxFinalizeRequeues();
if (requeueCount >= maxRequeues) {
Logger.error(
{
attachmentId: attachmentId.toString(),
requeueCount,
maxRequeues,
archiveId: workflow.archive_id?.toString() ?? null,
},
'NCMEC finalize gave up after max requeues; manual intervention required',
);
return;
}
const auditLogReason =
workflow.archive_audit_log_reason ??
(submission.ncmec_report_id
? formatNcmecAuditLogReason(
submission.ncmec_report_id,
`${submission.channel_id.toString()}/${submission.attachment_id.toString()}`,
)
: null);
const requesterAdminId =
workflow.archive_requested_by_admin_id ??
workflow.deleted_by_admin_id ??
submission.submitted_by_admin_id ??
null;
if (requesterAdminId === null) {
Logger.error(
{attachmentId: attachmentId.toString(), userId: submissionUserId.toString()},
'NCMEC finalize missing requester admin id; manual intervention required',
);
return;
}
const requester = createUserID(requesterAdminId);
const refreshedWorkflow = await this.ensureArchiveAfterBan(
workflow,
createUserID(submissionUserId),
requester,
auditLogReason,
);
if (!refreshedWorkflow.archive_id) {
await this.requeueFinalizer(attachmentId, requeueCount + 1);
return;
}
const archive = await this.deps.adminArchiveService.getArchive(
'user',
submissionUserId,
refreshedWorkflow.archive_id,
);
if (!archive || !archive.completed_at) {
await this.requeueFinalizer(attachmentId, requeueCount + 1);
return;
}
const archiveCompletedAt = new Date(archive.completed_at);
if (!refreshedWorkflow.archive_completed_at) {
await this.deps.ncmecRepository.upsertUserWorkflow({
...refreshedWorkflow,
archive_completed_at: archiveCompletedAt,
updated_at: new Date(),
});
}
await this.deleteMessageSilently(
createChannelID(submission.channel_id),
createMessageID(submission.message_id),
createUserID(submissionUserId),
);
await this.deps.ncmecRepository.upsertAttachmentSubmission({
...submission,
archive_completed_at: archiveCompletedAt,
content_deleted_at: new Date(),
updated_at: new Date(),
});
if (!refreshedWorkflow.deletion_job_queued_at) {
await this.deps.ncmecRepository.upsertUserWorkflow({
...refreshedWorkflow,
archive_completed_at: archiveCompletedAt,
deletion_job_queued_at: new Date(),
updated_at: new Date(),
});
}
}
private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> {
const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId);
if (liveMessage?.authorId) {
const liveAttachment = collectMessageAttachments(liveMessage).find(
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
);
if (liveAttachment) {
return this.buildAttachmentContext(input, {
contentType: liveAttachment.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
const embedMedia = findEmbedReferencedAttachmentMedia(
liveMessage,
makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename),
);
if (embedMedia) {
return this.buildAttachmentContext(input, {
contentType: embedMedia.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
}
if (!input.sourceReportId) {
throw new UnknownMessageError();
}
const report = await this.deps.reportRepository.getReport(input.sourceReportId);
if (!report) {
throw new UnknownReportError();
}
const context = findAttachmentInReport(
report,
input.channelId,
input.messageId,
input.attachmentId,
input.filename,
);
if (context) {
return this.buildAttachmentContext(input, {
contentType: context.attachment.content_type,
reportedAt: context.message.timestamp,
userId: context.message.authorId,
sourceReportId: input.sourceReportId,
bucket: Config.s3.buckets.reports,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
throw new UnknownMessageError();
}
private buildAttachmentContext(
input: SubmitAttachmentToNcmecInput,
context: {
contentType: string | null;
reportedAt: Date;
userId: UserID | null;
sourceReportId: ReportID | null;
bucket: string;
storageKey: string;
},
): ResolvedAttachment {
validateImageAttachment(context.contentType);
return {
channelId: input.channelId,
messageId: input.messageId,
attachmentId: input.attachmentId,
filename: input.filename,
contentType: context.contentType,
reportedAt: context.reportedAt,
userId: context.userId,
sourceReportId: context.sourceReportId,
cdnBucket: context.bucket,
storageKey: context.storageKey,
cdnUrl: makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename),
};
}
private async readAttachmentBytes(attachment: ResolvedAttachment): Promise<Uint8Array> {
const buffer = await this.deps.storageService.readObject(attachment.cdnBucket, attachment.storageKey);
return new Uint8Array(buffer);
}
private async snapshotReportedUser(userId: UserID | null): Promise<ReportedUserSnapshot> {
if (userId === null) {
return {
screenName: null,
displayName: null,
email: null,
emailVerified: false,
dateOfBirth: null,
lastActiveIp: null,
};
}
const user = await this.deps.userRepository.findUnique(userId);
if (!user) {
return {
screenName: null,
displayName: null,
email: null,
emailVerified: false,
dateOfBirth: null,
lastActiveIp: null,
};
}
return {
screenName: user.username ?? null,
displayName: user.globalName ?? null,
email: user.email ?? null,
emailVerified: user.emailVerified ?? false,
dateOfBirth: user.dateOfBirth ?? null,
lastActiveIp: user.lastActiveIp ?? null,
};
}
private async advanceUserWorkflow(args: {
userId: UserID;
adminUserId: UserID;
ncmecReportId: string;
auditLogReason: string;
contextLabel: string;
existingWorkflow: NcmecUserWorkflowRow | null;
}): Promise<NcmecUserWorkflowRow> {
const now = new Date();
let workflow: NcmecUserWorkflowRow = args.existingWorkflow ?? {
user_id: BigInt(args.userId),
deleted_at: null,
deleted_by_admin_id: null,
deletion_private_reason: null,
deletion_ncmec_report_id: null,
archive_id: null,
archive_requested_at: null,
archive_requested_by_admin_id: null,
archive_audit_log_reason: null,
archive_completed_at: null,
deletion_job_queued_at: null,
previous_report_ids: null,
created_at: now,
updated_at: now,
};
workflow = await this.banUserIfNeeded(
workflow,
args.userId,
args.adminUserId,
args.ncmecReportId,
args.contextLabel,
);
workflow = await this.ensureArchiveAfterBan(workflow, args.userId, args.adminUserId, args.auditLogReason);
const priorReportIds = new Set(workflow.previous_report_ids ?? []);
priorReportIds.add(args.ncmecReportId);
const updated: NcmecUserWorkflowRow = {
...workflow,
archive_audit_log_reason: workflow.archive_audit_log_reason ?? args.auditLogReason,
previous_report_ids: priorReportIds,
updated_at: new Date(),
};
await this.deps.ncmecRepository.upsertUserWorkflow(updated);
return updated;
}
private async banUserIfNeeded(
workflow: NcmecUserWorkflowRow,
userId: UserID,
adminUserId: UserID,
ncmecReportId: string,
contextLabel: string,
): Promise<NcmecUserWorkflowRow> {
if (workflow.deleted_at) return workflow;
const user = await this.deps.userRepository.findUnique(userId);
if (!user) return workflow;
const privateReason = `Confirmed CSAM - NCMEC Report ${ncmecReportId} - ${contextLabel}`;
const pendingDeletionAt = new Date();
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + NCMEC_DELETION_GRACE_DAYS);
const updatedUser = await this.deps.userRepository.updateDeletionSchedule(user, {
flags: user.flags | UserFlags.DELETED | UserFlags.DISABLED,
temp_banned_until: null,
pending_deletion_at: pendingDeletionAt,
deletion_reason_code: DeletionReasons.CHILD_SEXUAL_CONTENT,
deletion_public_reason: null,
deletion_audit_log_reason: privateReason,
});
await reschedulePendingDeletion({
userId,
currentPendingDeletionAt: user.pendingDeletionAt,
nextPendingDeletionAt: pendingDeletionAt,
deletionReasonCode: DeletionReasons.CHILD_SEXUAL_CONTENT,
userRepository: this.deps.userRepository,
deletionQueue: this.deps.deletionQueue,
});
await this.deps.userRepository.deleteAllAuthSessions(userId);
await this.updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
return {
...workflow,
deleted_at: new Date(),
deleted_by_admin_id: BigInt(adminUserId),
deletion_private_reason: privateReason,
deletion_ncmec_report_id: ncmecReportId,
updated_at: new Date(),
};
}
private async ensureArchiveAfterBan(
workflow: NcmecUserWorkflowRow,
userId: UserID,
adminUserId: UserID,
auditLogReason: string | null,
): Promise<NcmecUserWorkflowRow> {
if (BigInt(adminUserId) === 0n) return workflow;
const banAt = workflow.deleted_at;
const archivedAt = workflow.archive_requested_at;
const hasPostBanArchive =
workflow.archive_id !== null && archivedAt !== null && banAt !== null && archivedAt >= banAt;
if (hasPostBanArchive) return workflow;
if (workflow.archive_id !== null && banAt === null) return workflow;
const archive = await this.deps.adminArchiveService.triggerUserArchive(userId, adminUserId, true);
await this.deps.adminAuditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'trigger_user_archive',
auditLogReason,
metadata: new Map([['archive_id', archive.archive_id]]),
});
return {
...workflow,
archive_id: BigInt(archive.archive_id),
archive_requested_at: new Date(),
archive_requested_by_admin_id: BigInt(adminUserId),
archive_audit_log_reason: workflow.archive_audit_log_reason ?? auditLogReason,
archive_completed_at: null,
deletion_job_queued_at: null,
updated_at: new Date(),
};
}
private async writeAttachmentSubmission(args: {
existing: NcmecAttachmentSubmissionRow | null;
attachment: ResolvedAttachment;
status: 'submitting' | 'submitted' | 'failed';
ncmecReportId: string | null;
ncmecFileId: string | null;
reporterFullName: string;
adminUserId: UserID;
failureReason: string | null;
}): Promise<void> {
const now = new Date();
await this.deps.ncmecRepository.upsertAttachmentSubmission({
attachment_id: BigInt(args.attachment.attachmentId),
user_id: args.attachment.userId !== null ? BigInt(args.attachment.userId) : null,
channel_id: BigInt(args.attachment.channelId),
message_id: BigInt(args.attachment.messageId),
filename: args.attachment.filename,
source_report_id: args.attachment.sourceReportId ? BigInt(args.attachment.sourceReportId) : null,
status: args.status,
ncmec_report_id: args.ncmecReportId,
ncmec_file_id: args.ncmecFileId,
submitted_at: args.status === 'submitted' ? now : null,
submitted_by_admin_id: BigInt(args.adminUserId),
reporter_full_name: args.reporterFullName,
failure_reason: args.failureReason,
archive_completed_at: args.existing?.archive_completed_at ?? null,
content_deleted_at: args.existing?.content_deleted_at ?? null,
created_at: args.existing?.created_at ?? now,
updated_at: now,
});
}
private async deleteMessageSilently(
channelId: ChannelID,
messageId: MessageID,
fallbackUserId: UserID,
): Promise<void> {
const channel = await this.deps.channelRepository.findUnique(channelId);
const message = await this.deps.channelRepository.getMessage(channelId, messageId);
if (!message) return;
if (message.attachments.length > 0) {
await purgeMessageAttachments(message, this.deps.storageService, this.deps.purgeQueue);
}
await this.deps.channelRepository.deleteMessage(
channelId,
messageId,
message.authorId ?? fallbackUserId,
message.pinnedTimestamp || undefined,
);
await deleteMessageSearchDocuments([messageId], {context: {source: 'ncmec_submission_delete'}});
if (!channel) return;
if (channel.guildId) {
await this.deps.gatewayService.dispatchGuild({
guildId: channel.guildId,
event: 'MESSAGE_DELETE',
data: {channel_id: channelId.toString(), id: messageId.toString()},
});
return;
}
for (const recipientId of channel.recipientIds) {
await this.deps.gatewayService.dispatchPresence({
userId: recipientId,
event: 'MESSAGE_DELETE',
data: {channel_id: channelId.toString(), id: messageId.toString()},
});
}
}
private async requeueFinalizer(attachmentId: AttachmentID, requeueCount: number): Promise<void> {
await this.deps.workerService.addJob(
'finalizeNcmecAttachmentReport',
{attachmentId: attachmentId.toString(), requeueCount},
{jobKey: finalizeJobKey(attachmentId), runAt: new Date(Date.now() + getFinalizeDelayMs())},
);
}
}
function toAttachmentStatusResponse(submission: NcmecAttachmentSubmissionRow | null): NcmecAttachmentStatusResponse {
if (!submission) {
return {
status: 'not_submitted',
ncmec_report_id: null,
submitted_at: null,
submitted_by_admin_id: null,
failure_reason: null,
};
}
return {
status: submission.status as NcmecSubmissionStatus,
ncmec_report_id: submission.ncmec_report_id,
submitted_at: submission.submitted_at?.toISOString() ?? null,
submitted_by_admin_id: submission.submitted_by_admin_id?.toString() ?? null,
failure_reason: submission.failure_reason,
};
}
function buildAdditionalInfo(args: {
channelId: ChannelID;
messageId: MessageID;
attachmentId: AttachmentID;
userId: UserID | null;
sourceReportId: ReportID | null;
priorReportIds: ReadonlyArray<string>;
reportedUser: ReportedUserSnapshot;
attachmentUploadTrace: AttachmentUploadTraceByAttachmentRow | null;
}): string {
const lines = [
'Fluxer admin attachment report.',
`Channel ID: ${args.channelId.toString()}`,
`Message ID: ${args.messageId.toString()}`,
`Attachment ID: ${args.attachmentId.toString()}`,
`Reported User ID: ${args.userId !== null ? args.userId.toString() : 'unknown'}`,
];
if (args.reportedUser.email) {
lines.push(`Reported User Email: ${args.reportedUser.email}`);
lines.push(`Reported User Email Verified: ${args.reportedUser.emailVerified ? 'true' : 'false'}`);
}
if (args.reportedUser.dateOfBirth) {
lines.push(`Reported User Date of Birth: ${args.reportedUser.dateOfBirth}`);
}
if (args.attachmentUploadTrace) {
lines.push(`Attachment Upload Mode: ${args.attachmentUploadTrace.upload_mode}`);
lines.push(`Attachment Upload Request IP: ${args.attachmentUploadTrace.request_ip}`);
lines.push(`Attachment Upload Request Time: ${args.attachmentUploadTrace.requested_at.toISOString()}`);
lines.push(`Attachment Upload IP Source: ${describeBestEffortUploadIpSource(args.attachmentUploadTrace)}`);
if (args.attachmentUploadTrace.completion_ip) {
lines.push(`Attachment Upload Completion IP: ${args.attachmentUploadTrace.completion_ip}`);
}
if (args.attachmentUploadTrace.completed_at) {
lines.push(`Attachment Upload Completion Time: ${args.attachmentUploadTrace.completed_at.toISOString()}`);
}
}
if (!args.attachmentUploadTrace && args.reportedUser.lastActiveIp) {
lines.push(`Attachment Upload IP Unavailable; Current Last Active IP: ${args.reportedUser.lastActiveIp}`);
}
if (args.sourceReportId) lines.push(`Source Report ID: ${args.sourceReportId.toString()}`);
if (args.priorReportIds.length > 0) {
lines.push(`Previous NCMEC Report IDs: ${args.priorReportIds.join(', ')}`);
}
return lines.join('\n');
}
function buildReportedUserPerson(reportedUser: ReportedUserSnapshot): {
emails: Array<{
address: string;
verified: boolean;
type: 'Home';
}>;
phones: Array<{
number: string;
type: 'Mobile';
}>;
dateOfBirth: string | null;
} | null {
const emails = reportedUser.email
? [{address: reportedUser.email, verified: reportedUser.emailVerified, type: 'Home' as const}]
: [];
const phones: Array<{number: string; type: 'Mobile'}> = [];
if (emails.length === 0 && !reportedUser.dateOfBirth) {
return null;
}
return {
emails,
phones,
dateOfBirth: reportedUser.dateOfBirth,
};
}
function buildReportedUserIpCaptureEvents(trace: AttachmentUploadTraceByAttachmentRow | null): Array<{
ipAddress: string;
eventName: 'Upload';
dateTime: Date;
}> {
if (!trace) {
return [];
}
const events = [{ipAddress: trace.request_ip, eventName: 'Upload' as const, dateTime: trace.requested_at}];
if (trace.completion_ip && trace.completed_at && trace.completion_ip !== trace.request_ip) {
events.push({ipAddress: trace.completion_ip, eventName: 'Upload', dateTime: trace.completed_at});
}
return events;
}
function buildBestEffortUploadIpCaptureEvent(trace: AttachmentUploadTraceByAttachmentRow | null): {
ipAddress: string;
eventName: 'Upload';
dateTime: Date;
} | null {
if (!trace) {
return null;
}
if (trace.completion_ip && trace.completed_at) {
return {ipAddress: trace.completion_ip, eventName: 'Upload', dateTime: trace.completed_at};
}
return {ipAddress: trace.request_ip, eventName: 'Upload', dateTime: trace.requested_at};
}
function getBestEffortUploadTimestamp(trace: AttachmentUploadTraceByAttachmentRow | null): Date | null {
return trace?.completed_at ?? trace?.requested_at ?? null;
}
function describeBestEffortUploadIpSource(trace: AttachmentUploadTraceByAttachmentRow): string {
if (trace.completion_ip && trace.completed_at) {
return 'multipart completion callback observed by Fluxer API';
}
if (trace.upload_mode === 'form_data') {
return 'attachment upload request handled directly by Fluxer API';
}
return 'presigned upload authorization request observed by Fluxer API';
}
function buildAuditMetadata(args: {
attachment: ResolvedAttachment;
reportedUser: ReportedUserSnapshot;
attachmentUploadTrace: AttachmentUploadTraceByAttachmentRow | null;
}): Map<string, string> {
const metadata = new Map<string, string>([
['channel_id', args.attachment.channelId.toString()],
['message_id', args.attachment.messageId.toString()],
['attachment_id', args.attachment.attachmentId.toString()],
]);
if (args.reportedUser.email) metadata.set('reported_user_email', args.reportedUser.email);
metadata.set('reported_user_email_verified', args.reportedUser.emailVerified ? 'true' : 'false');
if (args.reportedUser.dateOfBirth) metadata.set('reported_user_date_of_birth', args.reportedUser.dateOfBirth);
if (args.reportedUser.lastActiveIp) metadata.set('reported_user_last_active_ip', args.reportedUser.lastActiveIp);
if (args.attachmentUploadTrace) {
metadata.set('attachment_upload_mode', args.attachmentUploadTrace.upload_mode);
metadata.set('attachment_upload_request_ip', args.attachmentUploadTrace.request_ip);
metadata.set('attachment_upload_requested_at', args.attachmentUploadTrace.requested_at.toISOString());
metadata.set('attachment_upload_ip_source', describeBestEffortUploadIpSource(args.attachmentUploadTrace));
if (args.attachmentUploadTrace.completion_ip) {
metadata.set('attachment_upload_completion_ip', args.attachmentUploadTrace.completion_ip);
}
if (args.attachmentUploadTrace.completed_at) {
metadata.set('attachment_upload_completed_at', args.attachmentUploadTrace.completed_at.toISOString());
}
}
return metadata;
}
function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null {
const scan = (embeds: Array<Embed>): EmbedMedia | null => {
for (const embed of embeds) {
for (const media of [embed.image, embed.thumbnail, embed.video, embed.audio]) {
if (media?.url === targetCdnUrl) {
return media;
}
}
}
return null;
};
const direct = scan(message.embeds);
if (direct) {
return direct;
}
for (const snapshot of message.messageSnapshots) {
const found = scan(snapshot.embeds);
if (found) {
return found;
}
}
return null;
}
function findAttachmentInReport(
report: IARSubmission,
channelId: ChannelID,
messageId: MessageID,
attachmentId: AttachmentID,
filename: string,
): {
message: IARMessageContext;
attachment: IARMessageContext['attachments'][number];
} | null {
for (const message of report.messageContext ?? []) {
if (message.messageId !== messageId) continue;
if (message.channelId !== channelId) continue;
const attachment = message.attachments.find(
(item) => item.attachment_id === attachmentId && item.filename === filename,
);
if (attachment) return {message, attachment};
}
return null;
}
function formatNcmecAuditLogReason(reportId: string, contextLabel: string): string {
return `NCMEC Report ${reportId} - ${contextLabel}`;
}
function finalizeJobKey(attachmentId: AttachmentID): string {
return `ncmec-finalize:${attachmentId.toString()}`;
}
function sanitizeFailureMessage(rawMessage: string): string {
const lowered = rawMessage.toLowerCase();
if (lowered.includes('http 401') || lowered.includes('unauthorized')) {
return 'NCMEC authentication failed. Check the service credentials.';
}
if (lowered.includes('responsecode')) {
const match = rawMessage.match(/responseCode\s+(-?\d+)/i);
return match ? `NCMEC rejected the submission (responseCode ${match[1]}).` : 'NCMEC rejected the submission.';
}
if (lowered.includes('http ')) {
const match = rawMessage.match(/http\s+(\d{3})/i);
return match ? `NCMEC request failed (HTTP ${match[1]}).` : 'NCMEC request failed.';
}
return 'NCMEC submission failed. See server logs for details.';
}
function validateImageAttachment(contentType: string | null | undefined): void {
if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) return;
throw InputValidationError.fromCode('attachment_id', ValidationErrorCodes.NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO);
}
function requireReporterEmail(email: string | null): string {
const trimmed = email?.trim();
if (trimmed) {
return trimmed;
}
throw new Error('NCMEC reporter email is required for submission.');
}