feat: add fluxer upstream source and self-hosting documentation

- Clone of github.com/fluxerapp/fluxer (official upstream)
- SELF_HOSTING.md: full VM rebuild procedure, architecture overview,
  service reference, step-by-step setup, troubleshooting, seattle reference
- dev/.env.example: all env vars with secrets redacted and generation instructions
- dev/livekit.yaml: LiveKit config template with placeholder keys
- fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
Vish
2026-03-13 00:55:14 -07:00
parent 5ceda343b8
commit 3b9d759b4b
5859 changed files with 1923440 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import path from 'node:path';
import {Config} from '@fluxer/api/src/Config';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {
CsamEvidenceExpirationRow,
CsamEvidenceLegalHoldRow,
CsamEvidencePackageRow,
} from '@fluxer/api/src/database/types/CsamTypes';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import {CsamEvidenceExpirations, CsamEvidenceLegalHolds, CsamEvidencePackages} from '@fluxer/api/src/Tables';
export class CsamEvidenceRetentionService {
private readonly bucket = Config.s3.buckets.reports;
private readonly batchSize = Config.csam.cleanupBatchSize;
constructor(private readonly storageService: IStorageService) {}
async trackExpiration(reportId: bigint, expiresAt: Date | null): Promise<void> {
if (!expiresAt) {
return;
}
try {
await upsertOne(
CsamEvidenceExpirations.insert({
bucket: this.bucket,
expires_at: expiresAt,
report_id: reportId,
}),
);
} catch (error) {
Logger.error({error, reportId: reportId.toString(), expiresAt}, 'Failed to track CSAM evidence expiration');
}
}
async rescheduleExpiration(reportId: bigint, newExpiresAt: Date): Promise<void> {
try {
await upsertOne(CsamEvidencePackages.patchByPk({report_id: reportId}, {expires_at: Db.set(newExpiresAt)}));
await this.trackExpiration(reportId, newExpiresAt);
} catch (error) {
Logger.error(
{error, reportId: reportId.toString(), newExpiresAt},
'Failed to reschedule CSAM evidence expiration',
);
throw error;
}
}
async rescheduleForHold(reportId: bigint, heldUntil: Date | null): Promise<void> {
try {
await upsertOne(
CsamEvidencePackages.patchByPk(
{report_id: reportId},
{
expires_at: heldUntil ? Db.set(heldUntil) : Db.clear<Date>(),
},
),
);
if (heldUntil) {
await this.trackExpiration(reportId, heldUntil);
}
} catch (error) {
Logger.error(
{error, reportId: reportId.toString(), heldUntil},
'Failed to reschedule CSAM evidence for legal hold',
);
throw error;
}
}
async cleanupExpired(): Promise<void> {
const now = new Date();
while (true) {
const rows = await fetchMany<CsamEvidenceExpirationRow>(
CsamEvidenceExpirations.select({
where: [
CsamEvidenceExpirations.where.eq('bucket', 'bucket'),
CsamEvidenceExpirations.where.lte('expires_at', 'expires_at'),
],
orderBy: {col: 'expires_at', direction: 'ASC'},
limit: this.batchSize,
}).bind({
bucket: this.bucket,
expires_at: now,
}),
);
if (rows.length === 0) {
return;
}
let processedInBatch = 0;
for (const row of rows) {
try {
await this.processExpiration(row, now);
processedInBatch += 1;
} catch (error) {
Logger.error({error, reportId: row.report_id.toString()}, 'CSAM evidence cleanup failed for report');
}
}
if (processedInBatch === 0) {
Logger.error(
{batchSize: rows.length},
'CSAM evidence cleanup made no progress; stopping to avoid repeated failures',
);
return;
}
if (rows.length < this.batchSize) {
return;
}
}
}
private async processExpiration(row: CsamEvidenceExpirationRow, deadline: Date): Promise<void> {
const reportId = row.report_id;
const pkg = await fetchOne<CsamEvidencePackageRow>(
CsamEvidencePackages.select({
where: [CsamEvidencePackages.where.eq('report_id', 'report_id')],
}).bind({report_id: reportId}),
);
if (!pkg) {
Logger.warn({reportId: reportId.toString()}, 'CSAM evidence package missing during cleanup');
await this.deleteExpirationRow(row);
return;
}
if (!pkg.expires_at || pkg.expires_at.getTime() > deadline.getTime()) {
await this.deleteExpirationRow(row);
return;
}
const hold = await fetchOne<CsamEvidenceLegalHoldRow>(
CsamEvidenceLegalHolds.select({
where: [CsamEvidenceLegalHolds.where.eq('report_id', 'report_id')],
}).bind({report_id: reportId}),
);
if (hold && (!hold.held_until || hold.held_until.getTime() > deadline.getTime())) {
Logger.debug({reportId: reportId.toString()}, 'CSAM evidence is on legal hold, skipping cleanup');
await this.deleteExpirationRow(row);
return;
}
await this.deleteEvidenceObjects(reportId, pkg);
await upsertOne(
CsamEvidencePackages.patchByPk(
{report_id: reportId},
{
evidence_zip_key: Db.clear<string>(),
hashes: Db.clear<string>(),
frames: Db.clear<string>(),
match_details: Db.clear<string>(),
context_snapshot: Db.clear<string>(),
expires_at: Db.clear<Date>(),
},
),
);
await this.deleteExpirationRow(row);
Logger.info({reportId: reportId.toString()}, 'CSAM evidence expired and purged');
}
private async deleteEvidenceObjects(reportId: bigint, pkg: CsamEvidencePackageRow): Promise<void> {
const bucket = this.bucket;
const keysToDelete: Array<string> = [];
if (pkg.evidence_zip_key) {
keysToDelete.push(pkg.evidence_zip_key);
}
keysToDelete.push(this.buildAssetCopyKey(reportId, pkg));
await Promise.all(keysToDelete.map((key) => this.storageService.deleteObject(bucket, key)));
const attachmentsPrefix = `csam/evidence/${reportId.toString()}/attachments/`;
const attachments = await this.storageService.listObjects({bucket, prefix: attachmentsPrefix});
if (attachments.length > 0) {
await this.storageService.deleteObjects({
bucket,
objects: attachments.map((entry) => ({Key: entry.key})),
});
}
}
private buildAssetCopyKey(reportId: bigint, pkg: CsamEvidencePackageRow): string {
const base = pkg.key ? path.basename(pkg.key) : pkg.filename ? path.basename(pkg.filename) : 'asset';
return `csam/evidence/${reportId.toString()}/asset/${base}`;
}
private async deleteExpirationRow(row: CsamEvidenceExpirationRow): Promise<void> {
await deleteOneOrMany(
CsamEvidenceExpirations.delete({
where: [
CsamEvidenceExpirations.where.eq('bucket', 'bucket'),
CsamEvidenceExpirations.where.eq('expires_at', 'expires_at'),
CsamEvidenceExpirations.where.eq('report_id', 'report_id'),
],
}).bind({
bucket: row.bucket,
expires_at: row.expires_at,
report_id: row.report_id,
}),
);
}
}

View File

@@ -0,0 +1,247 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import path from 'node:path';
import {Config} from '@fluxer/api/src/Config';
import type {CsamEvidenceRetentionService} from '@fluxer/api/src/csam/CsamEvidenceRetentionService';
import type {AttachmentEvidenceInfo, EvidenceContext} from '@fluxer/api/src/csam/CsamTypes';
import type {
ICsamEvidenceService,
StoreEvidenceArgs,
StoreEvidenceResult,
} from '@fluxer/api/src/csam/ICsamEvidenceService';
import {upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {CsamEvidencePackageRow} from '@fluxer/api/src/database/types/CsamTypes';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import {CsamEvidencePackages} from '@fluxer/api/src/Tables';
import {recordCsamEvidenceStorage} from '@fluxer/api/src/telemetry/CsamTelemetry';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import archiver from 'archiver';
function bigintReplacer(_key: string, value: unknown): unknown {
return typeof value === 'bigint' ? value.toString() : value;
}
export class CsamEvidenceService implements ICsamEvidenceService {
constructor(
private readonly storageService: IStorageService,
private readonly retentionService: CsamEvidenceRetentionService,
) {}
async storeEvidence(args: StoreEvidenceArgs): Promise<StoreEvidenceResult> {
const {reportId, job, matchResult, frames, hashes, context} = args;
if (!job.bucket || !job.key) {
throw new Error('CSAM job missing bucket or key');
}
const idString = reportId.toString();
const assetCopyKey = `csam/evidence/${idString}/asset/${path.basename(job.key) || 'asset'}`;
try {
await this.storageService.copyObject({
sourceBucket: job.bucket,
sourceKey: job.key,
destinationBucket: Config.s3.buckets.reports,
destinationKey: assetCopyKey,
});
} catch (error) {
Logger.error({error, reportId: idString, source: job.key}, 'Failed to copy CSAM asset to reports bucket');
throw error;
}
const assetBuffer = await this.storageService.readObject(job.bucket, job.key);
const integrityHash = crypto.createHash('sha256').update(assetBuffer).digest('hex');
const contextSnapshot: EvidenceContext | null = context ?? null;
const assetEntryName = `asset/${path.basename(job.key) || job.filename || 'asset'}`;
const evidenceZipKey = `csam/evidence/${idString}/evidence.zip`;
const metadataPayload = {
reportId: idString,
job,
matchResult,
frames,
hashes,
context: contextSnapshot,
createdAt: new Date().toISOString(),
assetEntryName,
evidenceZipKey,
};
const archiveEntries: Array<{name: string; content: Buffer}> = [
{
name: 'metadata.json',
content: Buffer.from(JSON.stringify(metadataPayload, bigintReplacer, 2)),
},
{
name: 'match.json',
content: Buffer.from(JSON.stringify(matchResult, bigintReplacer, 2)),
},
{
name: 'frames.json',
content: Buffer.from(JSON.stringify(frames, bigintReplacer, 2)),
},
];
for (let index = 0; index < frames.length; index += 1) {
const frame = frames[index];
let frameBuffer: Buffer;
try {
frameBuffer = Buffer.from(frame.base64, 'base64');
} catch (error) {
Logger.warn({error, reportId: idString, frameIndex: index}, 'Failed to decode frame for archive');
recordCsamEvidenceStorage({status: 'error', evidenceType: 'frame'});
continue;
}
archiveEntries.push({
name: `frames/frame-${index + 1}.jpg`,
content: frameBuffer,
});
recordCsamEvidenceStorage({status: 'success', evidenceType: 'frame'});
}
if (hashes.length > 0) {
archiveEntries.push({
name: 'hashes.json',
content: Buffer.from(JSON.stringify(hashes, bigintReplacer, 2)),
});
}
if (contextSnapshot) {
archiveEntries.push({
name: 'context.json',
content: Buffer.from(JSON.stringify(contextSnapshot, bigintReplacer, 2)),
});
if (contextSnapshot.contactLogs && contextSnapshot.contactLogs.length > 0) {
archiveEntries.push({
name: 'contact_logs.json',
content: Buffer.from(JSON.stringify(contextSnapshot.contactLogs, bigintReplacer, 2)),
});
}
const attachments: Array<AttachmentEvidenceInfo> =
contextSnapshot.attachments?.filter(
(entry: Record<string, unknown> | AttachmentEvidenceInfo): entry is AttachmentEvidenceInfo =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as AttachmentEvidenceInfo).attachmentId === 'string' &&
typeof (entry as AttachmentEvidenceInfo).evidenceKey === 'string',
) ?? [];
for (const attachment of attachments) {
try {
const attachmentBuffer = await this.storageService.readObject(
Config.s3.buckets.reports,
attachment.evidenceKey,
);
const attachmentName = `attachments/${attachment.attachmentId}/${attachment.filename}`;
archiveEntries.push({
name: attachmentName,
content: Buffer.from(attachmentBuffer),
});
recordCsamEvidenceStorage({status: 'success', evidenceType: 'attachment'});
} catch (error) {
Logger.error(
{error, reportId: idString, attachmentId: attachment.attachmentId},
'Failed to include attachment in CSAM evidence archive',
);
recordCsamEvidenceStorage({status: 'error', evidenceType: 'attachment'});
}
}
}
archiveEntries.push({
name: assetEntryName,
content: Buffer.from(assetBuffer),
});
const archiveBuffer = await this.buildEvidenceArchive(archiveEntries);
try {
await this.storageService.uploadObject({
bucket: Config.s3.buckets.reports,
key: evidenceZipKey,
body: archiveBuffer,
contentType: 'application/zip',
});
recordCsamEvidenceStorage({status: 'success', evidenceType: 'package'});
} catch (error) {
Logger.error({error, reportId: idString, evidenceZipKey}, 'Failed to upload CSAM evidence package');
recordCsamEvidenceStorage({status: 'error', evidenceType: 'package'});
throw error;
}
const now = new Date();
const retentionMs = Math.max(1, Config.csam.evidenceRetentionDays) * MS_PER_DAY;
const expiresAt = new Date(now.getTime() + retentionMs);
const packageRow: CsamEvidencePackageRow = {
report_id: reportId,
resource_type: job.resourceType,
bucket: job.bucket,
key: job.key,
cdn_url: job.cdnUrl,
filename: job.filename,
content_type: job.contentType,
channel_id: job.channelId ? BigInt(job.channelId) : null,
message_id: job.messageId ? BigInt(job.messageId) : null,
guild_id: job.guildId ? BigInt(job.guildId) : null,
user_id: job.userId ? BigInt(job.userId) : null,
match_tracking_id: matchResult.trackingId || null,
match_details: JSON.stringify(matchResult.matchDetails ?? [], bigintReplacer),
frames: JSON.stringify(frames, bigintReplacer),
hashes: JSON.stringify(hashes, bigintReplacer),
context_snapshot: contextSnapshot ? JSON.stringify(contextSnapshot, bigintReplacer) : null,
created_at: now,
expires_at: expiresAt,
integrity_sha256: integrityHash,
evidence_zip_key: evidenceZipKey,
};
await upsertOne(CsamEvidencePackages.insert(packageRow));
await this.retentionService.trackExpiration(reportId, expiresAt);
return {
integrityHash,
evidenceZipKey,
assetCopyKey,
};
}
private async buildEvidenceArchive(entries: Array<{name: string; content: Buffer}>): Promise<Buffer> {
const archive = archiver('zip', {zlib: {level: 9}});
const chunks: Array<Buffer> = [];
archive.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
const done = new Promise<void>((resolve, reject) => {
archive.once('error', reject);
archive.once('end', () => resolve());
});
for (const entry of entries) {
archive.append(entry.content, {name: entry.name});
}
archive.finalize();
await done;
return Buffer.concat(chunks);
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {CsamEvidenceLegalHoldRow} from '@fluxer/api/src/database/types/CsamTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {CsamEvidenceLegalHolds} from '@fluxer/api/src/Tables';
export class CsamLegalHoldService {
private async toReportId(reportId: string): Promise<bigint> {
try {
return BigInt(reportId);
} catch (error) {
Logger.error({error, reportId}, 'Invalid report ID for CSAM legal hold');
throw error;
}
}
async hold(reportId: string, expiresAt?: Date): Promise<void> {
const id = await this.toReportId(reportId);
const row: CsamEvidenceLegalHoldRow = {
report_id: id,
held_until: expiresAt ?? null,
created_at: new Date(),
};
try {
await upsertOne(CsamEvidenceLegalHolds.insert(row));
} catch (error) {
Logger.error({error, reportId}, 'Failed to persist CSAM legal hold');
throw error;
}
}
async release(reportId: string): Promise<void> {
const id = await this.toReportId(reportId);
try {
await deleteOneOrMany(CsamEvidenceLegalHolds.deleteByPk({report_id: id}));
} catch (error) {
Logger.error({error, reportId}, 'Failed to release CSAM legal hold');
throw error;
}
}
async isHeld(reportId: string): Promise<boolean> {
const id = await this.toReportId(reportId);
try {
const row = await fetchOne<CsamEvidenceLegalHoldRow>(
CsamEvidenceLegalHolds.select({
where: CsamEvidenceLegalHolds.where.eq('report_id'),
limit: 1,
}).bind({report_id: id}),
);
if (!row) {
return false;
}
const now = Date.now();
if (row.held_until && row.held_until.getTime() < now) {
return false;
}
return true;
} catch (error) {
Logger.error({error, reportId}, 'Failed to fetch CSAM legal hold');
throw error;
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {createChannelID, createGuildID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
import type {CsamResourceType, PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {ICsamEvidenceService} from '@fluxer/api/src/csam/ICsamEvidenceService';
import type {ICsamReportSnapshotService} from '@fluxer/api/src/csam/ICsamReportSnapshotService';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {ISnowflakeService} from '@fluxer/api/src/infrastructure/ISnowflakeService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {IReportRepository} from '@fluxer/api/src/report/IReportRepository';
import {ReportStatus, ReportType} from '@fluxer/api/src/report/IReportRepository';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
export interface CreateSnapshotParams {
scanResult: PhotoDnaMatchResult;
resourceType: CsamResourceType;
userId: string | null;
guildId: string | null;
channelId: string | null;
messageId: string | null;
mediaData: Buffer;
filename: string;
contentType: string | null;
}
export interface CsamReportSnapshotServiceDeps {
storageService: IStorageService;
reportRepository: IReportRepository;
snowflakeService: ISnowflakeService;
csamEvidenceService: ICsamEvidenceService;
logger: ILogger;
}
export class CsamReportSnapshotService implements ICsamReportSnapshotService {
private readonly storageService: IStorageService;
private readonly reportRepository: IReportRepository;
private readonly snowflakeService: ISnowflakeService;
private readonly csamEvidenceService: ICsamEvidenceService;
private readonly logger: ILogger;
constructor(deps: CsamReportSnapshotServiceDeps) {
this.storageService = deps.storageService;
this.reportRepository = deps.reportRepository;
this.snowflakeService = deps.snowflakeService;
this.csamEvidenceService = deps.csamEvidenceService;
this.logger = deps.logger;
}
async createSnapshot(params: CreateSnapshotParams): Promise<bigint> {
const {scanResult, resourceType, userId, guildId, channelId, messageId, mediaData, filename, contentType} = params;
const reportId = await this.snowflakeService.generate();
const idString = reportId.toString();
const integrityHash = crypto.createHash('sha256').update(mediaData).digest('hex');
const assetKey = `csam/evidence/${idString}/asset/${filename}`;
try {
await this.storageService.uploadObject({
bucket: Config.s3.buckets.reports,
key: assetKey,
body: mediaData,
contentType: contentType ?? undefined,
});
} catch (error) {
this.logger.error({error, reportId: idString, filename}, 'Failed to upload CSAM evidence asset');
throw error;
}
const evidenceResult = await this.csamEvidenceService.storeEvidence({
reportId,
job: {
jobId: idString,
resourceType,
bucket: Config.s3.buckets.reports,
key: assetKey,
cdnUrl: null,
filename,
contentType,
channelId,
messageId,
guildId,
userId,
},
matchResult: scanResult,
frames: [],
hashes: [integrityHash],
});
const reportRow = {
report_id: reportId,
reporter_id: SYSTEM_USER_ID,
reporter_email: null,
reporter_full_legal_name: null,
reporter_country_of_residence: null,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.MESSAGE,
category: CATEGORY_CHILD_SAFETY,
additional_info: JSON.stringify({
trackingId: scanResult.trackingId,
matchDetails: scanResult.matchDetails,
hashes: [integrityHash],
integrity: evidenceResult.integrityHash,
evidenceZip: evidenceResult.evidenceZipKey,
resourceType,
}),
reported_user_id: userId ? createUserID(BigInt(userId)) : null,
reported_user_avatar_hash: null,
reported_guild_id: guildId ? createGuildID(BigInt(guildId)) : null,
reported_guild_name: null,
reported_guild_icon_hash: null,
reported_message_id: messageId ? createMessageID(BigInt(messageId)) : null,
reported_channel_id: channelId ? createChannelID(BigInt(channelId)) : null,
reported_channel_name: null,
message_context: null,
guild_context_id: guildId ? createGuildID(BigInt(guildId)) : null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: `CSAM match ${scanResult.trackingId}`,
reported_guild_invite_code: null,
};
await this.reportRepository.createReport(reportRow);
this.logger.info(
{reportId: idString, resourceType, trackingId: scanResult.trackingId},
'CSAM report snapshot created',
);
return reportId;
}
}

View File

@@ -0,0 +1,221 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createChannelID, createGuildID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {ChannelRepository} from '@fluxer/api/src/channel/ChannelRepository';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '@fluxer/api/src/channel/services/message/MessageHelpers';
import {SYSTEM_USER_ID} from '@fluxer/api/src/constants/Core';
import type {CsamEvidenceService} from '@fluxer/api/src/csam/CsamEvidenceService';
import type {
AttachmentEvidenceInfo,
CsamScanJobPayload,
EvidenceContext,
FrameSample,
PhotoDnaMatchResult,
} from '@fluxer/api/src/csam/CsamTypes';
import type {NcmecReporter} from '@fluxer/api/src/csam/NcmecReporter';
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
import type {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import type {Message} from '@fluxer/api/src/models/Message';
import type {User} from '@fluxer/api/src/models/User';
import type {IARMessageContextRow} from '@fluxer/api/src/report/IReportRepository';
import {ReportStatus, ReportType} from '@fluxer/api/src/report/IReportRepository';
import type {ReportRepository} from '@fluxer/api/src/report/ReportRepository';
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
import type {UserRepository} from '@fluxer/api/src/user/repositories/UserRepository';
import type {UserContactChangeLogService} from '@fluxer/api/src/user/services/UserContactChangeLogService';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
interface CsamResponseServiceDeps {
channelRepository: ChannelRepository;
reportRepository: ReportRepository;
reportSearchService?: IReportSearchService;
storageService: IStorageService;
userRepository: UserRepository;
guildRepository: GuildRepository;
csamEvidenceService: CsamEvidenceService;
ncmecReporter: NcmecReporter;
contactChangeLogService: UserContactChangeLogService;
}
interface CsamResponseArgs {
reportId: bigint;
job: CsamScanJobPayload;
matchResult: PhotoDnaMatchResult;
frames: Array<FrameSample>;
hashes: Array<string>;
message: Message | null;
}
export class CsamResponseService {
constructor(private readonly deps: CsamResponseServiceDeps) {}
async handleMatch(args: CsamResponseArgs): Promise<void> {
const {reportId, job, matchResult, frames, hashes, message} = args;
const attachmentInfo: Array<AttachmentEvidenceInfo> = message ? await this.copyAttachments(reportId, message) : [];
const user = job.userId ? await this.deps.userRepository.findUnique(createUserID(BigInt(job.userId))) : null;
const guild = job.guildId ? await this.deps.guildRepository.findUnique(createGuildID(BigInt(job.guildId))) : null;
const channel = job.channelId
? await this.deps.channelRepository.findUnique(createChannelID(BigInt(job.channelId)))
: null;
const channelSnapshot = channel ? channel.toRow() : null;
const contactLogRows: Array<UserContactChangeLogRow> = user
? await this.deps.contactChangeLogService.listLogs({userId: user.id, limit: 100})
: [];
const contactLogSnapshot =
contactLogRows.length > 0
? contactLogRows.map((entry) => ({
eventId: entry.event_id.toString(),
field: entry.field,
oldValue: entry.old_value,
newValue: entry.new_value,
reason: entry.reason,
actorUserId: entry.actor_user_id?.toString() ?? null,
eventAt: entry.event_at ? entry.event_at.toISOString() : null,
}))
: null;
const contextSnapshot: EvidenceContext = {
message: message ? message.toRow() : null,
user: user ? user.toRow() : null,
guild: guild ? guild.toRow() : null,
channel: channelSnapshot,
attachments: attachmentInfo,
contactLogs: contactLogSnapshot,
};
const evidenceResult = await this.deps.csamEvidenceService.storeEvidence({
reportId,
job,
matchResult,
frames,
hashes,
context: contextSnapshot,
});
const messageContextRows = message ? [this.buildMessageContextRow(message, user)] : null;
const reportRow = {
report_id: reportId,
reporter_id: SYSTEM_USER_ID,
reporter_email: null,
reporter_full_legal_name: null,
reporter_country_of_residence: null,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.MESSAGE,
category: CATEGORY_CHILD_SAFETY,
additional_info: JSON.stringify({
trackingId: matchResult.trackingId,
matchDetails: matchResult.matchDetails,
hashes,
integrity: evidenceResult.integrityHash,
evidenceZip: evidenceResult.evidenceZipKey,
}),
reported_user_id: job.userId ? createUserID(BigInt(job.userId)) : null,
reported_user_avatar_hash: user?.avatarHash ?? null,
reported_guild_id: job.guildId ? createGuildID(BigInt(job.guildId)) : null,
reported_guild_name: guild?.name ?? null,
reported_guild_icon_hash: guild?.iconHash ?? null,
reported_message_id: job.messageId ? createMessageID(BigInt(job.messageId)) : null,
reported_channel_id: job.channelId ? createChannelID(BigInt(job.channelId)) : null,
reported_channel_name: channel?.name ?? null,
message_context: messageContextRows,
guild_context_id: job.guildId ? createGuildID(BigInt(job.guildId)) : null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: `PhotoDNA match ${matchResult.trackingId}`,
reported_guild_invite_code: null,
};
const createdReport = await this.deps.reportRepository.createReport(reportRow);
if (this.deps.reportSearchService && 'indexReport' in this.deps.reportSearchService) {
await this.deps.reportSearchService.indexReport(createdReport).catch((error) => {
Logger.error({error, reportId: reportId.toString()}, 'Failed to index CSAM report in search');
});
}
}
private buildMessageContextRow(message: Message, user: User | null): IARMessageContextRow {
const timestamp = snowflakeToDate(message.id);
return {
message_id: message.id,
channel_id: message.channelId,
author_id: message.authorId ?? 0n,
author_username: user?.username ?? 'unknown',
author_discriminator: user?.discriminator ?? 0,
author_avatar_hash: user?.avatarHash ?? null,
content: message.content,
timestamp,
edited_timestamp: message.editedTimestamp,
type: message.type,
flags: message.flags,
mention_everyone: message.mentionEveryone,
mention_users: message.mentionedUserIds.size > 0 ? message.mentionedUserIds : null,
mention_roles: message.mentionedRoleIds.size > 0 ? message.mentionedRoleIds : null,
mention_channels: message.mentionedChannelIds.size > 0 ? message.mentionedChannelIds : null,
attachments: message.attachments.length > 0 ? message.attachments.map((att) => att.toMessageAttachment()) : null,
embeds: message.embeds.length > 0 ? message.embeds.map((embed) => embed.toMessageEmbed()) : null,
sticker_items:
message.stickers.length > 0 ? message.stickers.map((sticker) => sticker.toMessageStickerItem()) : null,
};
}
private async copyAttachments(reportId: bigint, message: Message): Promise<Array<AttachmentEvidenceInfo>> {
const results: Array<AttachmentEvidenceInfo> = [];
for (const attachment of message.attachments) {
const sourceKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
const destKey = `csam/evidence/${reportId.toString()}/attachments/${attachment.id}/${attachment.filename}`;
try {
await this.deps.storageService.copyObject({
sourceBucket: Config.s3.buckets.cdn,
sourceKey,
destinationBucket: Config.s3.buckets.reports,
destinationKey: destKey,
});
} catch (error) {
Logger.error(
{error, attachmentId: attachment.id, reportId: reportId.toString()},
'Failed to copy attachment for CSAM evidence',
);
continue;
}
results.push({
attachmentId: attachment.id.toString(),
filename: attachment.filename,
contentType: attachment.contentType,
size: Number(attachment.size),
cdnUrl: makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename),
evidenceKey: destKey,
});
}
return results;
}
}

View File

@@ -0,0 +1,150 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import type {CsamScanJobStatus, CsamScanTarget, PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {DbOp} from '@fluxer/api/src/database/Cassandra';
import {Db, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {CsamScanJobRow} from '@fluxer/api/src/database/types/CsamTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {CsamScanJobs} from '@fluxer/api/src/Tables';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
export interface CreateCsamScanJobArgs {
jobId: string;
target: CsamScanTarget;
}
export interface ICsamScanJobService {
createJob(args: CreateCsamScanJobArgs): Promise<void>;
}
type CsamScanJobPatchOps = Partial<{[K in keyof CsamScanJobRow]: DbOp<CsamScanJobRow[K]>}>;
export class CsamScanJobService implements ICsamScanJobService {
private readonly retentionMs: number;
constructor(private readonly logger = Logger) {
const retentionDays = Math.max(1, Config.csam.jobRetentionDays);
this.retentionMs = retentionDays * MS_PER_DAY;
}
private extendExpiry(): Date {
return new Date(Date.now() + this.retentionMs);
}
private buildPatchOps(patch: Partial<CsamScanJobRow>): CsamScanJobPatchOps {
const ops: CsamScanJobPatchOps = {};
const setStringColumn = (
column: 'status' | 'hashes' | 'match_tracking_id' | 'match_details' | 'error_message',
value: string | null | undefined,
): void => {
if (value === undefined) {
return;
}
if (value === null) {
ops[column] = Db.clear<string>();
return;
}
ops[column] = Db.set(value);
};
setStringColumn('status', patch.status ?? undefined);
setStringColumn('hashes', patch.hashes ?? undefined);
setStringColumn('match_tracking_id', patch.match_tracking_id ?? undefined);
setStringColumn('match_details', patch.match_details ?? undefined);
setStringColumn('error_message', patch.error_message ?? undefined);
ops.last_updated = Db.set(new Date());
ops.expires_at = Db.set(this.extendExpiry());
return ops;
}
private async patchJob(jobId: string, patch: Partial<CsamScanJobRow>): Promise<void> {
const ops = this.buildPatchOps(patch);
try {
await upsertOne(CsamScanJobs.patchByPk({job_id: jobId}, ops));
} catch (error) {
this.logger.error({error, jobId, patch}, 'Failed to update CSAM scan job');
}
}
async createJob(args: CreateCsamScanJobArgs): Promise<void> {
const now = new Date();
const row: CsamScanJobRow = {
job_id: args.jobId,
resource_type: args.target.resourceType,
bucket: args.target.bucket ?? null,
key: args.target.key ?? null,
cdn_url: args.target.cdnUrl ?? null,
filename: args.target.filename ?? null,
content_type: args.target.contentType ?? null,
channel_id: args.target.channelId ? BigInt(args.target.channelId) : null,
message_id: args.target.messageId ? BigInt(args.target.messageId) : null,
guild_id: args.target.guildId ? BigInt(args.target.guildId) : null,
user_id: args.target.userId ? BigInt(args.target.userId) : null,
status: 'pending',
enqueue_time: now,
last_updated: now,
match_tracking_id: null,
match_details: null,
hashes: null,
error_message: null,
expires_at: this.extendExpiry(),
};
try {
await upsertOne(CsamScanJobs.insert(row));
} catch (error) {
this.logger.error({error, jobId: args.jobId}, 'Failed to persist CSAM scan job');
}
}
async markProcessing(jobId: string): Promise<void> {
await this.patchJob(jobId, {
status: 'processing',
});
}
async recordHashes(jobId: string, hashes: Array<string>): Promise<void> {
await this.patchJob(jobId, {
status: 'hashing',
hashes: JSON.stringify(hashes),
});
}
async recordMatchResult(jobId: string, matchResult: PhotoDnaMatchResult): Promise<void> {
const status: CsamScanJobStatus = matchResult.isMatch ? 'matched' : 'no_match';
await this.patchJob(jobId, {
status,
match_tracking_id: matchResult.trackingId || null,
match_details: JSON.stringify(matchResult.matchDetails ?? []),
});
}
async recordError(jobId: string, error: unknown): Promise<void> {
await this.patchJob(jobId, {
status: 'failed',
error_message: error instanceof Error ? error.message : String(error),
});
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import type {CsamResourceType, PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {recordCsamQueueWaitTime} from '@fluxer/api/src/telemetry/CsamTelemetry';
import {DEFAULT_CSAM_SCAN_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
import {
CsamScanFailedError,
CsamScanParseError,
CsamScanSubscriptionError,
CsamScanTimeoutError,
} from '@fluxer/errors/src/domains/csam/CsamScanErrors';
import type {IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
const CSAM_SCAN_QUEUE_KEY = 'csam:scan:queue';
export interface CsamScanQueueServiceOptions {
kvProvider: IKVProvider;
logger: ILogger;
timeoutMs?: number;
}
export interface CsamScanContext {
resourceType: CsamResourceType;
userId: string | null;
guildId: string | null;
channelId: string | null;
messageId: string | null;
}
export interface CsamScanSubmitParams {
hashes: Array<string>;
context: CsamScanContext;
timeoutMs?: number;
}
export interface CsamScanQueueResult {
isMatch: boolean;
matchResult?: PhotoDnaMatchResult;
}
export interface CsamScanQueueEntry {
requestId: string;
hashes: Array<string>;
timestamp: number;
context: CsamScanContext;
}
interface CsamScanResultMessage {
isMatch: boolean;
matchResult?: PhotoDnaMatchResult;
error?: string;
}
export interface ICsamScanQueueService {
submitScan(params: CsamScanSubmitParams): Promise<CsamScanQueueResult>;
}
export class CsamScanQueueService implements ICsamScanQueueService {
private kvProvider: IKVProvider;
private logger: ILogger;
private defaultTimeoutMs: number;
constructor(options: CsamScanQueueServiceOptions) {
this.kvProvider = options.kvProvider;
this.logger = options.logger;
this.defaultTimeoutMs = options.timeoutMs ?? DEFAULT_CSAM_SCAN_TIMEOUT_MS;
}
async submitScan(params: CsamScanSubmitParams): Promise<CsamScanQueueResult> {
const requestId = randomUUID();
const timeoutMs = params.timeoutMs ?? this.defaultTimeoutMs;
const resultChannel = `csam:result:${requestId}`;
const queueEntry: CsamScanQueueEntry = {
requestId,
hashes: params.hashes,
timestamp: Date.now(),
context: params.context,
};
let subscription: IKVSubscription | null = null;
try {
subscription = this.kvProvider.duplicate();
await subscription.connect();
await subscription.subscribe(resultChannel);
const resultPromise = new Promise<CsamScanQueueResult>((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
timeoutId = setTimeout(() => {
cleanup();
const waitTimeMs = Date.now() - queueEntry.timestamp;
recordCsamQueueWaitTime({waitTimeMs});
reject(new CsamScanTimeoutError());
}, timeoutMs);
subscription!.on('message', (_channel, message) => {
cleanup();
try {
const result = JSON.parse(message) as CsamScanResultMessage;
const waitTimeMs = Date.now() - queueEntry.timestamp;
recordCsamQueueWaitTime({waitTimeMs});
if (result.error) {
reject(new CsamScanFailedError());
return;
}
resolve({
isMatch: result.isMatch,
matchResult: result.matchResult,
});
} catch (_parseError) {
const waitTimeMs = Date.now() - queueEntry.timestamp;
recordCsamQueueWaitTime({waitTimeMs});
reject(new CsamScanParseError());
}
});
subscription!.on('error', (error) => {
cleanup();
const waitTimeMs = Date.now() - queueEntry.timestamp;
recordCsamQueueWaitTime({waitTimeMs});
this.logger.error({error, requestId}, 'CSAM scan subscription error');
reject(new CsamScanSubscriptionError());
});
});
await this.kvProvider.rpush(CSAM_SCAN_QUEUE_KEY, JSON.stringify(queueEntry));
this.logger.debug({requestId, hashCount: params.hashes.length}, 'Submitted CSAM scan request');
return await resultPromise;
} finally {
if (subscription) {
try {
subscription.removeAllListeners('message');
subscription.removeAllListeners('error');
await subscription.quit();
} catch (cleanupError) {
this.logger.error({error: cleanupError, requestId}, 'Failed to cleanup CSAM scan subscription');
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
import type {GuildRow} from '@fluxer/api/src/database/types/GuildTypes';
import type {MessageRow} from '@fluxer/api/src/database/types/MessageTypes';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {WorkerJobPayload} from '@fluxer/worker/src/contracts/WorkerTypes';
export type CsamResourceType = 'attachment' | 'avatar' | 'emoji' | 'sticker' | 'banner' | 'other';
export interface CsamScanTarget {
bucket: string;
key: string;
cdnUrl: string | null;
filename: string;
contentType: string | null;
resourceType: CsamResourceType;
channelId?: string | null;
messageId?: string | null;
attachmentId?: string | null;
userId?: string | null;
guildId?: string | null;
}
export interface CsamScanJobPayload extends WorkerJobPayload {
jobId: string;
resourceType: CsamResourceType;
bucket: string;
key: string;
cdnUrl: string | null;
filename: string;
contentType: string | null;
channelId: string | null;
messageId: string | null;
guildId: string | null;
userId: string | null;
}
export interface FrameSample {
timestamp: number;
mimeType: string;
base64: string;
}
export interface PhotoDnaMatchDetail {
source: string;
violations: Array<string>;
matchDistance: number;
matchId?: string;
}
export type CsamScanProviderType = 'photo_dna' | 'arachnid_shield';
export interface PhotoDnaMatchResult {
isMatch: boolean;
trackingId: string;
matchDetails: Array<PhotoDnaMatchDetail>;
timestamp: string;
provider?: CsamScanProviderType;
}
export type CsamScanJobStatus = 'pending' | 'processing' | 'hashing' | 'matched' | 'no_match' | 'failed';
export interface CsamScanQueueEntry {
requestId: string;
hashes: Array<string>;
}
export interface CsamScanResultMessage {
isMatch: boolean;
matchResult?: PhotoDnaMatchResult;
error?: string;
}
export interface AttachmentEvidenceInfo {
attachmentId: string;
filename: string;
contentType: string;
size: number;
cdnUrl: string;
evidenceKey: string;
}
export interface EvidenceContext {
message?: MessageRow | Record<string, unknown> | null;
user?: UserRow | Record<string, unknown> | null;
guild?: GuildRow | Record<string, unknown> | null;
channel?: ChannelRow | Record<string, unknown> | null;
attachments?: Array<Record<string, unknown> | AttachmentEvidenceInfo> | null;
contactLogs?: Array<Record<string, unknown>> | null;
additional?: Record<string, unknown>;
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
CsamScanJobPayload,
EvidenceContext,
FrameSample,
PhotoDnaMatchResult,
} from '@fluxer/api/src/csam/CsamTypes';
export interface StoreEvidenceArgs {
reportId: bigint;
job: CsamScanJobPayload;
matchResult: PhotoDnaMatchResult;
frames: Array<FrameSample>;
hashes: Array<string>;
context?: EvidenceContext;
}
export interface StoreEvidenceResult {
integrityHash: string;
evidenceZipKey: string;
assetCopyKey: string;
}
export interface ICsamEvidenceService {
storeEvidence(args: StoreEvidenceArgs): Promise<StoreEvidenceResult>;
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CreateSnapshotParams} from '@fluxer/api/src/csam/CsamReportSnapshotService';
export interface ICsamReportSnapshotService {
createSnapshot(params: CreateSnapshotParams): Promise<bigint>;
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {
ScanBase64Params,
ScanMediaParams,
SynchronousCsamScanResult,
} from '@fluxer/api/src/csam/SynchronousCsamScanner';
export interface ISynchronousCsamScanner {
scanMedia(params: ScanMediaParams): Promise<SynchronousCsamScanResult>;
scanBase64(params: ScanBase64Params): Promise<SynchronousCsamScanResult>;
}

View File

@@ -0,0 +1,303 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {Logger} from '@fluxer/api/src/Logger';
import {recordNcmecSubmission} from '@fluxer/api/src/telemetry/CsamTelemetry';
import {XMLParser} from 'fast-xml-parser';
type NcmecTelemetryOperation = 'report' | 'evidence' | 'fileinfo' | 'finish' | 'retract';
export type NcmecApiConfig =
| {enabled: true; baseUrl: string; username: string; password: string}
| {enabled: false; baseUrl: null; username: null; password: null};
export interface NcmecApiDeps {
config: NcmecApiConfig;
fetch: typeof fetch;
}
export class NcmecRequestError extends Error {
constructor(
message: string,
public readonly httpStatus: number,
public readonly responseCode: number | null,
public readonly responseDescription: string | null,
public readonly requestId: string | null,
public readonly body: string | null,
) {
super(message);
}
}
type ReportResponse = {
responseCode?: number;
responseDescription?: string;
reportId?: string;
fileId?: string;
hash?: string;
};
type ReportDoneResponse = {
responseCode?: number;
reportId?: string;
files?: {fileId?: string | Array<string>};
};
export class NcmecReporter {
private readonly parser = new XMLParser({
ignoreAttributes: true,
attributeNamePrefix: '@_',
removeNSPrefix: true,
parseTagValue: true,
trimValues: true,
});
constructor(private readonly deps: NcmecApiDeps) {}
async submitReport(reportXml: string): Promise<string> {
return this.withTelemetry('report', async () => {
const {res, text} = await this.request('/submit', {
method: 'POST',
headers: {'Content-Type': 'text/xml; charset=utf-8'},
body: reportXml,
});
const parsed = this.parseReportResponse(text);
this.ensureOk(res, parsed, text);
const reportId = normaliseResponseId(parsed.reportId);
if (!reportId) {
throw this.makeError('NCMEC /submit returned no reportId.', res, parsed, text);
}
return reportId;
});
}
async uploadEvidence(
reportId: string,
buffer: Uint8Array,
filename: string,
): Promise<{fileId: string; md5: string | null}> {
return this.withTelemetry('evidence', async () => {
const form = new FormData();
form.append('id', reportId);
form.append('file', new Blob([buffer as any]), filename);
const {res, text} = await this.request('/upload', {method: 'POST', body: form});
const parsed = this.parseReportResponse(text);
this.ensureOk(res, parsed, text);
const fileId = normaliseResponseId(parsed.fileId);
if (!fileId) {
throw this.makeError('NCMEC /upload returned no fileId.', res, parsed, text);
}
return {fileId, md5: normaliseResponseId(parsed.hash)};
});
}
async submitFileDetails(fileDetailsXml: string): Promise<void> {
await this.withTelemetry('fileinfo', async () => {
const {res, text} = await this.request('/fileinfo', {
method: 'POST',
headers: {'Content-Type': 'text/xml; charset=utf-8'},
body: fileDetailsXml,
});
const parsed = this.parseReportResponse(text);
this.ensureOk(res, parsed, text);
});
}
async finish(requestReportId: string): Promise<{reportId: string; fileIds: Array<string>}> {
return this.withTelemetry('finish', async () => {
const form = new FormData();
form.append('id', requestReportId);
const {res, text} = await this.request('/finish', {method: 'POST', body: form});
const parsed = this.parseReportDoneResponse(text);
this.ensureOk(res, parsed, text);
const responseReportId = normaliseResponseId(parsed.reportId);
if (!responseReportId) {
throw this.makeError('NCMEC /finish returned no reportId.', res, parsed, text);
}
const raw = parsed.files?.fileId;
const fileIds = (Array.isArray(raw) ? raw : raw ? [raw] : [])
.map((value) => normaliseResponseId(value))
.filter((value): value is string => Boolean(value));
return {reportId: responseReportId, fileIds};
});
}
async retract(reportId: string): Promise<void> {
await this.withTelemetry('retract', async () => {
const form = new FormData();
form.append('id', reportId);
const {res, text} = await this.request('/retract', {method: 'POST', body: form});
const parsed = this.parseReportResponse(text);
this.ensureOk(res, parsed, text);
});
}
private async request(path: string, init: RequestInit): Promise<{res: Response; text: string}> {
const cfg = this.requireEnabledConfig();
const res = await this.deps.fetch(`${cfg.baseUrl}${path}`, {
...init,
headers: {
Authorization: basicAuth(cfg.username, cfg.password),
...(init.headers ?? {}),
},
});
const text = await res.text().catch(() => '');
return {res, text};
}
private parseReportResponse(xml: string): ReportResponse {
const parsed = this.safeParse(xml);
return (parsed?.reportResponse ?? {}) as ReportResponse;
}
private parseReportDoneResponse(xml: string): ReportDoneResponse {
const parsed = this.safeParse(xml);
return (parsed?.reportDoneResponse ?? {}) as ReportDoneResponse;
}
private safeParse(xml: string): any | null {
if (!xml) return null;
try {
return this.parser.parse(xml);
} catch {
return null;
}
}
private ensureOk(res: Response, parsed: {responseCode?: number; responseDescription?: string}, body: string): void {
const requestId = res.headers.get('Request-ID');
const responseCode = parsed?.responseCode ?? null;
const responseDescription = parsed?.responseDescription ?? null;
if (res.ok && responseCode === 0) return;
const message =
responseCode !== null
? `NCMEC request failed (http ${res.status}, responseCode ${responseCode}).`
: `NCMEC request failed (http ${res.status}).`;
Logger.warn(
{status: res.status, responseCode, responseDescription, requestId, body: body || '<no body>'},
'NCMEC request failed',
);
throw new NcmecRequestError(message, res.status, responseCode, responseDescription, requestId, body || null);
}
private makeError(
message: string,
res: Response,
parsed: {responseCode?: number; responseDescription?: string},
body: string,
): NcmecRequestError {
return new NcmecRequestError(
message,
res.status,
parsed?.responseCode ?? null,
parsed?.responseDescription ?? null,
res.headers.get('Request-ID'),
body || null,
);
}
private requireEnabledConfig(): Extract<NcmecApiConfig, {enabled: true}> {
const cfg = this.deps.config;
if (!cfg.enabled) throw new Error('NCMEC reporting is disabled.');
return cfg;
}
private async withTelemetry<T>(operation: NcmecTelemetryOperation, fn: () => Promise<T>): Promise<T> {
if (!this.deps.config.enabled) {
recordNcmecSubmission({operation, status: 'disabled'});
throw new Error('NCMEC reporting is disabled.');
}
try {
const result = await fn();
recordNcmecSubmission({operation, status: 'success'});
return result;
} catch (error) {
recordNcmecSubmission({operation, status: 'error'});
Logger.error({error, operation}, 'NCMEC operation failed');
throw error;
}
}
}
export function createNcmecApiConfig(): NcmecApiConfig {
if (!Config.ncmec.enabled) {
return {enabled: false, baseUrl: null, username: null, password: null};
}
return {
enabled: true,
baseUrl: normaliseBaseUrl(Config.ncmec.baseUrl),
username: normaliseRequiredValue(Config.ncmec.username, 'NCMEC username'),
password: normaliseRequiredValue(Config.ncmec.password, 'NCMEC password'),
};
}
export function createNcmecApi(): NcmecReporter {
return new NcmecReporter({config: createNcmecApiConfig(), fetch});
}
function basicAuth(username: string, password: string): string {
return `Basic ${Buffer.from(`${username}:${password}`, 'utf-8').toString('base64')}`;
}
function normaliseBaseUrl(rawUrl: string | undefined | null): string {
const trimmed = (rawUrl ?? '').trim();
if (!trimmed) throw new Error('NCMEC base URL is required when reporting is enabled.');
return trimmed.replace(/\/+$/, '');
}
function normaliseRequiredValue(value: string | undefined | null, label: string): string {
const trimmed = (value ?? '').trim();
if (!trimmed) throw new Error(`${label} is required when reporting is enabled.`);
return trimmed;
}
function normaliseResponseId(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : null;
}
return null;
}

View File

@@ -0,0 +1,254 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {NcmecReporter} from '@fluxer/api/src/csam/NcmecReporter';
import {fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {CsamEvidencePackageRow, NcmecSubmissionRow} from '@fluxer/api/src/database/types/CsamTypes';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {Logger} from '@fluxer/api/src/Logger';
import type {IARSubmission} from '@fluxer/api/src/report/IReportRepository';
import type {ReportRepository} from '@fluxer/api/src/report/ReportRepository';
import {CsamEvidencePackages, NcmecSubmissions} from '@fluxer/api/src/Tables';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
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 {XMLBuilder} from 'fast-xml-parser';
export type NcmecSubmissionStatus = 'not_submitted' | 'submitted' | 'failed';
export interface NcmecSubmissionStatusResponse {
status: NcmecSubmissionStatus;
ncmec_report_id: string | null;
submitted_at: string | null;
submitted_by_admin_id: string | null;
failure_reason: string | null;
}
export interface NcmecSubmitResult {
success: boolean;
ncmec_report_id: string | null;
error: string | null;
}
interface NcmecSubmissionServiceDeps {
reportRepository: ReportRepository;
ncmecApi: NcmecReporter;
storageService: IStorageService;
}
const GET_SUBMISSION_QUERY = NcmecSubmissions.select({
where: NcmecSubmissions.where.eq('report_id'),
limit: 1,
});
const GET_EVIDENCE_PACKAGE_QUERY = CsamEvidencePackages.select({
where: CsamEvidencePackages.where.eq('report_id'),
limit: 1,
});
export class NcmecSubmissionService {
constructor(private readonly deps: NcmecSubmissionServiceDeps) {}
async getSubmissionStatus(reportId: ReportID): Promise<NcmecSubmissionStatusResponse> {
await this.requireChildSafetyReport(reportId);
const submission = await fetchOne<NcmecSubmissionRow>(GET_SUBMISSION_QUERY.bind({report_id: BigInt(reportId)}));
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,
};
}
async submitToNcmec(reportId: ReportID, adminUserId: UserID): Promise<NcmecSubmitResult> {
const report = await this.requireChildSafetyReport(reportId);
const existingSubmission = await fetchOne<NcmecSubmissionRow>(
GET_SUBMISSION_QUERY.bind({report_id: BigInt(reportId)}),
);
if (existingSubmission?.status === 'submitted') {
throw new NcmecAlreadySubmittedError();
}
const evidencePackage = await fetchOne<CsamEvidencePackageRow>(
GET_EVIDENCE_PACKAGE_QUERY.bind({report_id: BigInt(reportId)}),
);
const reportXml = buildSimpleNcmecReportXml(report);
let ncmecReportId: string | null = null;
try {
ncmecReportId = await this.deps.ncmecApi.submitReport(reportXml);
if (evidencePackage?.evidence_zip_key) {
const evidenceBuffer = await this.deps.storageService.readObject(
Config.s3.buckets.reports,
evidencePackage.evidence_zip_key,
);
await this.deps.ncmecApi.uploadEvidence(ncmecReportId, new Uint8Array(evidenceBuffer), 'evidence.zip');
}
await this.deps.ncmecApi.finish(ncmecReportId);
await this.writeSubmissionRow({
reportId,
adminUserId,
existingCreatedAt: existingSubmission?.created_at ?? null,
status: 'submitted',
ncmecReportId,
failureReason: null,
submittedAt: new Date(),
});
Logger.info(
{reportId: reportId.toString(), adminUserId: adminUserId.toString()},
'NCMEC report submitted successfully',
);
return {success: true, ncmec_report_id: ncmecReportId, error: null};
} catch (error) {
if (ncmecReportId) {
try {
await this.deps.ncmecApi.retract(ncmecReportId);
} catch (retractError) {
Logger.warn(
{error: retractError, reportId: reportId.toString()},
'Failed to retract failed NCMEC submission',
);
}
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await this.writeSubmissionRow({
reportId,
adminUserId,
existingCreatedAt: existingSubmission?.created_at ?? null,
status: 'failed',
ncmecReportId: null,
failureReason: errorMessage,
submittedAt: null,
});
Logger.error(
{error, reportId: reportId.toString(), adminUserId: adminUserId.toString()},
'NCMEC report submission failed',
);
throw new NcmecSubmissionFailedError(errorMessage);
}
}
private async requireChildSafetyReport(reportId: ReportID): Promise<IARSubmission> {
const report = await this.deps.reportRepository.getReport(reportId);
if (!report || report.category !== CATEGORY_CHILD_SAFETY) {
throw new UnknownReportError();
}
return report;
}
private async writeSubmissionRow(args: {
reportId: ReportID;
adminUserId: UserID;
existingCreatedAt: Date | null;
status: 'submitted' | 'failed';
ncmecReportId: string | null;
failureReason: string | null;
submittedAt: Date | null;
}): Promise<void> {
const now = new Date();
const row: NcmecSubmissionRow = {
report_id: BigInt(args.reportId),
status: args.status,
ncmec_report_id: args.ncmecReportId,
submitted_at: args.submittedAt,
submitted_by_admin_id: BigInt(args.adminUserId),
failure_reason: args.failureReason,
created_at: args.existingCreatedAt ?? now,
updated_at: now,
};
await upsertOne(NcmecSubmissions.insert(row));
}
}
function buildSimpleNcmecReportXml(report: IARSubmission): string {
const schemaRoot = (Config.ncmec.baseUrl ?? 'https://report.cybertip.org/ispws').replace(/\/+$/, '');
const schemaLocation = `${schemaRoot}/xsd`;
const incidentSummary: Record<string, unknown> = {
incidentType: 'Child Pornography (possession, manufacture, and distribution)',
incidentDateTime: report.reportedAt.toISOString(),
};
if (report.additionalInfo) {
incidentSummary.additionalInfo = report.additionalInfo;
}
const reportingPerson: Record<string, unknown> = {};
if (report.reporterEmail) reportingPerson.email = report.reporterEmail;
const {firstName, lastName} = splitName(report.reporterFullLegalName);
if (firstName) reportingPerson.firstName = firstName;
if (lastName) reportingPerson.lastName = lastName;
const reportNode: Record<string, unknown> = {
'@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'@_xsi:noNamespaceSchemaLocation': schemaLocation,
incidentSummary,
};
if (Object.keys(reportingPerson).length > 0) {
reportNode.reporter = {reportingPerson};
}
const builder = new XMLBuilder({
attributeNamePrefix: '@_',
format: false,
ignoreAttributes: false,
suppressEmptyNode: true,
});
return `<?xml version="1.0" encoding="UTF-8"?>${builder.build({report: reportNode})}`;
}
function splitName(fullName: string | null): {firstName?: string; lastName?: string} {
const trimmed = (fullName ?? '').trim();
if (!trimmed) return {};
const parts = trimmed.split(/\s+/);
if (parts.length === 1) return {firstName: parts[0]};
const [firstName, ...rest] = parts;
return {firstName, lastName: rest.join(' ')};
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import type {FrameSample} from '@fluxer/api/src/csam/CsamTypes';
import {Logger} from '@fluxer/api/src/Logger';
interface HashServiceRequest {
images: Array<{
mime_type: string;
data: string;
}>;
}
interface HashServiceResponse {
hashes: Array<string>;
errors?: Array<string>;
}
export interface IPhotoDnaHashClient {
hashFrames(frames: Array<FrameSample>): Promise<Array<string>>;
}
export class PhotoDnaHashClient implements IPhotoDnaHashClient {
private readonly endpoint: string;
private readonly timeoutMs: number;
constructor() {
if (!Config.photoDna.enabled) {
throw new Error('PhotoDNA hash client initialized while the feature is disabled');
}
const url = Config.photoDna.hashService.url;
if (!url) {
throw new Error('PhotoDNA hash service URL is not configured');
}
this.endpoint = url;
this.timeoutMs = Config.photoDna.hashService.timeoutMs;
}
async hashFrames(frames: Array<FrameSample>): Promise<Array<string>> {
const url = `${this.endpoint.replace(/\/$/, '')}/hash`;
const body: HashServiceRequest = {
images: frames.map((frame) => ({
mime_type: frame.mimeType,
data: frame.base64,
})),
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const text = await response.text().catch(() => '<no body>');
throw new Error(`PhotoDNA hash service returned ${response.status}: ${text}`);
}
const result = (await response.json()) as HashServiceResponse;
if (!Array.isArray(result.hashes)) {
throw new Error('Invalid response from PhotoDNA hash service');
}
return result.hashes;
} catch (error) {
Logger.error({error}, 'Failed to compute PhotoDNA hashes');
throw error;
} finally {
clearTimeout(timeout);
}
}
}

View File

@@ -0,0 +1,214 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import type {PhotoDnaMatchDetail, PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {
recordCsamMatch,
recordPhotoDnaApiCall,
recordPhotoDnaApiDuration,
} from '@fluxer/api/src/telemetry/CsamTelemetry';
import {ms} from 'itty-time';
interface MatchRequestItem {
DataRepresentation: 'Hash';
Value: string;
}
interface MatchResponseResult {
Status: {
Code: number;
Description?: string;
};
ContentId?: string | null;
IsMatch: boolean;
MatchDetails?: {
MatchFlags?: Array<{
AdvancedInfo?: Array<{Key: string; Value: string}>;
Source: string;
Violations?: Array<string>;
MatchDistance?: number;
}>;
};
XPartnerCustomerId?: string | null;
TrackingId?: string | null;
}
interface MatchResponse {
TrackingId: string;
MatchResults: Array<MatchResponseResult>;
}
export class PhotoDnaMatchService {
private readonly minIntervalMs = Math.floor(1000 / Math.max(Config.photoDna.rateLimit.requestsPerSecond, 1));
private requestChain: Promise<unknown> = Promise.resolve();
private nextAvailableTimestamp = Date.now();
private readonly subscriptionKey: string;
constructor() {
if (!Config.photoDna.enabled) {
throw new Error('PhotoDNA match service initialized while the feature is disabled');
}
const subscriptionKey = Config.photoDna.api.subscriptionKey;
if (!subscriptionKey) {
throw new Error('PhotoDNA subscription key is not configured');
}
this.subscriptionKey = subscriptionKey;
}
async matchHashes(hashes: Array<string>): Promise<PhotoDnaMatchResult> {
const normalized = hashes.filter((hash) => typeof hash === 'string' && hash.trim().length > 0);
if (normalized.length === 0) {
return {
isMatch: false,
trackingId: '',
matchDetails: [],
timestamp: new Date().toISOString(),
};
}
const aggregated: Array<PhotoDnaMatchDetail> = [];
let trackingId = '';
let isMatch = false;
const chunks = this.chunkArray(normalized, 5);
for (const chunk of chunks) {
const response = await this.schedule(() => this.callMatchApi(chunk));
if (!response) {
continue;
}
trackingId = response.TrackingId || trackingId;
for (const result of response.MatchResults ?? []) {
if (result.IsMatch) {
isMatch = true;
}
const flags = result.MatchDetails?.MatchFlags ?? [];
for (const flag of flags) {
const matchId = flag.AdvancedInfo?.find((info) => info.Key === 'MatchId')?.Value;
aggregated.push({
source: flag.Source,
violations: flag.Violations ?? [],
matchDistance: flag.MatchDistance ?? 0,
matchId,
});
}
}
}
if (isMatch && aggregated.length > 0) {
const source = aggregated[0]?.source ?? 'unknown';
recordCsamMatch({
resourceType: 'other',
source,
matchCount: aggregated.length,
});
}
return {
isMatch,
trackingId,
matchDetails: aggregated,
timestamp: new Date().toISOString(),
};
}
private async callMatchApi(chunk: Array<string>): Promise<MatchResponse | null> {
const startTime = Date.now();
const url = new URL(Config.photoDna.api.endpoint);
url.searchParams.set('enhance', Config.photoDna.api.enhance ? 'true' : 'false');
const body = chunk.map<MatchRequestItem>((hash) => ({
DataRepresentation: 'Hash',
Value: hash,
}));
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': this.subscriptionKey,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(ms('30 seconds')),
});
const durationMs = Date.now() - startTime;
if (!response.ok) {
const details = await response.text().catch(() => '<no details>');
Logger.warn(
{
status: response.status,
url: url.toString(),
body: details,
},
'PhotoDNA match request failed',
);
recordPhotoDnaApiCall({
operation: 'match',
status: 'error',
hashCount: chunk.length,
});
recordPhotoDnaApiDuration({
operation: 'match',
durationMs,
});
return null;
}
recordPhotoDnaApiCall({
operation: 'match',
status: 'success',
hashCount: chunk.length,
});
recordPhotoDnaApiDuration({
operation: 'match',
durationMs,
});
return (await response.json()) as MatchResponse;
}
private async schedule<T>(fn: () => Promise<T>): Promise<T> {
const scheduled = this.requestChain.then(async () => {
const now = Date.now();
const wait = Math.max(0, this.nextAvailableTimestamp - now);
if (wait > 0) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
this.nextAvailableTimestamp = Math.max(this.nextAvailableTimestamp, Date.now()) + this.minIntervalMs;
return fn();
});
this.requestChain = scheduled.catch(() => {});
return scheduled;
}
private chunkArray<T>(values: Array<T>, size: number): Array<Array<T>> {
const chunks: Array<Array<T>> = [];
for (let i = 0; i < values.length; i += size) {
chunks.push(values.slice(i, i + size));
}
return chunks;
}
}

View File

@@ -0,0 +1,359 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CsamScanContext, ICsamScanQueueService} from '@fluxer/api/src/csam/CsamScanQueueService';
import type {CsamResourceType, FrameSample, PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {ISynchronousCsamScanner} from '@fluxer/api/src/csam/ISynchronousCsamScanner';
import type {IPhotoDnaHashClient} from '@fluxer/api/src/csam/PhotoDnaHashClient';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import {recordCsamMatch, recordCsamScan, recordCsamScanDuration} from '@fluxer/api/src/telemetry/CsamTelemetry';
export interface SynchronousCsamScannerOptions {
enabled: boolean;
logger: ILogger;
}
export interface ScanMediaParams {
bucket: string;
key: string;
contentType: string | null;
context?: CsamScanContext;
}
export interface ScanBase64Params {
base64: string;
mimeType: string;
context?: CsamScanContext;
}
export interface SynchronousCsamScanResult {
isMatch: boolean;
matchResult?: PhotoDnaMatchResult;
frames?: Array<FrameSample>;
hashes?: Array<string>;
}
const SCANNABLE_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/tiff',
'image/avif',
'image/apng',
]);
const SCANNABLE_VIDEO_TYPES = new Set([
'video/mp4',
'video/webm',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska',
]);
function isScannableMediaType(contentType: string | null): boolean {
if (!contentType) {
return false;
}
const normalized = contentType.toLowerCase().split(';')[0].trim();
return SCANNABLE_IMAGE_TYPES.has(normalized) || SCANNABLE_VIDEO_TYPES.has(normalized);
}
function isVideoType(contentType: string): boolean {
const normalized = contentType.toLowerCase().split(';')[0].trim();
return SCANNABLE_VIDEO_TYPES.has(normalized);
}
function createDefaultContext(resourceType: CsamResourceType = 'other'): CsamScanContext {
return {
resourceType,
userId: null,
guildId: null,
channelId: null,
messageId: null,
};
}
export class SynchronousCsamScanner implements ISynchronousCsamScanner {
constructor(
private readonly hashClient: IPhotoDnaHashClient,
private readonly mediaService: IMediaService,
private readonly queueService: ICsamScanQueueService | null,
private readonly options: SynchronousCsamScannerOptions,
) {}
async scanMedia(params: ScanMediaParams): Promise<SynchronousCsamScanResult> {
if (!this.options.enabled || !this.queueService) {
return {isMatch: false};
}
const context = params.context ?? createDefaultContext();
const mediaType = params.contentType ?? 'unknown';
if (!isScannableMediaType(params.contentType)) {
this.options.logger.debug(
{bucket: params.bucket, key: params.key, contentType: params.contentType},
'Skipping non-scannable media type',
);
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'skipped',
});
return {isMatch: false};
}
return await withBusinessSpan(
'fluxer.csam.scan_media',
'fluxer.csam.scans.media',
{resource_type: context.resourceType},
async () => {
const startTime = Date.now();
try {
const frames = await this.extractFramesFromS3(params.bucket, params.key, params.contentType!);
if (frames.length === 0) {
this.options.logger.debug({bucket: params.bucket, key: params.key}, 'No frames extracted from media');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false};
}
const hashes = await this.hashClient.hashFrames(frames);
if (hashes.length === 0) {
this.options.logger.debug('No hashes generated from frames');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false, frames, hashes: []};
}
const result = await this.queueService!.submitScan({
hashes,
context,
});
const durationMs = Date.now() - startTime;
if (result.isMatch) {
this.options.logger.warn(
{trackingId: result.matchResult?.trackingId, matchCount: result.matchResult?.matchDetails.length},
'CSAM match detected',
);
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount: result.matchResult?.matchDetails.length ?? 0,
});
}
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {
isMatch: result.isMatch,
matchResult: result.matchResult,
frames,
hashes,
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.options.logger.error({error, bucket: params.bucket, key: params.key}, 'Failed to scan media');
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'error',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
throw error;
}
},
);
}
async scanBase64(params: ScanBase64Params): Promise<SynchronousCsamScanResult> {
if (!this.options.enabled || !this.queueService) {
return {isMatch: false};
}
const context = params.context ?? createDefaultContext();
if (!isScannableMediaType(params.mimeType)) {
this.options.logger.debug({mimeType: params.mimeType}, 'Skipping non-scannable media type');
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'skipped',
});
return {isMatch: false};
}
return await withBusinessSpan(
'fluxer.csam.scan_base64',
'fluxer.csam.scans.base64',
{resource_type: context.resourceType},
async () => {
const startTime = Date.now();
try {
const frames: Array<FrameSample> = [
{
timestamp: 0,
mimeType: params.mimeType,
base64: params.base64,
},
];
const hashes = await this.hashClient.hashFrames(frames);
if (hashes.length === 0) {
this.options.logger.debug('No hashes generated from frames');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false, frames, hashes: []};
}
const result = await this.queueService!.submitScan({
hashes,
context,
});
const durationMs = Date.now() - startTime;
if (result.isMatch) {
this.options.logger.warn(
{trackingId: result.matchResult?.trackingId, matchCount: result.matchResult?.matchDetails.length},
'CSAM match detected',
);
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount: result.matchResult?.matchDetails.length ?? 0,
});
}
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {
isMatch: result.isMatch,
matchResult: result.matchResult,
frames,
hashes,
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.options.logger.error({error, mimeType: params.mimeType}, 'Failed to scan base64 media');
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'error',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
throw error;
}
},
);
}
private async extractFramesFromS3(bucket: string, key: string, contentType: string): Promise<Array<FrameSample>> {
if (isVideoType(contentType)) {
const response = await this.mediaService.extractFrames({
type: 's3',
bucket,
key,
});
return response.frames.map((frame) => ({
timestamp: frame.timestamp,
mimeType: frame.mime_type,
base64: frame.base64,
}));
}
const metadata = await this.mediaService.getMetadata({
type: 's3',
bucket,
key,
with_base64: true,
isNSFWAllowed: true,
});
if (!metadata) {
throw new Error('Media proxy returned no metadata for image scan');
}
if (!metadata.base64) {
throw new Error('Media proxy returned metadata without base64 for image scan');
}
return [
{
timestamp: 0,
mimeType: metadata.content_type,
base64: metadata.base64,
},
];
}
}

View File

@@ -0,0 +1,387 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {S3ServiceException} from '@aws-sdk/client-s3';
import type {CsamResourceType} from '@fluxer/api/src/csam/CsamTypes';
import type {
CsamMatchResult,
CsamScanContext,
CsamScanProvider,
CsamScanResult,
ICsamScanProvider,
ScanBase64Params,
ScanMediaParams,
} from '@fluxer/api/src/csam/providers/ICsamScanProvider';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import {
recordArachnidApiCall,
recordArachnidApiDuration,
recordCsamMatch,
recordCsamScan,
recordCsamScanDuration,
} from '@fluxer/api/src/telemetry/CsamTelemetry';
export interface ArachnidShieldConfig {
endpoint: string;
username: string;
password: string;
timeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
}
export interface ArachnidShieldProviderOptions {
config: ArachnidShieldConfig;
logger: ILogger;
storageService: IStorageService;
}
type ArachnidClassification = 'csam' | 'harmful-abusive-material' | 'no-known-match' | 'test';
interface ArachnidNearMatchDetail {
classification: ArachnidClassification;
sha1_base32: string;
sha256_hex: string;
timestamp: number;
}
interface ArachnidShieldResponse {
classification: ArachnidClassification;
match_type: 'exact' | 'near' | null;
near_match_details?: Array<ArachnidNearMatchDetail>;
sha1_base32: string;
sha256_hex: string;
size_bytes: number;
}
class NonRetryableApiError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = 'NonRetryableApiError';
this.statusCode = statusCode;
}
}
function normaliseMediaType(contentType: string): string {
return contentType.toLowerCase().split(';')[0].trim();
}
function isScannableMediaType(contentType: string | null): boolean {
if (!contentType) {
return false;
}
const t = normaliseMediaType(contentType);
return t.startsWith('image/') || t.startsWith('video/');
}
function defaultContext(): CsamScanContext {
return {
resourceType: 'other',
userId: null,
guildId: null,
channelId: null,
messageId: null,
};
}
function parseRateLimitHeader(header: string | null): {remaining: number; resetSeconds: number} | null {
if (!header) {
return null;
}
const match = header.match(/r=(\d+);t=(\d+)/);
if (!match) {
return null;
}
return {
remaining: Number.parseInt(match[1], 10),
resetSeconds: Number.parseInt(match[2], 10),
};
}
function isRetryableStatus(status: number): boolean {
return status === 429 || status === 503 || status >= 500;
}
function matchesClassification(classification: ArachnidClassification): boolean {
return classification === 'csam' || classification === 'harmful-abusive-material';
}
export class ArachnidShieldProvider implements ICsamScanProvider {
readonly providerName: CsamScanProvider = 'arachnid_shield';
private readonly config: ArachnidShieldConfig;
private readonly logger: ILogger;
private readonly storageService: IStorageService;
private readonly authHeader: string;
constructor(options: ArachnidShieldProviderOptions) {
this.config = options.config;
this.logger = options.logger;
this.storageService = options.storageService;
this.authHeader = `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`;
}
async scanMedia(params: ScanMediaParams): Promise<CsamScanResult> {
const context = params.context ?? defaultContext();
const mediaType = params.contentType ?? 'unknown';
if (!isScannableMediaType(params.contentType)) {
this.logger.debug(
{bucket: params.bucket, key: params.key, contentType: params.contentType},
'Skipping non-scannable media type',
);
recordCsamScan({resourceType: context.resourceType, mediaType, status: 'skipped'});
return {isMatch: false};
}
return this.withSpan('fluxer.csam.scan_media', 'fluxer.csam.scans.media', context, async () => {
const start = Date.now();
try {
let data: Uint8Array;
try {
data = await this.storageService.readObject(params.bucket, params.key);
} catch (storageError) {
if (
storageError instanceof S3ServiceException &&
(storageError.name === 'NoSuchKey' || storageError.name === 'NotFound')
) {
this.logger.debug({bucket: params.bucket, key: params.key}, 'File not found in storage');
this.recordScan(context.resourceType, mediaType, 'error', Date.now() - start);
return {isMatch: false};
}
throw storageError;
}
const response = await this.submitToArachnid(data, normaliseMediaType(params.contentType!));
const result = this.translateResponse(response);
if (result.isMatch) {
const matchCount = result.matchResult?.matchDetails.length ?? 0;
this.logger.warn({trackingId: result.matchResult?.trackingId, matchCount}, 'CSAM match detected');
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount,
});
}
this.recordScan(context.resourceType, mediaType, 'success', Date.now() - start);
return {
...result,
hashes: [response.sha256_hex],
};
} catch (error) {
this.logger.error({error, bucket: params.bucket, key: params.key}, 'Failed to scan media');
this.recordScan(context.resourceType, mediaType, 'error', Date.now() - start);
throw error;
}
});
}
async scanBase64(params: ScanBase64Params): Promise<CsamScanResult> {
const context = params.context ?? defaultContext();
if (!isScannableMediaType(params.mimeType)) {
this.logger.debug({mimeType: params.mimeType}, 'Skipping non-scannable media type');
recordCsamScan({resourceType: context.resourceType, mediaType: params.mimeType, status: 'skipped'});
return {isMatch: false};
}
return this.withSpan('fluxer.csam.scan_base64', 'fluxer.csam.scans.base64', context, async () => {
const start = Date.now();
try {
const data = new Uint8Array(Buffer.from(params.base64, 'base64'));
const response = await this.submitToArachnid(data, normaliseMediaType(params.mimeType));
const result = this.translateResponse(response);
if (result.isMatch) {
const matchCount = result.matchResult?.matchDetails.length ?? 0;
this.logger.warn({trackingId: result.matchResult?.trackingId, matchCount}, 'CSAM match detected');
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount,
});
}
this.recordScan(context.resourceType, params.mimeType, 'success', Date.now() - start);
return {
...result,
hashes: [response.sha256_hex],
};
} catch (error) {
this.logger.error({error, mimeType: params.mimeType}, 'Failed to scan base64 media');
this.recordScan(context.resourceType, params.mimeType, 'error', Date.now() - start);
throw error;
}
});
}
private async withSpan<T>(
spanName: string,
metricName: string,
context: CsamScanContext,
fn: () => Promise<T>,
): Promise<T> {
return withBusinessSpan(
spanName,
metricName,
{resource_type: context.resourceType, provider: this.providerName},
fn,
);
}
private recordScan(
resourceType: CsamResourceType,
mediaType: string,
status: 'success' | 'error' | 'skipped',
durationMs: number,
): void {
recordCsamScan({resourceType, mediaType, status});
recordCsamScanDuration({resourceType, durationMs});
}
private async submitToArachnid(fileData: Uint8Array, contentType: string): Promise<ArachnidShieldResponse> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) {
const apiStart = Date.now();
try {
const response = await this.fetchWithTimeout(fileData, contentType);
recordArachnidApiDuration({durationMs: Date.now() - apiStart});
if (response.ok) {
recordArachnidApiCall({status: 'success'});
return (await response.json()) as ArachnidShieldResponse;
}
recordArachnidApiCall({status: 'error'});
if (!isRetryableStatus(response.status)) {
const text = await response.text().catch(() => '<no body>');
throw new NonRetryableApiError(`Arachnid Shield API returned ${response.status}: ${text}`, response.status);
}
const rateLimit = parseRateLimitHeader(response.headers.get('ratelimit'));
const waitMs = this.computeWaitMs(attempt, rateLimit);
if (attempt < this.config.maxRetries) {
this.logger.warn(
{attempt, status: response.status, waitMs, rateLimit},
'Arachnid Shield API request failed, retrying',
);
await this.sleep(waitMs);
continue;
}
throw new Error(`Arachnid Shield API returned ${response.status} after ${attempt + 1} attempts`);
} catch (error) {
if (error instanceof NonRetryableApiError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
recordArachnidApiCall({status: 'timeout'});
lastError = new Error(`Arachnid Shield API request timed out after ${this.config.timeoutMs}ms`);
} else {
lastError = error instanceof Error ? error : new Error(String(error));
}
if (attempt < this.config.maxRetries) {
const waitMs = this.computeWaitMs(attempt, null);
this.logger.warn({attempt, error: lastError.message, waitMs}, 'Arachnid Shield API request failed, retrying');
await this.sleep(waitMs);
}
}
}
throw lastError ?? new Error('Arachnid Shield API request failed');
}
private computeWaitMs(attempt: number, rateLimit: {remaining: number; resetSeconds: number} | null): number {
if (rateLimit && rateLimit.remaining === 0) {
return rateLimit.resetSeconds * 1000;
}
return Math.min(this.config.retryBackoffMs * 2 ** attempt, 60000);
}
private async fetchWithTimeout(fileData: Uint8Array, contentType: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
try {
return await fetch(this.config.endpoint, {
method: 'POST',
headers: {
Authorization: this.authHeader,
'Content-Type': contentType,
},
body: fileData,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
private translateResponse(response: ArachnidShieldResponse): CsamScanResult {
if (!matchesClassification(response.classification)) {
return {isMatch: false};
}
const matchDistance = response.match_type === 'near' ? 1 : 0;
const matchDetails = response.near_match_details?.map((detail) => ({
source: 'arachnid',
violations: [detail.classification],
matchDistance,
matchId: detail.sha256_hex,
})) ?? [
{
source: 'arachnid',
violations: [response.classification],
matchDistance,
matchId: response.sha256_hex,
},
];
const matchResult: CsamMatchResult = {
isMatch: true,
trackingId: response.sha256_hex,
matchDetails,
timestamp: new Date().toISOString(),
provider: this.providerName,
};
return {isMatch: true, matchResult};
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICsamScanQueueService} from '@fluxer/api/src/csam/CsamScanQueueService';
import type {IPhotoDnaHashClient} from '@fluxer/api/src/csam/PhotoDnaHashClient';
import {ArachnidShieldProvider} from '@fluxer/api/src/csam/providers/ArachnidShieldProvider';
import type {CsamScanProvider, ICsamScanProvider} from '@fluxer/api/src/csam/providers/ICsamScanProvider';
import {PhotoDnaProvider} from '@fluxer/api/src/csam/providers/PhotoDnaProvider';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
export interface CsamIntegrationConfig {
enabled: boolean;
provider: CsamScanProvider;
photoDna: {
hashServiceUrl: string;
hashServiceTimeoutMs: number;
matchEndpoint: string;
subscriptionKey: string;
matchEnhance: boolean;
rateLimitRps: number;
};
arachnidShield: {
endpoint: string;
username: string;
password: string;
timeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
};
}
export interface CsamProviderFactoryDeps {
logger: ILogger;
mediaService: IMediaService;
storageService: IStorageService;
hashClient?: IPhotoDnaHashClient;
queueService?: ICsamScanQueueService;
}
export function createCsamProvider(
config: CsamIntegrationConfig,
deps: CsamProviderFactoryDeps,
): ICsamScanProvider | null {
if (!config.enabled) {
deps.logger.info('CSAM scanning is disabled');
return null;
}
switch (config.provider) {
case 'photo_dna': {
if (!deps.hashClient) {
deps.logger.error('PhotoDNA hash client is required but not provided');
return null;
}
if (!deps.queueService) {
deps.logger.error('CSAM scan queue service is required but not provided');
return null;
}
deps.logger.info('Using PhotoDNA provider for CSAM scanning');
return new PhotoDnaProvider(deps.hashClient, deps.mediaService, deps.queueService, {
logger: deps.logger,
});
}
case 'arachnid_shield': {
if (!config.arachnidShield.username || !config.arachnidShield.password) {
deps.logger.error('Arachnid Shield credentials are required but not configured');
return null;
}
deps.logger.info('Using Arachnid Shield provider for CSAM scanning');
return new ArachnidShieldProvider({
config: {
endpoint: config.arachnidShield.endpoint,
username: config.arachnidShield.username,
password: config.arachnidShield.password,
timeoutMs: config.arachnidShield.timeoutMs,
maxRetries: config.arachnidShield.maxRetries,
retryBackoffMs: config.arachnidShield.retryBackoffMs,
},
logger: deps.logger,
storageService: deps.storageService,
});
}
default: {
deps.logger.error({provider: config.provider}, 'Unknown CSAM provider');
return null;
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CsamResourceType, FrameSample} from '@fluxer/api/src/csam/CsamTypes';
export type CsamScanProvider = 'photo_dna' | 'arachnid_shield';
export interface CsamMatchDetail {
source: string;
violations: Array<string>;
matchDistance: number;
matchId?: string;
}
export interface CsamMatchResult {
isMatch: boolean;
trackingId: string;
matchDetails: Array<CsamMatchDetail>;
timestamp: string;
provider: CsamScanProvider;
}
export interface CsamScanResult {
isMatch: boolean;
matchResult?: CsamMatchResult;
frames?: Array<FrameSample>;
hashes?: Array<string>;
}
export interface CsamScanContext {
resourceType: CsamResourceType;
userId: string | null;
guildId: string | null;
channelId: string | null;
messageId: string | null;
}
export interface ScanMediaParams {
bucket: string;
key: string;
contentType: string | null;
context?: CsamScanContext;
}
export interface ScanBase64Params {
base64: string;
mimeType: string;
context?: CsamScanContext;
}
export interface ICsamScanProvider {
readonly providerName: CsamScanProvider;
scanMedia(params: ScanMediaParams): Promise<CsamScanResult>;
scanBase64(params: ScanBase64Params): Promise<CsamScanResult>;
}

View File

@@ -0,0 +1,357 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ICsamScanQueueService} from '@fluxer/api/src/csam/CsamScanQueueService';
import type {FrameSample} from '@fluxer/api/src/csam/CsamTypes';
import type {IPhotoDnaHashClient} from '@fluxer/api/src/csam/PhotoDnaHashClient';
import type {
CsamMatchResult,
CsamScanContext,
CsamScanProvider,
CsamScanResult,
ICsamScanProvider,
ScanBase64Params,
ScanMediaParams,
} from '@fluxer/api/src/csam/providers/ICsamScanProvider';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {IMediaService} from '@fluxer/api/src/infrastructure/IMediaService';
import {withBusinessSpan} from '@fluxer/api/src/telemetry/BusinessSpans';
import {recordCsamMatch, recordCsamScan, recordCsamScanDuration} from '@fluxer/api/src/telemetry/CsamTelemetry';
export interface PhotoDnaProviderOptions {
logger: ILogger;
}
function isScannableMediaType(contentType: string | null): boolean {
if (!contentType) {
return false;
}
const normalized = contentType.toLowerCase().split(';')[0].trim();
return normalized.startsWith('image/') || normalized.startsWith('video/');
}
function isVideoType(contentType: string): boolean {
const normalized = contentType.toLowerCase().split(';')[0].trim();
return normalized.startsWith('video/');
}
function createDefaultContext(): CsamScanContext {
return {
resourceType: 'other',
userId: null,
guildId: null,
channelId: null,
messageId: null,
};
}
export class PhotoDnaProvider implements ICsamScanProvider {
readonly providerName: CsamScanProvider = 'photo_dna';
constructor(
private readonly hashClient: IPhotoDnaHashClient,
private readonly mediaService: IMediaService,
private readonly queueService: ICsamScanQueueService,
private readonly options: PhotoDnaProviderOptions,
) {}
async scanMedia(params: ScanMediaParams): Promise<CsamScanResult> {
const context = params.context ?? createDefaultContext();
const mediaType = params.contentType ?? 'unknown';
if (!isScannableMediaType(params.contentType)) {
this.options.logger.debug(
{bucket: params.bucket, key: params.key, contentType: params.contentType},
'Skipping non-scannable media type',
);
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'skipped',
});
return {isMatch: false};
}
return await withBusinessSpan(
'fluxer.csam.scan_media',
'fluxer.csam.scans.media',
{resource_type: context.resourceType, provider: this.providerName},
async () => {
const startTime = Date.now();
try {
const frames = await this.extractFramesFromS3(params.bucket, params.key, params.contentType!);
if (frames.length === 0) {
this.options.logger.debug({bucket: params.bucket, key: params.key}, 'No frames extracted from media');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false};
}
const hashes = await this.hashClient.hashFrames(frames);
if (hashes.length === 0) {
this.options.logger.debug('No hashes generated from frames');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false, frames, hashes: []};
}
const result = await this.queueService.submitScan({
hashes,
context: {
resourceType: context.resourceType,
userId: context.userId,
guildId: context.guildId,
channelId: context.channelId,
messageId: context.messageId,
},
});
const durationMs = Date.now() - startTime;
let matchResult: CsamMatchResult | undefined;
if (result.matchResult) {
matchResult = {
isMatch: result.matchResult.isMatch,
trackingId: result.matchResult.trackingId,
matchDetails: result.matchResult.matchDetails.map((detail) => ({
source: detail.source,
violations: detail.violations,
matchDistance: detail.matchDistance,
matchId: detail.matchId,
})),
timestamp: result.matchResult.timestamp,
provider: this.providerName,
};
}
if (result.isMatch) {
const matchCount = matchResult?.matchDetails.length ?? 0;
this.options.logger.warn({trackingId: matchResult?.trackingId, matchCount}, 'CSAM match detected');
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount,
});
}
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {
isMatch: result.isMatch,
matchResult,
frames,
hashes,
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.options.logger.error({error, bucket: params.bucket, key: params.key}, 'Failed to scan media');
recordCsamScan({
resourceType: context.resourceType,
mediaType,
status: 'error',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
throw error;
}
},
);
}
async scanBase64(params: ScanBase64Params): Promise<CsamScanResult> {
const context = params.context ?? createDefaultContext();
if (!isScannableMediaType(params.mimeType)) {
this.options.logger.debug({mimeType: params.mimeType}, 'Skipping non-scannable media type');
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'skipped',
});
return {isMatch: false};
}
return await withBusinessSpan(
'fluxer.csam.scan_base64',
'fluxer.csam.scans.base64',
{resource_type: context.resourceType, provider: this.providerName},
async () => {
const startTime = Date.now();
try {
const frames: Array<FrameSample> = [
{
timestamp: 0,
mimeType: params.mimeType,
base64: params.base64,
},
];
const hashes = await this.hashClient.hashFrames(frames);
if (hashes.length === 0) {
this.options.logger.debug('No hashes generated from frames');
const durationMs = Date.now() - startTime;
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {isMatch: false, frames, hashes: []};
}
const result = await this.queueService.submitScan({
hashes,
context: {
resourceType: context.resourceType,
userId: context.userId,
guildId: context.guildId,
channelId: context.channelId,
messageId: context.messageId,
},
});
const durationMs = Date.now() - startTime;
let matchResult: CsamMatchResult | undefined;
if (result.matchResult) {
matchResult = {
isMatch: result.matchResult.isMatch,
trackingId: result.matchResult.trackingId,
matchDetails: result.matchResult.matchDetails.map((detail) => ({
source: detail.source,
violations: detail.violations,
matchDistance: detail.matchDistance,
matchId: detail.matchId,
})),
timestamp: result.matchResult.timestamp,
provider: this.providerName,
};
}
if (result.isMatch) {
const matchCount = matchResult?.matchDetails.length ?? 0;
this.options.logger.warn({trackingId: matchResult?.trackingId, matchCount}, 'CSAM match detected');
recordCsamMatch({
resourceType: context.resourceType,
source: 'synchronous',
matchCount,
});
}
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'success',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
return {
isMatch: result.isMatch,
matchResult,
frames,
hashes,
};
} catch (error) {
const durationMs = Date.now() - startTime;
this.options.logger.error({error, mimeType: params.mimeType}, 'Failed to scan base64 media');
recordCsamScan({
resourceType: context.resourceType,
mediaType: params.mimeType,
status: 'error',
});
recordCsamScanDuration({
resourceType: context.resourceType,
durationMs,
});
throw error;
}
},
);
}
private async extractFramesFromS3(bucket: string, key: string, contentType: string): Promise<Array<FrameSample>> {
if (isVideoType(contentType)) {
const response = await this.mediaService.extractFrames({
type: 's3',
bucket,
key,
});
return response.frames.map((frame) => ({
timestamp: frame.timestamp,
mimeType: frame.mime_type,
base64: frame.base64,
}));
}
const metadata = await this.mediaService.getMetadata({
type: 's3',
bucket,
key,
with_base64: true,
isNSFWAllowed: true,
});
if (!metadata?.base64) {
return [];
}
return [
{
timestamp: 0,
mimeType: metadata.content_type,
base64: metadata.base64,
},
];
}
}

View File

@@ -0,0 +1,539 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import {ArachnidShieldProvider} from '@fluxer/api/src/csam/providers/ArachnidShieldProvider';
import {createNoopLogger, TEST_FIXTURES} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {MockStorageService} from '@fluxer/api/src/test/mocks/MockStorageService';
import {
type ArachnidShieldRequestCapture,
createArachnidShieldErrorHandler,
createArachnidShieldHandler,
createArachnidShieldSequenceHandler,
} from '@fluxer/api/src/test/msw/handlers/ArachnidShieldHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const DEFAULT_CONFIG = {
endpoint: 'https://shield.projectarachnid.com/v1/media',
username: 'test-user',
password: 'test-password',
timeoutMs: 30000,
maxRetries: 3,
retryBackoffMs: 100,
};
function makeProvider(
logger: ILogger,
storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT}),
config = DEFAULT_CONFIG,
): ArachnidShieldProvider {
return new ArachnidShieldProvider({config, logger, storageService});
}
describe('ArachnidShieldProvider', () => {
let logger: ILogger;
beforeEach(() => {
logger = createNoopLogger();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('providerName', () => {
it('returns arachnid_shield as provider name', () => {
const provider = makeProvider(logger);
expect(provider.providerName).toBe('arachnid_shield');
});
});
describe('scanMedia', () => {
it('returns isMatch: false for null content type', async () => {
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: null,
});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key', contentType: null},
'Skipping non-scannable media type',
);
});
it('returns isMatch: false for text/plain content type', async () => {
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'text/plain',
});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false for audio/mp3 content type', async () => {
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'audio/mp3',
});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false when file not found in storage', async () => {
const storageService = new MockStorageService({fileData: null});
const provider = makeProvider(logger, storageService);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith({bucket: 'test-bucket', key: 'test-key'}, 'File not found in storage');
});
it('sends correct headers with basic auth', async () => {
const requestCapture: {current: ArachnidShieldRequestCapture | null} = {current: null};
server.use(createArachnidShieldHandler({}, requestCapture));
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService);
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(requestCapture.current).not.toBeNull();
const expectedAuth = `Basic ${Buffer.from('test-user:test-password').toString('base64')}`;
expect(requestCapture.current!.headers.get('Authorization')).toBe(expectedAuth);
expect(requestCapture.current!.headers.get('Content-Type')).toBe('image/png');
});
it('sends full file data to API', async () => {
const fileData = TEST_FIXTURES.PNG_1X1_TRANSPARENT;
const requestCapture: {current: ArachnidShieldRequestCapture | null} = {current: null};
server.use(createArachnidShieldHandler({}, requestCapture));
const storageService = new MockStorageService({fileData});
const provider = makeProvider(logger, storageService);
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(requestCapture.current).not.toBeNull();
const sentData = new Uint8Array(requestCapture.current!.body);
expect(sentData).toEqual(new Uint8Array(fileData));
});
it('returns isMatch: false for no-known-match classification', async () => {
server.use(createArachnidShieldHandler({classification: 'no-known-match', sha256Hex: 'abc123def456'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
expect(result.hashes).toEqual(['abc123def456']);
});
it('returns isMatch: false for test classification', async () => {
server.use(createArachnidShieldHandler({classification: 'test', sha256Hex: 'test-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
});
it('returns isMatch: true for csam classification', async () => {
const trackingHash = `csam-match-${randomUUID()}`;
server.use(
createArachnidShieldHandler({
classification: 'csam',
sha256Hex: trackingHash,
matchId: 'match-123',
}),
);
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(true);
expect(result.matchResult!.trackingId).toBe(trackingHash);
expect(result.matchResult!.provider).toBe('arachnid_shield');
expect(result.hashes).toEqual([trackingHash]);
});
it('returns isMatch: true for harmful-abusive-material classification', async () => {
server.use(createArachnidShieldHandler({classification: 'harmful-abusive-material', sha256Hex: 'ham-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.matchDetails[0].violations).toContain('harmful-abusive-material');
});
it('logs warning when CSAM match is detected', async () => {
server.use(createArachnidShieldHandler({classification: 'csam', sha256Hex: 'warning-hash'}));
const provider = makeProvider(logger);
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({trackingId: 'warning-hash'}),
'CSAM match detected',
);
});
it('includes match details from API response', async () => {
server.use(
createArachnidShieldHandler({
classification: 'csam',
matchType: 'near',
sha1Base32: 'abc123base32',
sha256Hex: 'detail-hash',
sizeBytes: 1024,
nearMatchDetails: [
{
classification: 'csam',
sha1_base32: 'match-sha1',
sha256_hex: 'match-sha256',
timestamp: 1609459200,
},
],
}),
);
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.matchResult!.matchDetails).toHaveLength(1);
expect(result.matchResult!.matchDetails[0]).toEqual({
source: 'arachnid',
violations: ['csam'],
matchDistance: 1,
matchId: 'match-sha256',
});
});
it('does not return frames field for images', async () => {
server.use(createArachnidShieldHandler({classification: 'no-known-match', sha256Hex: 'no-frames-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.frames).toBeUndefined();
});
it('does not return frames field for videos', async () => {
server.use(createArachnidShieldHandler({classification: 'no-known-match', sha256Hex: 'video-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'video-key',
contentType: 'video/mp4',
});
expect(result.frames).toBeUndefined();
});
it('retries on 429 status', async () => {
server.use(
createArachnidShieldSequenceHandler([
{status: 429, headers: {ratelimit: '"burst";r=0;t=1'}},
{
status: 200,
body: {
classification: 'no-known-match',
sha256_hex: 'retry-hash',
sha1_base32: 'test-sha1',
match_type: null,
size_bytes: 1024,
},
},
]),
);
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService, {...DEFAULT_CONFIG, retryBackoffMs: 10});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
});
it('retries on 503 status', async () => {
server.use(
createArachnidShieldSequenceHandler([
{status: 503},
{
status: 200,
body: {
classification: 'no-known-match',
sha256_hex: 'retry-503-hash',
sha1_base32: 'test-sha1',
match_type: null,
size_bytes: 1024,
},
},
]),
);
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService, {...DEFAULT_CONFIG, retryBackoffMs: 10});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
});
it('does not retry on 400 status', async () => {
server.use(createArachnidShieldErrorHandler(400, 'Bad Request'));
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService, {...DEFAULT_CONFIG, retryBackoffMs: 10});
await expect(
provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
}),
).rejects.toThrow('Arachnid Shield API returned 400');
});
it('respects maxRetries limit', async () => {
server.use(createArachnidShieldErrorHandler(500));
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService, {...DEFAULT_CONFIG, maxRetries: 2, retryBackoffMs: 10});
await expect(
provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
}),
).rejects.toThrow('Arachnid Shield API returned 500 after 3 attempts');
});
it('logs retry attempts', async () => {
server.use(
createArachnidShieldSequenceHandler([
{status: 503},
{
status: 200,
body: {
classification: 'no-known-match',
sha256_hex: 'log-retry-hash',
sha1_base32: 'test-sha1',
match_type: null,
size_bytes: 1024,
},
},
]),
);
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService, {...DEFAULT_CONFIG, retryBackoffMs: 10});
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({attempt: 0, status: 503}),
'Arachnid Shield API request failed, retrying',
);
});
it('throws error when storage read fails', async () => {
const storageService = new MockStorageService({shouldFail: true});
const provider = makeProvider(logger, storageService);
await expect(
provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
}),
).rejects.toThrow('Mock storage read failure');
});
it('logs error when scan fails', async () => {
const storageService = new MockStorageService({shouldFail: true});
const provider = makeProvider(logger, storageService);
await expect(
provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
}),
).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({bucket: 'test-bucket', key: 'test-key'}),
'Failed to scan media',
);
});
});
describe('scanBase64', () => {
it('returns isMatch: false for unscannable media type', async () => {
const provider = makeProvider(logger);
const result = await provider.scanBase64({base64: 'dGVzdA==', mimeType: 'text/plain'});
expect(result).toEqual({isMatch: false});
});
it('decodes base64 and sends to API', async () => {
const base64Data = TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64');
const requestCapture: {current: ArachnidShieldRequestCapture | null} = {current: null};
server.use(createArachnidShieldHandler({sha256Hex: 'base64-hash'}, requestCapture));
const storageService = new MockStorageService({fileData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const provider = makeProvider(logger, storageService);
await provider.scanBase64({base64: base64Data, mimeType: 'image/png'});
expect(requestCapture.current).not.toBeNull();
const sentData = Buffer.from(requestCapture.current!.body);
expect(sentData.toString('base64')).toBe(base64Data);
});
it('returns match result with provider field when match found', async () => {
server.use(createArachnidShieldHandler({classification: 'csam', sha256Hex: 'base64-match-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanBase64({
base64: TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64'),
mimeType: 'image/png',
});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.provider).toBe('arachnid_shield');
});
it('does not return frames field', async () => {
server.use(createArachnidShieldHandler({classification: 'no-known-match', sha256Hex: 'no-frames-base64'}));
const provider = makeProvider(logger);
const result = await provider.scanBase64({
base64: TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64'),
mimeType: 'image/png',
});
expect(result.frames).toBeUndefined();
});
});
describe('context handling', () => {
it('uses context for telemetry', async () => {
server.use(createArachnidShieldHandler({classification: 'no-known-match', sha256Hex: 'context-hash'}));
const provider = makeProvider(logger);
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
context: {
resourceType: 'attachment',
userId: '123',
guildId: '456',
channelId: '789',
messageId: '012',
},
});
expect(result.isMatch).toBe(false);
});
});
});

View File

@@ -0,0 +1,458 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {PhotoDnaProvider} from '@fluxer/api/src/csam/providers/PhotoDnaProvider';
import {
createMockFrameSamples,
createMockMatchResult,
createNoopLogger,
} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import * as CsamTelemetry from '@fluxer/api/src/telemetry/CsamTelemetry';
import {MockCsamScanQueueService} from '@fluxer/api/src/test/mocks/MockCsamScanQueueService';
import {MockMediaService} from '@fluxer/api/src/test/mocks/MockMediaService';
import {MockPhotoDnaHashClient} from '@fluxer/api/src/test/mocks/MockPhotoDnaHashClient';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('PhotoDnaProvider', () => {
let logger: ILogger;
beforeEach(() => {
logger = createNoopLogger();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe('providerName', () => {
it('returns photo_dna as provider name', () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
expect(provider.providerName).toBe('photo_dna');
});
});
describe('scanMedia', () => {
describe('unscannable media types', () => {
it('returns isMatch: false for null content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: null,
});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key', contentType: null},
'Skipping non-scannable media type',
);
});
it('returns isMatch: false for text/plain content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'text/plain',
});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false for audio/mp3 content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'audio/mp3',
});
expect(result).toEqual({isMatch: false});
});
});
describe('scannable media types', () => {
it.each([
['image/jpeg'],
['image/png'],
['image/gif'],
['image/webp'],
['video/mp4'],
['video/webm'],
])('scans %s content type', async (contentType) => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await provider.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType});
if (contentType.startsWith('video/')) {
expect(mediaService.extractFramesSpy).toHaveBeenCalled();
} else {
expect(mediaService.getMetadataSpy).toHaveBeenCalled();
}
});
});
describe('no match scenario', () => {
it('returns isMatch: false when PhotoDNA finds no match', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1', 'hash-2']});
const queueService = new MockCsamScanQueueService({
matchResult: createMockMatchResult({isMatch: false}),
});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(false);
expect(result.frames).toBeDefined();
expect(result.hashes).toEqual(['hash-1', 'hash-2']);
});
it('returns isMatch: false when no frames are extracted', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({returnNullMetadata: true});
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false when no hashes are generated', async () => {
const hashClient = new MockPhotoDnaHashClient({returnEmpty: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(false);
expect(result.hashes).toEqual([]);
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('match detected scenario', () => {
it('returns isMatch: true with matchResult when match found', async () => {
const hashes = ['hash-1', 'hash-2'];
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(true);
expect(result.matchResult!.provider).toBe('photo_dna');
expect(result.hashes).toEqual(hashes);
});
it('logs warning when CSAM match is detected', async () => {
const matchResult = createMockMatchResult({isMatch: true, trackingId: 'test-tracking-id'});
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await provider.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(logger.warn).toHaveBeenCalledWith(
{trackingId: 'test-tracking-id', matchCount: 1},
'CSAM match detected',
);
});
it('records telemetry when match details are missing', async () => {
const recordCsamMatchSpy = vi.spyOn(CsamTelemetry, 'recordCsamMatch');
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
vi.spyOn(queueService, 'submitScan').mockResolvedValue({isMatch: true});
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
context: {
resourceType: 'attachment',
userId: null,
guildId: null,
channelId: null,
messageId: null,
},
});
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeUndefined();
expect(recordCsamMatchSpy).toHaveBeenCalledWith({
resourceType: 'attachment',
source: 'synchronous',
matchCount: 0,
});
recordCsamMatchSpy.mockRestore();
expect(logger.warn).toHaveBeenCalledWith({trackingId: undefined, matchCount: 0}, 'CSAM match detected');
});
});
describe('error handling', () => {
it('throws error when frame extraction fails', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({shouldFailFrameExtraction: true});
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await expect(
provider.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'}),
).rejects.toThrow('Mock frame extraction failure');
});
it('throws error when hash client fails', async () => {
const hashClient = new MockPhotoDnaHashClient({shouldFail: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await expect(
provider.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock hash client failure');
});
it('throws error when queue service fails', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({shouldFail: true});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await expect(
provider.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock queue service failure');
});
});
});
describe('scanBase64', () => {
it('returns isMatch: false for unscannable media type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanBase64({base64: 'dGVzdA==', mimeType: 'text/plain'});
expect(result).toEqual({isMatch: false});
});
it('scans base64 image data successfully', async () => {
const base64Data = Buffer.from('test-image-data').toString('base64');
const hashes = ['base64-hash-1'];
const matchResult = createMockMatchResult({isMatch: false});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanBase64({base64: base64Data, mimeType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.frames).toHaveLength(1);
expect(result.frames![0]).toEqual({
timestamp: 0,
mimeType: 'image/png',
base64: base64Data,
});
expect(result.hashes).toEqual(hashes);
});
it('returns match result with provider field when match found', async () => {
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes: ['match-hash']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanBase64({
base64: Buffer.from('suspicious-image').toString('base64'),
mimeType: 'image/jpeg',
});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.provider).toBe('photo_dna');
});
it('does not call media service for base64 scanning', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await provider.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'});
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
expect(mediaService.extractFramesSpy).not.toHaveBeenCalled();
});
});
describe('context handling', () => {
it('passes context to queue service', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
context: {
resourceType: 'attachment',
userId: '123',
guildId: '456',
channelId: '789',
messageId: '012',
},
});
expect(queueService.submitScanSpy).toHaveBeenCalledWith(
expect.objectContaining({
context: {
resourceType: 'attachment',
userId: '123',
guildId: '456',
channelId: '789',
messageId: '012',
},
}),
);
});
it('uses default context when not provided', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
await provider.scanMedia({
bucket: 'test-bucket',
key: 'test-key',
contentType: 'image/png',
});
expect(queueService.submitScanSpy).toHaveBeenCalledWith(
expect.objectContaining({
context: {
resourceType: 'other',
userId: null,
guildId: null,
channelId: null,
messageId: null,
},
}),
);
});
});
describe('integration with test utilities', () => {
it('works with createMockFrameSamples utility', async () => {
const mockFrames = createMockFrameSamples(3);
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({
frames: mockFrames.map((f) => ({
timestamp: f.timestamp,
mime_type: f.mimeType,
base64: f.base64,
})),
});
const provider = new PhotoDnaProvider(hashClient, mediaService, queueService, {logger});
const result = await provider.scanMedia({
bucket: 'test-bucket',
key: 'video-key',
contentType: 'video/mp4',
});
expect(result.frames).toHaveLength(3);
});
});
});

View File

@@ -0,0 +1,967 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import type {AttachmentToProcess} from '@fluxer/api/src/channel/AttachmentDTOs';
import {AttachmentProcessingService} from '@fluxer/api/src/channel/services/message/AttachmentProcessingService';
import {createDefaultLimitConfig} from '@fluxer/api/src/constants/LimitConfig';
import type {PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {ScanMediaParams} from '@fluxer/api/src/csam/SynchronousCsamScanner';
import {createMockGuildResponse, TEST_FIXTURES} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
import {EntityAssetService} from '@fluxer/api/src/infrastructure/EntityAssetService';
import type {Message} from '@fluxer/api/src/models/Message';
import {MockAssetDeletionQueue} from '@fluxer/api/src/test/mocks/MockAssetDeletionQueue';
import {MockCsamReportSnapshotService} from '@fluxer/api/src/test/mocks/MockCsamReportSnapshotService';
import {MockMediaService} from '@fluxer/api/src/test/mocks/MockMediaService';
import {MockSnowflakeService} from '@fluxer/api/src/test/mocks/MockSnowflakeService';
import {MockStorageService} from '@fluxer/api/src/test/mocks/MockStorageService';
import {MockSynchronousCsamScanner} from '@fluxer/api/src/test/mocks/MockSynchronousCsamScanner';
import {MockVirusScanService} from '@fluxer/api/src/test/mocks/MockVirusScanService';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
function createMockMatchResult(options?: {trackingId?: string}): PhotoDnaMatchResult {
return {
isMatch: true,
trackingId: options?.trackingId ?? randomUUID(),
matchDetails: [
{
source: 'test-database',
violations: ['CSAM'],
matchDistance: 0.01,
matchId: randomUUID(),
},
],
timestamp: new Date().toISOString(),
};
}
function createMockMessage(options?: {authorId?: bigint; channelId?: bigint; id?: bigint}): Message {
return {
id: options?.id ?? 1n,
authorId: options?.authorId ?? 123n,
channelId: options?.channelId ?? 456n,
} as Message;
}
const TEST_LIMIT_CONFIG_SERVICE = {
getConfigSnapshot() {
return createDefaultLimitConfig({selfHosted: false});
},
};
describe('CsamBlockingBehavior', () => {
let storageService: MockStorageService;
let mediaService: MockMediaService;
let virusScanService: MockVirusScanService;
let snowflakeService: MockSnowflakeService;
let csamScanner: MockSynchronousCsamScanner;
let csamReportService: MockCsamReportSnapshotService;
let assetDeletionQueue: MockAssetDeletionQueue;
beforeEach(() => {
storageService = new MockStorageService();
mediaService = new MockMediaService();
virusScanService = new MockVirusScanService();
snowflakeService = new MockSnowflakeService();
csamScanner = new MockSynchronousCsamScanner();
csamReportService = new MockCsamReportSnapshotService();
assetDeletionQueue = new MockAssetDeletionQueue();
});
afterEach(() => {
storageService.reset();
csamScanner.reset();
csamReportService.reset();
assetDeletionQueue.reset();
vi.clearAllMocks();
});
describe('AttachmentProcessingService', () => {
let attachmentService: AttachmentProcessingService;
beforeEach(async () => {
await snowflakeService.initialize();
attachmentService = new AttachmentProcessingService(
storageService,
mediaService,
virusScanService,
snowflakeService,
csamScanner,
csamReportService,
);
});
describe('CSAM attachment blocking', () => {
it('rejects CSAM attachment with ContentBlockedError', async () => {
const uploadKey = `${randomUUID()}/test.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
csamScanner.configure({shouldMatch: true});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'test.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage({authorId: 123n, channelId: 456n, id: 789n});
await expect(
attachmentService.computeAttachments({
message,
attachments: [attachment],
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean attachment through', async () => {
const uploadKey = `${randomUUID()}/clean.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
csamScanner.configure({shouldMatch: false});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'clean.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage();
const result = await attachmentService.computeAttachments({
message,
attachments: [attachment],
isNSFWAllowed: false,
});
expect(result.attachments).toHaveLength(1);
expect(result.hasVirusDetected).toBe(false);
});
it('creates report snapshot when CSAM detected', async () => {
const uploadKey = `${randomUUID()}/csam.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
const matchResult = createMockMatchResult({trackingId: 'test-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'csam.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage({authorId: 123n, channelId: 456n, id: 789n});
await expect(
attachmentService.computeAttachments({
message,
attachments: [attachment],
guild: createMockGuildResponse({id: '999'}),
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('attachment');
expect(snapshots[0]!.params.userId).toBe('123');
expect(snapshots[0]!.params.guildId).toBe('999');
expect(snapshots[0]!.params.channelId).toBe('456');
expect(snapshots[0]!.params.messageId).toBe('789');
expect(snapshots[0]!.params.scanResult.trackingId).toBe('test-tracking-id');
});
it('rejects CSAM attachment even when match details are missing', async () => {
const uploadKey = `${randomUUID()}/csam-no-details.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
csamScanner.configure({shouldMatch: true, omitMatchResult: true});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'csam-no-details.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage({authorId: 123n, channelId: 456n, id: 789n});
await expect(
attachmentService.computeAttachments({
message,
attachments: [attachment],
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
expect(csamReportService.getSnapshots()).toHaveLength(0);
});
it('does NOT copy content to CDN when CSAM detected', async () => {
const uploadKey = `${randomUUID()}/csam.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
csamScanner.configure({shouldMatch: true});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'csam.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage();
await expect(
attachmentService.computeAttachments({
message,
attachments: [attachment],
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
const copiedObjects = storageService.getCopiedObjects();
expect(copiedObjects).toHaveLength(0);
});
it('deletes all attachments from uploads bucket on CSAM detection', async () => {
const uploadKey1 = `${randomUUID()}/attachment1.png`;
const uploadKey2 = `${randomUUID()}/attachment2.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey1,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
await storageService.uploadObject({
bucket: '',
key: uploadKey2,
body: TEST_FIXTURES.JPEG_1X1_RED,
contentType: 'image/jpeg',
});
csamScanner.configure({shouldMatch: true});
const attachments: Array<AttachmentToProcess> = [
{
id: 1,
filename: 'attachment1.png',
upload_filename: uploadKey1,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
},
{
id: 2,
filename: 'attachment2.png',
upload_filename: uploadKey2,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/jpeg',
},
];
const message = createMockMessage();
await expect(
attachmentService.computeAttachments({
message,
attachments,
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
const deletedObjects = storageService.getDeletedObjects();
expect(deletedObjects).toHaveLength(2);
expect(deletedObjects.some((d) => d.key === uploadKey1)).toBe(true);
expect(deletedObjects.some((d) => d.key === uploadKey2)).toBe(true);
});
});
});
describe('AvatarService', () => {
let avatarService: AvatarService;
beforeEach(() => {
avatarService = new AvatarService(
storageService,
mediaService,
TEST_LIMIT_CONFIG_SERVICE,
csamScanner,
csamReportService,
);
});
describe('CSAM avatar blocking', () => {
it('rejects CSAM avatar with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
avatarService.uploadAvatar({
prefix: 'avatars',
entityId: 123n,
errorPath: 'avatar',
base64Image,
csamContext: {userId: '123'},
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean avatar upload', async () => {
csamScanner.configure({shouldMatch: false});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
const result = await avatarService.uploadAvatar({
prefix: 'avatars',
entityId: 123n,
errorPath: 'avatar',
base64Image,
});
expect(result).not.toBeNull();
expect(typeof result).toBe('string');
});
it('creates report snapshot when CSAM avatar detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'avatar-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
avatarService.uploadAvatar({
prefix: 'avatars',
entityId: 123n,
errorPath: 'avatar',
base64Image,
csamContext: {userId: '456', guildId: '789'},
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('avatar');
expect(snapshots[0]!.params.userId).toBe('456');
expect(snapshots[0]!.params.guildId).toBe('789');
expect(snapshots[0]!.params.scanResult.trackingId).toBe('avatar-tracking-id');
});
});
describe('CSAM emoji blocking', () => {
it('rejects CSAM emoji with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await expect(
avatarService.uploadEmoji({
prefix: 'emojis',
emojiId: 123n,
imageBuffer,
contentType: 'image/png',
csamContext: {guildId: '456'},
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean emoji upload', async () => {
csamScanner.configure({shouldMatch: false});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await avatarService.uploadEmoji({
prefix: 'emojis',
emojiId: 123n,
imageBuffer,
contentType: 'image/png',
});
expect(storageService.hasObject('cdn', 'emojis/123')).toBe(true);
});
it('creates report snapshot when CSAM emoji detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'emoji-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await expect(
avatarService.uploadEmoji({
prefix: 'emojis',
emojiId: 123n,
imageBuffer,
contentType: 'image/png',
csamContext: {guildId: '456', userId: '789'},
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('emoji');
expect(snapshots[0]!.params.guildId).toBe('456');
});
});
describe('CSAM sticker blocking', () => {
it('rejects CSAM sticker with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await expect(
avatarService.uploadSticker({
prefix: 'stickers',
stickerId: 123n,
imageBuffer,
contentType: 'image/png',
csamContext: {guildId: '456'},
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean sticker upload', async () => {
csamScanner.configure({shouldMatch: false});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await avatarService.uploadSticker({
prefix: 'stickers',
stickerId: 123n,
imageBuffer,
contentType: 'image/png',
});
expect(storageService.hasObject('cdn', 'stickers/123')).toBe(true);
});
it('creates report snapshot when CSAM sticker detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'sticker-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const imageBuffer = new Uint8Array(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
await expect(
avatarService.uploadSticker({
prefix: 'stickers',
stickerId: 123n,
imageBuffer,
contentType: 'image/png',
csamContext: {guildId: '456'},
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('sticker');
});
});
});
describe('EntityAssetService', () => {
let entityAssetService: EntityAssetService;
beforeEach(() => {
entityAssetService = new EntityAssetService(
storageService,
mediaService,
assetDeletionQueue,
TEST_LIMIT_CONFIG_SERVICE,
csamScanner,
csamReportService,
);
});
afterEach(() => {
entityAssetService.cleanup();
});
describe('CSAM banner blocking', () => {
it('rejects CSAM banner with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: 123n,
previousHash: null,
base64Image,
errorPath: 'banner',
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean banner upload', async () => {
csamScanner.configure({shouldMatch: false});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
const result = await entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: 123n,
previousHash: null,
base64Image,
errorPath: 'banner',
});
expect(result.newHash).not.toBeNull();
expect(result._uploaded).toBe(true);
});
it('creates report snapshot when CSAM banner detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'banner-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'banner',
entityType: 'user',
entityId: 123n,
previousHash: null,
base64Image,
errorPath: 'banner',
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('banner');
expect(snapshots[0]!.params.userId).toBe('123');
expect(snapshots[0]!.params.scanResult.trackingId).toBe('banner-tracking-id');
});
});
describe('CSAM guild icon blocking', () => {
it('rejects CSAM guild icon with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'icon',
entityType: 'guild',
entityId: 456n,
previousHash: null,
base64Image,
errorPath: 'icon',
}),
).rejects.toThrow(ContentBlockedError);
});
it('allows clean guild icon upload', async () => {
csamScanner.configure({shouldMatch: false});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
const result = await entityAssetService.prepareAssetUpload({
assetType: 'icon',
entityType: 'guild',
entityId: 456n,
previousHash: null,
base64Image,
errorPath: 'icon',
});
expect(result.newHash).not.toBeNull();
expect(result._uploaded).toBe(true);
});
it('creates report snapshot when CSAM guild icon detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'icon-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'icon',
entityType: 'guild',
entityId: 456n,
previousHash: null,
base64Image,
errorPath: 'icon',
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('avatar');
expect(snapshots[0]!.params.guildId).toBe('456');
});
});
describe('CSAM splash blocking', () => {
it('rejects CSAM splash with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'splash',
entityType: 'guild',
entityId: 789n,
previousHash: null,
base64Image,
errorPath: 'splash',
}),
).rejects.toThrow(ContentBlockedError);
});
it('creates report snapshot when CSAM splash detected', async () => {
const matchResult = createMockMatchResult({trackingId: 'splash-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'splash',
entityType: 'guild',
entityId: 789n,
previousHash: null,
base64Image,
errorPath: 'splash',
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('banner');
});
});
describe('guild member avatar blocking', () => {
it('rejects CSAM guild member avatar with ContentBlockedError', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'guild_member',
entityId: 123n,
guildId: 456n,
previousHash: null,
base64Image,
errorPath: 'avatar',
}),
).rejects.toThrow(ContentBlockedError);
});
it('creates report snapshot with correct user and guild IDs for guild member avatar', async () => {
const matchResult = createMockMatchResult({trackingId: 'member-avatar-tracking-id'});
csamScanner.configure({shouldMatch: true, matchResult});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'guild_member',
entityId: 123n,
guildId: 456n,
previousHash: null,
base64Image,
errorPath: 'avatar',
}),
).rejects.toThrow(ContentBlockedError);
const snapshots = csamReportService.getSnapshots();
expect(snapshots).toHaveLength(1);
expect(snapshots[0]!.params.resourceType).toBe('avatar');
expect(snapshots[0]!.params.userId).toBe('123');
expect(snapshots[0]!.params.guildId).toBe('456');
});
});
describe('does not upload when CSAM detected', () => {
it('does not upload asset to S3 when CSAM detected', async () => {
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
await expect(
entityAssetService.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: 123n,
previousHash: null,
base64Image,
errorPath: 'avatar',
}),
).rejects.toThrow(ContentBlockedError);
expect(storageService.hasObject('cdn', 'avatars/123')).toBe(false);
});
});
});
describe('Error propagation', () => {
it('ContentBlockedError has correct error code', () => {
const error = new ContentBlockedError();
expect(error.code).toBe(APIErrorCodes.CONTENT_BLOCKED);
});
it('ContentBlockedError is instance of ForbiddenError', async () => {
const avatarService = new AvatarService(
storageService,
mediaService,
TEST_LIMIT_CONFIG_SERVICE,
csamScanner,
csamReportService,
);
csamScanner.configure({shouldMatch: true});
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
try {
await avatarService.uploadAvatar({
prefix: 'avatars',
entityId: 123n,
errorPath: 'avatar',
base64Image,
});
expect.fail('Expected ContentBlockedError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ContentBlockedError);
expect((error as ContentBlockedError).code).toBe(APIErrorCodes.CONTENT_BLOCKED);
}
});
it('throws ContentBlockedError even when multiple attachments and only one is CSAM', async () => {
await snowflakeService.initialize();
const attachmentService = new AttachmentProcessingService(
storageService,
mediaService,
virusScanService,
snowflakeService,
csamScanner,
csamReportService,
);
const uploadKey1 = `${randomUUID()}/clean.png`;
const uploadKey2 = `${randomUUID()}/csam.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey1,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
await storageService.uploadObject({
bucket: '',
key: uploadKey2,
body: TEST_FIXTURES.JPEG_1X1_RED,
contentType: 'image/jpeg',
});
let scanCallCount = 0;
const originalScanMedia = csamScanner.scanMedia.bind(csamScanner);
csamScanner.scanMedia = async (params: ScanMediaParams) => {
scanCallCount++;
if (scanCallCount === 2) {
csamScanner.configure({shouldMatch: true});
}
return originalScanMedia(params);
};
const attachments: Array<AttachmentToProcess> = [
{
id: 1,
filename: 'clean.png',
upload_filename: uploadKey1,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
},
{
id: 2,
filename: 'csam.png',
upload_filename: uploadKey2,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/jpeg',
},
];
const message = createMockMessage();
await expect(
attachmentService.computeAttachments({
message,
attachments,
isNSFWAllowed: false,
}),
).rejects.toThrow(ContentBlockedError);
});
});
describe('Scanner disabled behavior', () => {
it('allows all uploads when CSAM scanner is not provided to AttachmentProcessingService', async () => {
await snowflakeService.initialize();
const attachmentServiceNoScanner = new AttachmentProcessingService(
storageService,
mediaService,
virusScanService,
snowflakeService,
);
const uploadKey = `${randomUUID()}/test.png`;
await storageService.uploadObject({
bucket: '',
key: uploadKey,
body: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
contentType: 'image/png',
});
const attachment: AttachmentToProcess = {
id: 1,
filename: 'test.png',
upload_filename: uploadKey,
title: null,
description: null,
flags: 0,
file_size: 0,
content_type: 'image/png',
};
const message = createMockMessage();
const result = await attachmentServiceNoScanner.computeAttachments({
message,
attachments: [attachment],
isNSFWAllowed: false,
});
expect(result.attachments).toHaveLength(1);
});
it('allows all uploads when CSAM scanner is not provided to AvatarService', async () => {
const avatarServiceNoScanner = new AvatarService(storageService, mediaService, TEST_LIMIT_CONFIG_SERVICE);
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
const result = await avatarServiceNoScanner.uploadAvatar({
prefix: 'avatars',
entityId: 123n,
errorPath: 'avatar',
base64Image,
});
expect(result).not.toBeNull();
});
it('allows all uploads when CSAM scanner is not provided to EntityAssetService', async () => {
const entityAssetServiceNoScanner = new EntityAssetService(
storageService,
mediaService,
assetDeletionQueue,
TEST_LIMIT_CONFIG_SERVICE,
);
const base64Image = `data:image/png;base64,${TEST_FIXTURES.PNG_1X1_TRANSPARENT.toString('base64')}`;
const result = await entityAssetServiceNoScanner.prepareAssetUpload({
assetType: 'avatar',
entityType: 'user',
entityId: 123n,
previousHash: null,
base64Image,
errorPath: 'avatar',
});
expect(result.newHash).not.toBeNull();
entityAssetServiceNoScanner.cleanup();
});
});
});

View File

@@ -0,0 +1,113 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {CsamEvidenceRetentionService} from '@fluxer/api/src/csam/CsamEvidenceRetentionService';
import {fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import {clearSqliteStore} from '@fluxer/api/src/database/SqliteKV';
import type {CsamEvidenceExpirationRow, CsamEvidencePackageRow} from '@fluxer/api/src/database/types/CsamTypes';
import {CsamEvidenceExpirations, CsamEvidencePackages} from '@fluxer/api/src/Tables';
import {MockStorageService} from '@fluxer/api/src/test/mocks/MockStorageService';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
function buildEvidencePackageRow(reportId: bigint, expiresAt: Date): CsamEvidencePackageRow {
return {
report_id: reportId,
resource_type: 'attachment',
bucket: 'test-source-bucket',
key: `csam/source/${reportId.toString()}/asset.png`,
cdn_url: null,
filename: 'asset.png',
content_type: 'image/png',
channel_id: null,
message_id: null,
guild_id: null,
user_id: null,
match_tracking_id: 'test-tracking-id',
match_details: JSON.stringify([{source: 'test', matchId: 'match'}]),
frames: JSON.stringify([{index: 0, timestamp: 0}]),
hashes: JSON.stringify([{hash: 'deadbeef'}]),
context_snapshot: JSON.stringify({context: 'snapshot'}),
created_at: new Date(expiresAt.getTime() - 1000),
expires_at: expiresAt,
integrity_sha256: 'deadbeef',
evidence_zip_key: `csam/evidence/${reportId.toString()}/evidence.zip`,
};
}
function buildExpirationRow(reportId: bigint, expiresAt: Date): CsamEvidenceExpirationRow {
return {
bucket: Config.s3.buckets.reports,
expires_at: expiresAt,
report_id: reportId,
};
}
describe('CsamEvidenceRetentionService', () => {
let storageService: MockStorageService;
let retentionService: CsamEvidenceRetentionService;
beforeEach(() => {
clearSqliteStore();
storageService = new MockStorageService();
retentionService = new CsamEvidenceRetentionService(storageService);
});
afterEach(() => {
storageService.reset();
clearSqliteStore();
});
it('processes all expired evidence across multiple batches', async () => {
const totalRows = Config.csam.cleanupBatchSize + 1;
const baseTime = Date.now();
for (let index = 0; index < totalRows; index += 1) {
const reportId = BigInt(1000 + index);
const expiresAt = new Date(baseTime - (index + 1) * 1000);
const packageRow = buildEvidencePackageRow(reportId, expiresAt);
const expirationRow = buildExpirationRow(reportId, expiresAt);
await upsertOne(CsamEvidencePackages.insert(packageRow));
await upsertOne(CsamEvidenceExpirations.insert(expirationRow));
}
await retentionService.cleanupExpired();
const remainingExpirations = await fetchMany<CsamEvidenceExpirationRow>(
CsamEvidenceExpirations.select({
where: CsamEvidenceExpirations.where.eq('bucket', 'bucket'),
}).bind({bucket: Config.s3.buckets.reports}),
);
expect(remainingExpirations).toHaveLength(0);
const sampleReportId = 1000n;
const updatedPackage = await fetchOne<CsamEvidencePackageRow>(
CsamEvidencePackages.select({
where: [CsamEvidencePackages.where.eq('report_id', 'report_id')],
}).bind({report_id: sampleReportId}),
);
expect(updatedPackage).not.toBeNull();
expect(updatedPackage!.evidence_zip_key).toBeNull();
expect(updatedPackage!.expires_at).toBeNull();
expect(updatedPackage!.hashes).toBeNull();
});
});

View File

@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {CsamLegalHoldService} from '@fluxer/api/src/csam/CsamLegalHoldService';
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {CsamEvidenceLegalHoldRow} from '@fluxer/api/src/database/types/CsamTypes';
import {CsamEvidenceLegalHolds} from '@fluxer/api/src/Tables';
import {describe, expect, it} from 'vitest';
describe('CsamLegalHoldService expiry behaviour', () => {
it('does not delete expired holds when checking', async () => {
const service = new CsamLegalHoldService();
const reportId = 901n;
const heldUntil = new Date(Date.now() - 60_000);
const row: CsamEvidenceLegalHoldRow = {
report_id: reportId,
held_until: heldUntil,
created_at: new Date(heldUntil.getTime() - 1000),
};
try {
await upsertOne(CsamEvidenceLegalHolds.insert(row));
const isHeld = await service.isHeld(reportId.toString());
expect(isHeld).toBe(false);
const stored = await fetchOne<CsamEvidenceLegalHoldRow>(
CsamEvidenceLegalHolds.select({
where: [CsamEvidenceLegalHolds.where.eq('report_id', 'report_id')],
}).bind({report_id: reportId}),
);
expect(stored).not.toBeNull();
} finally {
await deleteOneOrMany(CsamEvidenceLegalHolds.deleteByPk({report_id: reportId}));
}
});
});

View File

@@ -0,0 +1,391 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createTestAccount, setUserACLs, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ReportResponse {
report_id: string;
status: string;
reported_at: string;
}
interface LegalHoldResponse {
held: boolean;
}
async function createUserReport(harness: ApiTestHarness, reporter: TestAccount, targetUserId: string): Promise<string> {
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUserId,
category: 'harassment',
additional_info: 'Test report for legal hold testing',
})
.expect(HTTP_STATUS.OK)
.execute();
return result.report_id;
}
async function createAdminUser(harness: ApiTestHarness, acls: Array<string>): Promise<TestAccount> {
const account = await createTestAccount(harness);
return setUserACLs(harness, account, acls);
}
describe('CSAM Legal Hold Service', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('GET /admin/reports/:report_id/legal-hold', () => {
test('returns false when no legal hold exists', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const result = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(result.held).toBe(false);
});
test('requires report:view ACL', async () => {
const admin = await createAdminUser(harness, ['admin:authenticate']);
const targetUser = await createTestAccount(harness);
const reporter = await createTestAccount(harness);
const reportId = await createUserReport(harness, reporter, targetUser.userId);
await createBuilder(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
});
test('requires authentication', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, reporter, targetUser.userId);
await createBuilder(harness, '')
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
});
describe('POST /admin/reports/:report_id/legal-hold', () => {
test('creates a legal hold without expiration', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const createResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
expect(createResult.held).toBe(true);
const checkResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(checkResult.held).toBe(true);
});
test('creates a legal hold with expiration date', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const createResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({expires_at: expiresAt})
.expect(HTTP_STATUS.OK)
.execute();
expect(createResult.held).toBe(true);
const checkResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(checkResult.held).toBe(true);
});
test('requires report:resolve ACL', async () => {
const admin = await createAdminUser(harness, ['admin:authenticate', 'report:view']);
const targetUser = await createTestAccount(harness);
const reporter = await createTestAccount(harness);
const reportId = await createUserReport(harness, reporter, targetUser.userId);
await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
});
test('requires authentication', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, reporter, targetUser.userId);
await createBuilder(harness, '')
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
test('can extend hold duration by re-posting', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const originalExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const extendedExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({expires_at: originalExpiry})
.expect(HTTP_STATUS.OK)
.execute();
const extendResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({expires_at: extendedExpiry})
.expect(HTTP_STATUS.OK)
.execute();
expect(extendResult.held).toBe(true);
const checkResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(checkResult.held).toBe(true);
});
});
describe('DELETE /admin/reports/:report_id/legal-hold', () => {
test('releases an existing legal hold', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
const holdCheck = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(holdCheck.held).toBe(true);
const releaseResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(releaseResult.held).toBe(false);
const afterRelease = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(afterRelease.held).toBe(false);
});
test('requires report:resolve ACL', async () => {
const adminWithResolve = await createAdminUser(harness, ['*']);
const adminWithoutResolve = await createAdminUser(harness, ['admin:authenticate', 'report:view']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, adminWithResolve, targetUser.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${adminWithResolve.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, `Bearer ${adminWithoutResolve.token}`)
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
});
test('requires authentication', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, '')
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.UNAUTHORIZED, 'UNAUTHORIZED')
.execute();
});
test('succeeds even when no hold exists', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const releaseResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(releaseResult.held).toBe(false);
});
});
describe('Legal hold prevents evidence deletion', () => {
test('active hold prevents automatic deletion', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
const checkResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(checkResult.held).toBe(true);
});
test('releasing hold allows evidence deletion', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
const checkResult = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(checkResult.held).toBe(false);
});
});
describe('Hold lifecycle', () => {
test('full hold lifecycle: create, verify, release, verify', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser = await createTestAccount(harness);
const reportId = await createUserReport(harness, admin, targetUser.userId);
const initialCheck = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(initialCheck.held).toBe(false);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
const afterHold = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(afterHold.held).toBe(true);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.delete(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
const afterRelease = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(afterRelease.held).toBe(false);
});
test('multiple reports can have independent holds', async () => {
const admin = await createAdminUser(harness, ['*']);
const targetUser1 = await createTestAccount(harness);
const targetUser2 = await createTestAccount(harness);
const reportId1 = await createUserReport(harness, admin, targetUser1.userId);
const reportId2 = await createUserReport(harness, admin, targetUser2.userId);
await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId1}/legal-hold`)
.body({})
.expect(HTTP_STATUS.OK)
.execute();
const check1 = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId1}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(check1.held).toBe(true);
const check2 = await createBuilder<LegalHoldResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId2}/legal-hold`)
.expect(HTTP_STATUS.OK)
.execute();
expect(check2.held).toBe(false);
});
});
});

View File

@@ -0,0 +1,400 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import crypto from 'node:crypto';
import {createReportID} from '@fluxer/api/src/BrandedTypes';
import {
type CreateSnapshotParams,
CsamReportSnapshotService,
type CsamReportSnapshotServiceDeps,
} from '@fluxer/api/src/csam/CsamReportSnapshotService';
import type {CsamResourceType} from '@fluxer/api/src/csam/CsamTypes';
import {createMockMatchResult, createNoopLogger, TEST_FIXTURES} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import {clearSqliteStore} from '@fluxer/api/src/database/SqliteKV';
import {ReportStatus, ReportType} from '@fluxer/api/src/report/IReportRepository';
import {ReportRepository} from '@fluxer/api/src/report/ReportRepository';
import {MockCsamEvidenceService} from '@fluxer/api/src/test/mocks/MockCsamEvidenceService';
import {MockSnowflakeService} from '@fluxer/api/src/test/mocks/MockSnowflakeService';
import {MockStorageService} from '@fluxer/api/src/test/mocks/MockStorageService';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
function createSnapshotParams(overrides?: Partial<CreateSnapshotParams>): CreateSnapshotParams {
return {
scanResult: createMockMatchResult({isMatch: true}),
resourceType: 'attachment',
userId: '123456789012345678',
guildId: '234567890123456789',
channelId: '345678901234567890',
messageId: '456789012345678901',
mediaData: TEST_FIXTURES.PNG_1X1_TRANSPARENT,
filename: 'test-image.png',
contentType: 'image/png',
...overrides,
};
}
describe('CsamReportSnapshotService', () => {
let mockStorageService: MockStorageService;
let reportRepository: ReportRepository;
let mockSnowflakeService: MockSnowflakeService;
let mockCsamEvidenceService: MockCsamEvidenceService;
let snapshotService: CsamReportSnapshotService;
beforeEach(async () => {
clearSqliteStore();
mockStorageService = new MockStorageService();
reportRepository = new ReportRepository();
mockSnowflakeService = new MockSnowflakeService({initialCounter: 1000000000000000000n});
await mockSnowflakeService.initialize();
mockCsamEvidenceService = new MockCsamEvidenceService();
const deps: CsamReportSnapshotServiceDeps = {
storageService: mockStorageService,
reportRepository,
snowflakeService: mockSnowflakeService,
csamEvidenceService: mockCsamEvidenceService,
logger: createNoopLogger(),
};
snapshotService = new CsamReportSnapshotService(deps);
});
afterEach(() => {
mockStorageService.reset();
mockSnowflakeService.reset();
mockCsamEvidenceService.reset();
clearSqliteStore();
});
describe('createSnapshot', () => {
it('creates report with correct metadata', async () => {
const scanResult = createMockMatchResult({
isMatch: true,
trackingId: 'test-tracking-id-123',
});
const params = createSnapshotParams({scanResult});
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.status).toBe(ReportStatus.PENDING);
expect(report!.reportType).toBe(ReportType.MESSAGE);
expect(report!.category).toBe(CATEGORY_CHILD_SAFETY);
expect(report!.reporterId).toBeNull();
expect(report!.auditLogReason).toBe('CSAM match test-tracking-id-123');
const additionalInfo = JSON.parse(report!.additionalInfo!);
expect(additionalInfo.trackingId).toBe('test-tracking-id-123');
expect(additionalInfo.resourceType).toBe('attachment');
expect(additionalInfo.matchDetails).toBeDefined();
expect(additionalInfo.hashes).toBeDefined();
expect(additionalInfo.integrity).toBeDefined();
expect(additionalInfo.evidenceZip).toBeDefined();
});
it('stores evidence to reports bucket', async () => {
const params = createSnapshotParams();
await snapshotService.createSnapshot(params);
expect(mockStorageService.uploadObjectSpy).toHaveBeenCalledOnce();
const uploadCall = mockStorageService.uploadObjectSpy.mock.calls[0]![0]!;
expect(uploadCall.bucket).toContain('reports');
expect(uploadCall.key).toContain('csam/evidence/');
expect(uploadCall.key).toContain('/asset/');
expect(uploadCall.body).toEqual(TEST_FIXTURES.PNG_1X1_TRANSPARENT);
expect(uploadCall.contentType).toBe('image/png');
});
it('generates unique report IDs using snowflake service', async () => {
const params1 = createSnapshotParams();
const params2 = createSnapshotParams();
const reportId1 = await snapshotService.createSnapshot(params1);
const reportId2 = await snapshotService.createSnapshot(params2);
expect(mockSnowflakeService.generateSpy).toHaveBeenCalledTimes(2);
expect(reportId1).not.toBe(reportId2);
const generatedIds = mockSnowflakeService.getGeneratedIds();
expect(generatedIds).toHaveLength(2);
expect(generatedIds[0]).toBe(reportId1);
expect(generatedIds[1]).toBe(reportId2);
});
it('preserves user context in report', async () => {
const params = createSnapshotParams({
userId: '111111111111111111',
guildId: '222222222222222222',
channelId: '333333333333333333',
messageId: '444444444444444444',
});
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reportedUserId).toEqual(111111111111111111n);
expect(report!.reportedGuildId).toEqual(222222222222222222n);
expect(report!.reportedChannelId).toEqual(333333333333333333n);
expect(report!.reportedMessageId).toEqual(444444444444444444n);
expect(report!.guildContextId).toEqual(222222222222222222n);
});
it('handles missing user context fields', async () => {
const params = createSnapshotParams({
userId: null,
guildId: null,
channelId: null,
messageId: null,
});
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reportedUserId).toBeNull();
expect(report!.reportedGuildId).toBeNull();
expect(report!.reportedChannelId).toBeNull();
expect(report!.reportedMessageId).toBeNull();
expect(report!.guildContextId).toBeNull();
});
it('handles partial context with only userId', async () => {
const params = createSnapshotParams({
userId: '555555555555555555',
guildId: null,
channelId: null,
messageId: null,
});
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reportedUserId).toEqual(555555555555555555n);
expect(report!.reportedGuildId).toBeNull();
expect(report!.reportedChannelId).toBeNull();
expect(report!.reportedMessageId).toBeNull();
});
it('handles partial context with guildId but no messageId', async () => {
const params = createSnapshotParams({
userId: '666666666666666666',
guildId: '777777777777777777',
channelId: '888888888888888888',
messageId: null,
});
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reportedUserId).toEqual(666666666666666666n);
expect(report!.reportedGuildId).toEqual(777777777777777777n);
expect(report!.reportedChannelId).toEqual(888888888888888888n);
expect(report!.reportedMessageId).toBeNull();
expect(report!.guildContextId).toEqual(777777777777777777n);
});
it('computes SHA-256 integrity hash for evidence', async () => {
const mediaData = TEST_FIXTURES.PNG_1X1_TRANSPARENT;
const params = createSnapshotParams({mediaData});
const reportId = await snapshotService.createSnapshot(params);
const expectedHash = crypto.createHash('sha256').update(mediaData).digest('hex');
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
const additionalInfo = JSON.parse(report!.additionalInfo!);
expect(additionalInfo.hashes).toContain(expectedHash);
expect(mockCsamEvidenceService.storeEvidenceSpy).toHaveBeenCalledOnce();
const evidenceEntry = mockCsamEvidenceService.getStoredEvidence()[0]!;
expect(evidenceEntry.args.hashes).toContain(expectedHash);
});
it('computes different hashes for different media data', async () => {
const params1 = createSnapshotParams({mediaData: TEST_FIXTURES.PNG_1X1_TRANSPARENT});
const params2 = createSnapshotParams({mediaData: TEST_FIXTURES.JPEG_1X1_RED});
const reportId1 = await snapshotService.createSnapshot(params1);
const reportId2 = await snapshotService.createSnapshot(params2);
const report1 = await reportRepository.getReport(createReportID(reportId1));
const report2 = await reportRepository.getReport(createReportID(reportId2));
expect(report1).not.toBeNull();
expect(report2).not.toBeNull();
const additionalInfo1 = JSON.parse(report1!.additionalInfo!);
const additionalInfo2 = JSON.parse(report2!.additionalInfo!);
expect(additionalInfo1.hashes[0]).not.toBe(additionalInfo2.hashes[0]);
});
describe('different resource types', () => {
const resourceTypes: Array<CsamResourceType> = ['attachment', 'avatar', 'emoji', 'sticker', 'banner', 'other'];
for (const resourceType of resourceTypes) {
it(`works for ${resourceType} resource type`, async () => {
const params = createSnapshotParams({resourceType});
const reportId = await snapshotService.createSnapshot(params);
expect(reportId).toBeDefined();
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
const additionalInfo = JSON.parse(report!.additionalInfo!);
expect(additionalInfo.resourceType).toBe(resourceType);
const evidenceEntries = mockCsamEvidenceService.getStoredEvidence();
expect(evidenceEntries.length).toBeGreaterThan(0);
const evidenceEntry = evidenceEntries[evidenceEntries.length - 1]!;
expect(evidenceEntry.args.job.resourceType).toBe(resourceType);
});
}
});
it('passes correct job payload to evidence service', async () => {
const params = createSnapshotParams({
resourceType: 'emoji',
userId: '999999999999999999',
guildId: '888888888888888888',
channelId: null,
messageId: null,
filename: 'custom_emoji.png',
contentType: 'image/png',
});
await snapshotService.createSnapshot(params);
expect(mockCsamEvidenceService.storeEvidenceSpy).toHaveBeenCalledOnce();
const evidenceEntry = mockCsamEvidenceService.getStoredEvidence()[0]!;
expect(evidenceEntry.args.job.resourceType).toBe('emoji');
expect(evidenceEntry.args.job.userId).toBe('999999999999999999');
expect(evidenceEntry.args.job.guildId).toBe('888888888888888888');
expect(evidenceEntry.args.job.channelId).toBeNull();
expect(evidenceEntry.args.job.messageId).toBeNull();
expect(evidenceEntry.args.job.filename).toBe('custom_emoji.png');
expect(evidenceEntry.args.job.contentType).toBe('image/png');
expect(evidenceEntry.args.job.cdnUrl).toBeNull();
});
it('includes match result in evidence', async () => {
const scanResult = createMockMatchResult({
isMatch: true,
trackingId: 'specific-tracking-id',
matchDetails: [
{
source: 'test-source',
violations: ['CSAM', 'EXPLOITATION'],
matchDistance: 0.005,
matchId: 'match-id-123',
},
],
});
const params = createSnapshotParams({scanResult});
await snapshotService.createSnapshot(params);
const evidenceEntry = mockCsamEvidenceService.getStoredEvidence()[0]!;
expect(evidenceEntry.args.matchResult.isMatch).toBe(true);
expect(evidenceEntry.args.matchResult.trackingId).toBe('specific-tracking-id');
expect(evidenceEntry.args.matchResult.matchDetails).toHaveLength(1);
expect(evidenceEntry.args.matchResult.matchDetails[0]!.source).toBe('test-source');
});
it('returns the generated report ID', async () => {
const params = createSnapshotParams();
const reportId = await snapshotService.createSnapshot(params);
const generatedIds = mockSnowflakeService.getGeneratedIds();
expect(reportId).toBe(generatedIds[0]);
expect(typeof reportId).toBe('bigint');
});
it('uses report ID in storage key path', async () => {
const params = createSnapshotParams();
const reportId = await snapshotService.createSnapshot(params);
const uploadCall = mockStorageService.uploadObjectSpy.mock.calls[0]![0]!;
expect(uploadCall.key).toContain(reportId.toString());
});
it('handles null contentType gracefully', async () => {
const params = createSnapshotParams({contentType: null});
await snapshotService.createSnapshot(params);
expect(mockStorageService.uploadObjectSpy).toHaveBeenCalledOnce();
const uploadCall = mockStorageService.uploadObjectSpy.mock.calls[0]![0]!;
expect(uploadCall.contentType).toBeUndefined();
});
it('propagates storage upload errors', async () => {
mockStorageService.configure({shouldFailUpload: true});
const params = createSnapshotParams();
await expect(snapshotService.createSnapshot(params)).rejects.toThrow('Mock storage upload failure');
});
it('records reported_at timestamp', async () => {
const beforeSnapshot = new Date();
const params = createSnapshotParams();
const reportId = await snapshotService.createSnapshot(params);
const afterSnapshot = new Date();
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reportedAt.getTime()).toBeGreaterThanOrEqual(beforeSnapshot.getTime());
expect(report!.reportedAt.getTime()).toBeLessThanOrEqual(afterSnapshot.getTime());
});
it('sets correct null fields for system-generated report', async () => {
const params = createSnapshotParams();
const reportId = await snapshotService.createSnapshot(params);
const report = await reportRepository.getReport(createReportID(reportId));
expect(report).not.toBeNull();
expect(report!.reporterEmail).toBeNull();
expect(report!.reporterFullLegalName).toBeNull();
expect(report!.reporterCountryOfResidence).toBeNull();
expect(report!.reportedUserAvatarHash).toBeNull();
expect(report!.reportedGuildName).toBeNull();
expect(report!.reportedGuildIconHash).toBeNull();
expect(report!.reportedChannelName).toBeNull();
expect(report!.messageContext).toBeNull();
expect(report!.resolvedAt).toBeNull();
expect(report!.resolvedByAdminId).toBeNull();
expect(report!.publicComment).toBeNull();
expect(report!.reportedGuildInviteCode).toBeNull();
});
});
});

View File

@@ -0,0 +1,494 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CsamScanContext, CsamScanQueueEntry} from '@fluxer/api/src/csam/CsamScanQueueService';
import {CsamScanQueueService} from '@fluxer/api/src/csam/CsamScanQueueService';
import {createMockMatchResult, createNoopLogger} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {MockKVProvider, MockKVSubscription} from '@fluxer/api/src/test/mocks/MockKVProvider';
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
function createDefaultContext(): CsamScanContext {
return {
resourceType: 'avatar',
userId: 'user-123',
guildId: null,
channelId: null,
messageId: null,
};
}
describe('CsamScanQueueService', () => {
let logger: ILogger;
let mockKvProvider: MockKVProvider;
let service: CsamScanQueueService;
beforeEach(() => {
logger = createNoopLogger();
mockKvProvider = new MockKVProvider();
service = new CsamScanQueueService({
kvProvider: mockKvProvider,
logger,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('submits scan request to KV queue', () => {
it('calls rpush with correct queue key', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1', 'hash-2'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(mockKvProvider.rpushCalls.length).toBeGreaterThan(0);
});
const noMatchResult = {isMatch: false};
subscription.simulateMessage('csam:result:test', JSON.stringify(noMatchResult));
await submitPromise;
expect(mockKvProvider.rpushCalls).toHaveLength(1);
expect(mockKvProvider.rpushCalls[0].key).toBe('csam:scan:queue');
});
it('includes requestId, hashes, timestamp, and context in queue entry', async () => {
const subscription = mockKvProvider.getSubscription();
const context = createDefaultContext();
const submitPromise = service.submitScan({
hashes: ['hash-1', 'hash-2'],
context,
});
await vi.waitFor(() => {
expect(mockKvProvider.rpushCalls.length).toBeGreaterThan(0);
});
const queueEntry = JSON.parse(mockKvProvider.rpushCalls[0].values[0]) as CsamScanQueueEntry;
expect(queueEntry.requestId).toBeDefined();
expect(typeof queueEntry.requestId).toBe('string');
expect(queueEntry.hashes).toEqual(['hash-1', 'hash-2']);
expect(queueEntry.timestamp).toBeDefined();
expect(typeof queueEntry.timestamp).toBe('number');
expect(queueEntry.context).toEqual(context);
subscription.simulateMessage('csam:result:test', JSON.stringify({isMatch: false}));
await submitPromise;
});
});
describe('waits for result via pub/sub', () => {
it('subscribes to result channel and returns result when message received', async () => {
const subscription = mockKvProvider.getSubscription();
const matchResult = createMockMatchResult({isMatch: false});
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
expect(subscription.connectCalled).toBe(true);
expect(subscription.subscribedChannels[0]).toMatch(/^csam:result:/);
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: false, matchResult}));
const result = await submitPromise;
expect(result.isMatch).toBe(false);
});
});
describe('times out and throws error if no result', () => {
it('throws InternalServerError with CSAM_SCAN_TIMEOUT code after timeout', async () => {
const shortTimeoutService = new CsamScanQueueService({
kvProvider: mockKvProvider,
logger,
timeoutMs: 100,
});
await expect(
shortTimeoutService.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
}),
).rejects.toThrow(InternalServerError);
try {
await shortTimeoutService.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
} catch (error) {
expect(error).toBeInstanceOf(InternalServerError);
expect((error as InternalServerError).code).toBe('CSAM_SCAN_TIMEOUT');
}
});
});
describe('returns match result correctly', () => {
it('returns isMatch: true with matchResult when match is found', async () => {
const subscription = mockKvProvider.getSubscription();
const matchResult = createMockMatchResult({isMatch: true});
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: true, matchResult}));
const result = await submitPromise;
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(true);
expect(result.matchResult!.matchDetails).toHaveLength(1);
});
it('includes all match details in the result', async () => {
const subscription = mockKvProvider.getSubscription();
const matchResult = createMockMatchResult({
isMatch: true,
matchDetails: [
{
source: 'ncmec',
violations: ['CSAM'],
matchDistance: 0.01,
matchId: 'match-123',
},
],
});
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: true, matchResult}));
const result = await submitPromise;
expect(result.matchResult!.matchDetails[0].source).toBe('ncmec');
expect(result.matchResult!.matchDetails[0].matchDistance).toBe(0.01);
});
});
describe('returns no-match result correctly', () => {
it('returns isMatch: false when no match is found', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: false}));
const result = await submitPromise;
expect(result.isMatch).toBe(false);
expect(result.matchResult).toBeUndefined();
});
it('returns no-match result with empty matchResult when provided', async () => {
const subscription = mockKvProvider.getSubscription();
const noMatchResult = createMockMatchResult({isMatch: false});
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(
subscription.subscribedChannels[0],
JSON.stringify({isMatch: false, matchResult: noMatchResult}),
);
const result = await submitPromise;
expect(result.isMatch).toBe(false);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(false);
});
});
describe('handles error in result message', () => {
it('throws InternalServerError with CSAM_SCAN_FAILED code when error field is present', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(
subscription.subscribedChannels[0],
JSON.stringify({error: 'PhotoDNA service unavailable'}),
);
await expect(submitPromise).rejects.toThrow(InternalServerError);
try {
const subscription2 = mockKvProvider.getSubscription();
const submitPromise2 = service.submitScan({
hashes: ['hash-2'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription2.subscribedChannels.length).toBeGreaterThan(1);
});
subscription2.simulateMessage(
subscription2.subscribedChannels[1],
JSON.stringify({error: 'PhotoDNA service unavailable'}),
);
await submitPromise2;
} catch (error) {
expect(error).toBeInstanceOf(InternalServerError);
expect((error as InternalServerError).code).toBe('CSAM_SCAN_FAILED');
}
});
it('throws InternalServerError with CSAM_SCAN_PARSE_ERROR for invalid JSON', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], 'not valid json{');
await expect(submitPromise).rejects.toThrow(InternalServerError);
});
it('throws InternalServerError with CSAM_SCAN_SUBSCRIPTION_ERROR on subscription error', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateError(new Error('Connection lost'));
await expect(submitPromise).rejects.toThrow(InternalServerError);
try {
const subscription2 = mockKvProvider.getSubscription();
const submitPromise2 = service.submitScan({
hashes: ['hash-2'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription2.subscribedChannels.length).toBeGreaterThan(1);
});
subscription2.simulateError(new Error('Connection lost'));
await submitPromise2;
} catch (error) {
expect(error).toBeInstanceOf(InternalServerError);
expect((error as InternalServerError).code).toBe('CSAM_SCAN_SUBSCRIPTION_ERROR');
}
});
});
describe('cleans up subscription on completion', () => {
it('calls quit on subscription after result received', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: false}));
await submitPromise;
expect(subscription.quitCalled).toBe(true);
expect(subscription.removeAllListenersCalled).toBe(true);
});
it('cleans up subscription after timeout', async () => {
const shortTimeoutKvProvider = new MockKVProvider();
const shortTimeoutService = new CsamScanQueueService({
kvProvider: shortTimeoutKvProvider,
logger,
timeoutMs: 50,
});
const subscription = shortTimeoutKvProvider.getSubscription();
try {
await shortTimeoutService.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
} catch {}
expect(subscription.quitCalled).toBe(true);
expect(subscription.removeAllListenersCalled).toBe(true);
});
it('cleans up subscription after error in result message', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(subscription.subscribedChannels.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({error: 'Some error'}));
try {
await submitPromise;
} catch {}
expect(subscription.quitCalled).toBe(true);
expect(subscription.removeAllListenersCalled).toBe(true);
});
it('logs error if cleanup fails but does not throw', async () => {
const failingSubscription = new MockKVSubscription();
failingSubscription.quit = vi.fn().mockRejectedValue(new Error('Cleanup failed'));
const failingKvProvider = new MockKVProvider();
failingKvProvider.setSubscription(failingSubscription);
const failingService = new CsamScanQueueService({
kvProvider: failingKvProvider,
logger,
});
const submitPromise = failingService.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(failingSubscription.subscribedChannels.length).toBeGreaterThan(0);
});
failingSubscription.simulateMessage(failingSubscription.subscribedChannels[0], JSON.stringify({isMatch: false}));
const result = await submitPromise;
expect(result.isMatch).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({error: expect.any(Error)}),
'Failed to cleanup CSAM scan subscription',
);
});
});
describe('logs debug information', () => {
it('logs when scan request is submitted', async () => {
const subscription = mockKvProvider.getSubscription();
const submitPromise = service.submitScan({
hashes: ['hash-1', 'hash-2', 'hash-3'],
context: createDefaultContext(),
});
await vi.waitFor(() => {
expect(mockKvProvider.rpushCalls.length).toBeGreaterThan(0);
});
subscription.simulateMessage(subscription.subscribedChannels[0], JSON.stringify({isMatch: false}));
await submitPromise;
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({requestId: expect.any(String), hashCount: 3}),
'Submitted CSAM scan request',
);
});
});
describe('uses custom timeout when provided', () => {
it('uses per-request timeout over default', async () => {
const defaultTimeoutService = new CsamScanQueueService({
kvProvider: mockKvProvider,
logger,
timeoutMs: 5000,
});
await expect(
defaultTimeoutService.submitScan({
hashes: ['hash-1'],
context: createDefaultContext(),
timeoutMs: 50,
}),
).rejects.toThrow(InternalServerError);
});
});
});

View File

@@ -0,0 +1,278 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import type {
CsamResourceType,
CsamScanJobPayload,
CsamScanJobStatus,
CsamScanTarget,
FrameSample,
PhotoDnaMatchDetail,
PhotoDnaMatchResult,
} from '@fluxer/api/src/csam/CsamTypes';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
import {vi} from 'vitest';
export interface MockPhotoDnaConfig {
shouldMatch: boolean;
matchDistance?: number;
matchSource?: string;
violations?: Array<string>;
matchId?: string;
}
export interface MockNcmecReport {
timestamp: string;
reportId: string;
payload: Record<string, unknown>;
}
export interface MockNcmecUpload {
timestamp: string;
reportId: string;
filename: string;
size: number;
}
export interface CapturedJob {
jobType: string;
payload: CsamScanJobPayload;
timestamp: Date;
}
export class MockWorkerService implements IWorkerService {
private jobs: Array<CapturedJob> = [];
reset(): void {
this.jobs = [];
}
getCapturedJobs(): Array<CapturedJob> {
return [...this.jobs];
}
async addJob(jobType: string, payload: unknown): Promise<void> {
this.jobs.push({
jobType,
payload: payload as CsamScanJobPayload,
timestamp: new Date(),
});
}
async cancelJob(_jobId: string): Promise<boolean> {
return false;
}
async retryDeadLetterJob(_jobId: string): Promise<boolean> {
return false;
}
}
export interface CsamTestContext {
account: TestAccount;
guild: GuildResponse;
guildId: string;
channelId: string;
}
export async function setupCsamTestContext(harness: ApiTestHarness): Promise<CsamTestContext> {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
const guild = await createGuild(harness, account.token, 'CSAM Test Guild');
const channelId = guild.system_channel_id ?? guild.id;
return {
account,
guild,
guildId: guild.id,
channelId,
};
}
export function createMockScanTarget(options: {
resourceType: CsamResourceType;
channelId?: string;
messageId?: string;
guildId?: string;
userId?: string;
filename?: string;
contentType?: string;
}): CsamScanTarget {
const id = randomUUID();
const filename = options.filename ?? 'test-file.png';
return {
bucket: 'test-cdn-bucket',
key: `test/${id}/${filename}`,
cdnUrl: `https://cdn.test.local/test/${id}`,
filename,
contentType: options.contentType ?? 'image/png',
resourceType: options.resourceType,
channelId: options.channelId ?? null,
messageId: options.messageId ?? null,
guildId: options.guildId ?? null,
userId: options.userId ?? null,
};
}
export function createMockJobPayload(options: {
resourceType: CsamResourceType;
channelId?: string | null;
messageId?: string | null;
guildId?: string | null;
userId?: string | null;
filename?: string;
contentType?: string;
}): CsamScanJobPayload {
const id = randomUUID();
const filename = options.filename ?? 'test-file.png';
return {
jobId: randomUUID(),
resourceType: options.resourceType,
bucket: 'test-cdn-bucket',
key: `test/${id}/${filename}`,
cdnUrl: `https://cdn.test.local/test/${id}`,
filename,
contentType: options.contentType ?? 'image/png',
channelId: options.channelId ?? null,
messageId: options.messageId ?? null,
guildId: options.guildId ?? null,
userId: options.userId ?? null,
};
}
export function createMockFrameSamples(count: number): Array<FrameSample> {
return Array.from({length: count}, (_, i) => ({
timestamp: i * 100,
mimeType: 'image/jpeg',
base64: Buffer.from(`mock-frame-data-${i}`).toString('base64'),
}));
}
export function createMockMatchResult(options?: {
isMatch?: boolean;
trackingId?: string;
matchDetails?: Array<PhotoDnaMatchDetail>;
}): PhotoDnaMatchResult {
const isMatch = options?.isMatch ?? true;
return {
isMatch,
trackingId: options?.trackingId ?? randomUUID(),
matchDetails: isMatch
? (options?.matchDetails ?? [
{
source: 'test-database',
violations: ['CSAM'],
matchDistance: 0.01,
matchId: randomUUID(),
},
])
: [],
timestamp: new Date().toISOString(),
};
}
export const CSAM_JOB_STATUSES: Record<string, CsamScanJobStatus> = {
PENDING: 'pending',
PROCESSING: 'processing',
HASHING: 'hashing',
MATCHED: 'matched',
NO_MATCH: 'no_match',
FAILED: 'failed',
};
export const TEST_FIXTURES = {
PNG_1X1_TRANSPARENT: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
),
GIF_1X1_TRANSPARENT: Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'),
JPEG_1X1_RED: Buffer.from(
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCwAB//2Q==',
'base64',
),
};
export function loadTestFixture(type: 'png' | 'gif' | 'jpeg'): Buffer {
if (type === 'png') {
return TEST_FIXTURES.PNG_1X1_TRANSPARENT;
}
if (type === 'gif') {
return TEST_FIXTURES.GIF_1X1_TRANSPARENT;
}
if (type === 'jpeg') {
return TEST_FIXTURES.JPEG_1X1_RED;
}
throw new Error(`Unknown fixture type: ${type}`);
}
export function createNoopLogger(): ILogger {
return {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: () => createNoopLogger(),
};
}
export function createMockGuildResponse(overrides?: Partial<GuildResponse>): GuildResponse {
return {
id: '999',
name: 'Test Guild',
icon: null,
banner: null,
banner_width: null,
banner_height: null,
splash: null,
splash_width: null,
splash_height: null,
splash_card_alignment: 0,
embed_splash: null,
embed_splash_width: null,
embed_splash_height: null,
vanity_url_code: null,
owner_id: '123',
system_channel_id: null,
system_channel_flags: 0,
rules_channel_id: null,
afk_channel_id: null,
afk_timeout: 300,
features: [],
verification_level: 0,
mfa_level: 0,
nsfw_level: 0,
explicit_content_filter: 0,
default_message_notifications: 0,
disabled_operations: 0,
...overrides,
};
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import {createNcmecApiConfig, NcmecReporter} from '@fluxer/api/src/csam/NcmecReporter';
import {
INVALID_REPORT_XML,
SAMPLE_FILE_DETAILS_XML,
SAMPLE_REPORT_XML,
} from '@fluxer/api/src/test/fixtures/ncmec/NcmecXmlFixtures';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const TEST_BASE_URL = 'https://exttest.cybertip.org/ispws';
const TEST_USERNAME = 'usr123';
const TEST_PASSWORD = 'pswd123';
describe('NcmecReporter', () => {
let originalConfig: {
enabled: boolean;
baseUrl?: string;
username?: string;
password?: string;
};
beforeEach(() => {
originalConfig = {
enabled: Config.ncmec.enabled,
baseUrl: Config.ncmec.baseUrl,
username: Config.ncmec.username,
password: Config.ncmec.password,
};
Config.ncmec.enabled = true;
Config.ncmec.baseUrl = TEST_BASE_URL;
Config.ncmec.username = TEST_USERNAME;
Config.ncmec.password = TEST_PASSWORD;
});
afterEach(() => {
Config.ncmec.enabled = originalConfig.enabled;
Config.ncmec.baseUrl = originalConfig.baseUrl;
Config.ncmec.username = originalConfig.username;
Config.ncmec.password = originalConfig.password;
});
test('submitReport returns the report ID when the XML response is successful', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
const reportId = await reporter.submitReport(SAMPLE_REPORT_XML);
expect(reportId).toMatch(/^\d+$/);
});
test('uploadEvidence returns file metadata from the XML response', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
const reportId = await reporter.submitReport(SAMPLE_REPORT_XML);
const result = await reporter.uploadEvidence(reportId, new Uint8Array([1, 2, 3]), 'evidence.zip');
expect(result.fileId).toMatch(/^file-\d+$/);
expect(result.md5).toBe('fafa5efeaf3cbe3b23b2748d13e629a1');
});
test('submitFileDetails posts XML to /fileinfo and succeeds', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
const reportId = await reporter.submitReport(SAMPLE_REPORT_XML);
const {fileId} = await reporter.uploadEvidence(reportId, new Uint8Array([4, 5, 6]), 'evidence.zip');
await reporter.submitFileDetails(SAMPLE_FILE_DETAILS_XML(reportId, fileId));
});
test('finish returns normalized file IDs even when multiple entries exist', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
const reportId = await reporter.submitReport(SAMPLE_REPORT_XML);
const firstUpload = await reporter.uploadEvidence(reportId, new Uint8Array([7]), 'file-a.zip');
const secondUpload = await reporter.uploadEvidence(reportId, new Uint8Array([8]), 'file-b.zip');
const result = await reporter.finish(reportId);
expect(result.reportId).toBe(reportId);
expect(result.fileIds).toEqual([firstUpload.fileId, secondUpload.fileId]);
});
test('retract succeeds when NCMEC responds with responseCode 0', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
const reportId = await reporter.submitReport(SAMPLE_REPORT_XML);
await expect(reporter.retract(reportId)).resolves.toBeUndefined();
});
test('submitReport throws when the XML response contains a non-zero responseCode', async () => {
const reporter = new NcmecReporter({config: createNcmecApiConfig(), fetch});
await expect(reporter.submitReport(INVALID_REPORT_XML)).rejects.toThrow('responseCode 1000');
});
});

View File

@@ -0,0 +1,332 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createTestAccount, setUserACLs, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {Config} from '@fluxer/api/src/Config';
import {
getChannel,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import type {NcmecSubmissionStatusResponse, NcmecSubmitResult} from '@fluxer/api/src/csam/NcmecSubmissionService';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {CATEGORY_CHILD_SAFETY} from '@fluxer/constants/src/ReportCategories';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ReportResponse {
report_id: string;
status: string;
reported_at: string;
}
async function createAdminWithNcmecAccess(harness: ApiTestHarness): Promise<TestAccount> {
const admin = await createTestAccount(harness);
return setUserACLs(harness, admin, ['admin:authenticate', 'report:view', 'report:resolve', 'csam:submit_ncmec']);
}
async function createAdminWithViewOnly(harness: ApiTestHarness): Promise<TestAccount> {
const admin = await createTestAccount(harness);
return setUserACLs(harness, admin, ['admin:authenticate', 'report:view']);
}
async function createChildSafetyMessageReport(harness: ApiTestHarness): Promise<{reportId: string; token: string}> {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0]!;
await ensureSessionStarted(harness, targetUser.token);
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message for CSAM report');
const report = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: CATEGORY_CHILD_SAFETY,
additional_info: 'Child safety report for testing',
})
.expect(HTTP_STATUS.OK)
.execute();
return {reportId: report.report_id, token: owner.token};
}
async function createNonChildSafetyReport(harness: ApiTestHarness): Promise<{reportId: string; token: string}> {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const report = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: 'Harassment report for testing',
})
.expect(HTTP_STATUS.OK)
.execute();
return {reportId: report.report_id, token: reporter.token};
}
describe('NCMEC Submission Admin Endpoints', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('GET /admin/reports/:report_id/ncmec-status', () => {
test('returns not_submitted for new child_safety report', async () => {
const admin = await createAdminWithViewOnly(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const status = await createBuilder<NcmecSubmissionStatusResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.status).toBe('not_submitted');
expect(status.ncmec_report_id).toBeNull();
expect(status.submitted_at).toBeNull();
expect(status.submitted_by_admin_id).toBeNull();
expect(status.failure_reason).toBeNull();
});
test('requires admin authentication', async () => {
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, '')
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.UNAUTHORIZED)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
});
test('requires report:view ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
});
test('returns error for non-existent report', async () => {
const admin = await createAdminWithViewOnly(harness);
const nonExistentReportId = '999999999999999999';
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${nonExistentReportId}/ncmec-status`)
.expect(HTTP_STATUS.NOT_FOUND)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.NOT_FOUND);
});
test('returns error for non-child-safety report', async () => {
const admin = await createAdminWithViewOnly(harness);
const {reportId} = await createNonChildSafetyReport(harness);
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.NOT_FOUND)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.NOT_FOUND);
});
});
describe('POST /admin/reports/:report_id/ncmec-submit', () => {
let originalNcmecConfig: {
enabled: boolean;
baseUrl?: string;
username?: string;
password?: string;
};
beforeEach(() => {
originalNcmecConfig = {
enabled: Config.ncmec.enabled,
baseUrl: Config.ncmec.baseUrl,
username: Config.ncmec.username,
password: Config.ncmec.password,
};
Config.ncmec.enabled = true;
Config.ncmec.baseUrl = 'https://exttest.cybertip.org/ispws';
Config.ncmec.username = 'usr123';
Config.ncmec.password = 'pswd123';
});
afterEach(() => {
Config.ncmec.enabled = originalNcmecConfig.enabled;
Config.ncmec.baseUrl = originalNcmecConfig.baseUrl;
Config.ncmec.username = originalNcmecConfig.username;
Config.ncmec.password = originalNcmecConfig.password;
});
test('successfully submits child_safety report to NCMEC', async () => {
const admin = await createAdminWithNcmecAccess(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const result = await createBuilder<NcmecSubmitResult>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.OK)
.execute();
expect(result.success).toBe(true);
expect(result.ncmec_report_id).toMatch(/^\d+$/);
expect(result.error).toBeNull();
});
test('status changes to submitted after successful submission', async () => {
const admin = await createAdminWithNcmecAccess(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
await createBuilder<NcmecSubmitResult>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.OK)
.execute();
const status = await createBuilder<NcmecSubmissionStatusResponse>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.OK)
.execute();
expect(status.status).toBe('submitted');
expect(status.ncmec_report_id).toMatch(/^\d+$/);
expect(status.submitted_at).toBeTruthy();
expect(status.submitted_by_admin_id).toBe(admin.userId);
});
test('requires admin authentication', async () => {
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, '')
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.UNAUTHORIZED)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED);
});
test('requires csam:submit_ncmec ACL', async () => {
const admin = await createAdminWithViewOnly(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
});
test('returns error for non-existent report', async () => {
const admin = await createAdminWithNcmecAccess(harness);
const nonExistentReportId = '999999999999999999';
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${nonExistentReportId}/ncmec-submit`)
.expect(HTTP_STATUS.NOT_FOUND)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.NOT_FOUND);
});
test('returns error for non-child-safety report', async () => {
const admin = await createAdminWithNcmecAccess(harness);
const {reportId} = await createNonChildSafetyReport(harness);
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.NOT_FOUND)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.NOT_FOUND);
});
test('returns error when already submitted', async () => {
const admin = await createAdminWithNcmecAccess(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
await createBuilder<NcmecSubmitResult>(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.OK)
.execute();
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.CONFLICT)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.CONFLICT);
});
});
describe('Authorization Boundary Tests', () => {
test('regular user cannot access ncmec-status endpoint', async () => {
const user = await createTestAccount(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, user.token)
.get(`/admin/reports/${reportId}/ncmec-status`)
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
});
test('regular user cannot access ncmec-submit endpoint', async () => {
const user = await createTestAccount(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, user.token)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
});
test('admin with only report:view cannot submit', async () => {
const admin = await createAdminWithViewOnly(harness);
const {reportId} = await createChildSafetyMessageReport(harness);
const {response} = await createBuilder(harness, `Bearer ${admin.token}`)
.post(`/admin/reports/${reportId}/ncmec-submit`)
.expect(HTTP_STATUS.FORBIDDEN)
.executeWithResponse();
expect(response.status).toBe(HTTP_STATUS.FORBIDDEN);
});
});
});

View File

@@ -0,0 +1,631 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {SynchronousCsamScanner} from '@fluxer/api/src/csam/SynchronousCsamScanner';
import {
createMockFrameSamples,
createMockMatchResult,
createNoopLogger,
} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {MediaProxyMetadataResponse} from '@fluxer/api/src/infrastructure/IMediaService';
import {MockCsamScanQueueService} from '@fluxer/api/src/test/mocks/MockCsamScanQueueService';
import {MockMediaService} from '@fluxer/api/src/test/mocks/MockMediaService';
import {MockPhotoDnaHashClient} from '@fluxer/api/src/test/mocks/MockPhotoDnaHashClient';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('SynchronousCsamScanner', () => {
let logger: ILogger;
beforeEach(() => {
logger = createNoopLogger();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('disabled scanner', () => {
it('returns isMatch: false immediately when disabled', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: false,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result).toEqual({isMatch: false});
expect(hashClient.hashFramesSpy).not.toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
});
it('returns isMatch: false for scanBase64 when disabled', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: false,
logger,
});
const result = await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'});
expect(result).toEqual({isMatch: false});
expect(hashClient.hashFramesSpy).not.toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('unscannable media types', () => {
it('returns isMatch: false for null content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: null});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key', contentType: null},
'Skipping non-scannable media type',
);
});
it('returns isMatch: false for text/plain content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'text/plain'});
expect(result).toEqual({isMatch: false});
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
});
it('returns isMatch: false for application/json content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'application/json'});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false for audio/mp3 content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'audio/mp3'});
expect(result).toEqual({isMatch: false});
});
it('skips scanning for unscannable media type in scanBase64', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'text/plain'});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith({mimeType: 'text/plain'}, 'Skipping non-scannable media type');
});
});
describe('scannable media types', () => {
it.each([
['image/jpeg'],
['image/png'],
['image/gif'],
['image/webp'],
['image/bmp'],
['image/tiff'],
['image/avif'],
['image/apng'],
['video/mp4'],
['video/webm'],
['video/quicktime'],
['video/x-msvideo'],
['video/x-matroska'],
])('scans %s content type', async (contentType) => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType});
if (contentType.startsWith('video/')) {
expect(mediaService.extractFramesSpy).toHaveBeenCalled();
} else {
expect(mediaService.getMetadataSpy).toHaveBeenCalled();
}
});
it('handles content type with charset parameter', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/jpeg; charset=utf-8'});
expect(mediaService.getMetadataSpy).toHaveBeenCalled();
});
});
describe('no match scenario', () => {
it('returns isMatch: false when PhotoDNA finds no match', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1', 'hash-2']});
const queueService = new MockCsamScanQueueService({
matchResult: createMockMatchResult({isMatch: false}),
});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(false);
expect(result.frames).toBeDefined();
expect(result.hashes).toEqual(['hash-1', 'hash-2']);
});
it('returns isMatch: false when no frames are extracted from video', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({returnEmptyFrames: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'video/mp4'});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key'},
'No frames extracted from media',
);
});
it('returns isMatch: false when no hashes are generated', async () => {
const hashClient = new MockPhotoDnaHashClient({returnEmpty: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.frames).toBeDefined();
expect(result.hashes).toEqual([]);
expect(logger.debug).toHaveBeenCalledWith('No hashes generated from frames');
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('match detected scenario', () => {
it('returns isMatch: true with matchResult, frames, and hashes when match found', async () => {
const hashes = ['hash-1', 'hash-2'];
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(true);
expect(result.matchResult!.matchDetails).toHaveLength(1);
expect(result.frames).toBeDefined();
expect(result.frames!.length).toBeGreaterThan(0);
expect(result.hashes).toEqual(hashes);
});
it('logs warning when CSAM match is detected', async () => {
const matchResult = createMockMatchResult({isMatch: true, trackingId: 'test-tracking-id'});
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(logger.warn).toHaveBeenCalledWith({trackingId: 'test-tracking-id', matchCount: 1}, 'CSAM match detected');
});
});
describe('frame extraction from S3', () => {
it('extracts frames from video using extractFrames', async () => {
const frames = [
{timestamp: 0, mime_type: 'image/jpeg', base64: 'frame0'},
{timestamp: 500, mime_type: 'image/jpeg', base64: 'frame1'},
{timestamp: 1000, mime_type: 'image/jpeg', base64: 'frame2'},
];
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({frames});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'});
expect(mediaService.extractFramesSpy).toHaveBeenCalledWith({
type: 's3',
bucket: 'test-bucket',
key: 'video-key',
});
expect(result.frames).toHaveLength(3);
expect(result.frames![0]).toEqual({timestamp: 0, mimeType: 'image/jpeg', base64: 'frame0'});
});
it('extracts single frame from image using getMetadata', async () => {
const metadata: MediaProxyMetadataResponse = {
format: 'png',
content_type: 'image/png',
content_hash: 'hash123',
size: 2048,
nsfw: false,
base64: 'imagebase64data',
};
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({metadata});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'});
expect(mediaService.getMetadataSpy).toHaveBeenCalledWith({
type: 's3',
bucket: 'test-bucket',
key: 'image-key',
with_base64: true,
isNSFWAllowed: true,
});
expect(result.frames).toHaveLength(1);
expect(result.frames![0]).toEqual({
timestamp: 0,
mimeType: 'image/png',
base64: 'imagebase64data',
});
});
it('throws error when metadata has no base64', async () => {
const metadata: MediaProxyMetadataResponse = {
format: 'png',
content_type: 'image/png',
content_hash: 'hash123',
size: 2048,
nsfw: false,
};
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({metadata});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Media proxy returned metadata without base64 for image scan');
});
});
describe('frame extraction failure', () => {
it('throws error when metadata returns null for image', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({returnNullMetadata: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Media proxy returned no metadata for image scan');
expect(logger.error).toHaveBeenCalledWith(
{error: expect.any(Error), bucket: 'test-bucket', key: 'image-key'},
'Failed to scan media',
);
});
it('throws error when frame extraction fails for video', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({shouldFailFrameExtraction: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'}),
).rejects.toThrow('Mock frame extraction failure');
expect(logger.error).toHaveBeenCalledWith(
{error: expect.any(Error), bucket: 'test-bucket', key: 'video-key'},
'Failed to scan media',
);
});
it('throws error when metadata fetch fails for image', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({shouldFailMetadata: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Mock metadata fetch failure');
expect(logger.error).toHaveBeenCalled();
});
});
describe('hash generation failure', () => {
it('throws error when hash client fails', async () => {
const hashClient = new MockPhotoDnaHashClient({shouldFail: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock hash client failure');
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('queue service failure', () => {
it('throws error when queue service fails', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({shouldFail: true});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock queue service failure');
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).toHaveBeenCalled();
});
});
describe('scanBase64 method', () => {
it('scans base64 image data successfully', async () => {
const base64Data = Buffer.from('test-image-data').toString('base64');
const hashes = ['base64-hash-1'];
const matchResult = createMockMatchResult({isMatch: false});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: base64Data, mimeType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.frames).toHaveLength(1);
expect(result.frames![0]).toEqual({
timestamp: 0,
mimeType: 'image/png',
base64: base64Data,
});
expect(result.hashes).toEqual(hashes);
});
it('returns match result when base64 content matches', async () => {
const base64Data = Buffer.from('suspicious-image').toString('base64');
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes: ['match-hash']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: base64Data, mimeType: 'image/jpeg'});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.isMatch).toBe(true);
expect(logger.warn).toHaveBeenCalled();
});
it('throws error when hash generation fails for base64', async () => {
const hashClient = new MockPhotoDnaHashClient({shouldFail: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'})).rejects.toThrow(
'Mock hash client failure',
);
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
it('does not call media service for base64 scanning', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'});
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
expect(mediaService.extractFramesSpy).not.toHaveBeenCalled();
});
});
describe('integration with test utilities', () => {
it('works with createMockFrameSamples utility', async () => {
const mockFrames = createMockFrameSamples(3);
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({
frames: mockFrames.map((f) => ({
timestamp: f.timestamp,
mime_type: f.mimeType,
base64: f.base64,
})),
});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'});
expect(result.frames).toHaveLength(3);
expect(hashClient.hashFramesSpy).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({timestamp: 0}),
expect.objectContaining({timestamp: 100}),
expect.objectContaining({timestamp: 200}),
]),
);
});
it('works with createMockMatchResult utility for match', async () => {
const matchResult = createMockMatchResult({
isMatch: true,
matchDetails: [
{
source: 'ncmec',
violations: ['CSAM', 'CGI'],
matchDistance: 0.005,
matchId: 'detail-123',
},
],
});
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.matchDetails[0]!.source).toBe('ncmec');
expect(result.matchResult!.matchDetails[0]!.violations).toContain('CSAM');
});
});
});