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:
231
fluxer/packages/api/src/csam/CsamEvidenceRetentionService.tsx
Normal file
231
fluxer/packages/api/src/csam/CsamEvidenceRetentionService.tsx
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
247
fluxer/packages/api/src/csam/CsamEvidenceService.tsx
Normal file
247
fluxer/packages/api/src/csam/CsamEvidenceService.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
86
fluxer/packages/api/src/csam/CsamLegalHoldService.tsx
Normal file
86
fluxer/packages/api/src/csam/CsamLegalHoldService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
155
fluxer/packages/api/src/csam/CsamReportSnapshotService.tsx
Normal file
155
fluxer/packages/api/src/csam/CsamReportSnapshotService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
221
fluxer/packages/api/src/csam/CsamResponseService.tsx
Normal file
221
fluxer/packages/api/src/csam/CsamResponseService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
150
fluxer/packages/api/src/csam/CsamScanJobService.tsx
Normal file
150
fluxer/packages/api/src/csam/CsamScanJobService.tsx
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
171
fluxer/packages/api/src/csam/CsamScanQueueService.tsx
Normal file
171
fluxer/packages/api/src/csam/CsamScanQueueService.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
fluxer/packages/api/src/csam/CsamTypes.tsx
Normal file
108
fluxer/packages/api/src/csam/CsamTypes.tsx
Normal 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>;
|
||||
}
|
||||
44
fluxer/packages/api/src/csam/ICsamEvidenceService.tsx
Normal file
44
fluxer/packages/api/src/csam/ICsamEvidenceService.tsx
Normal 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>;
|
||||
}
|
||||
24
fluxer/packages/api/src/csam/ICsamReportSnapshotService.tsx
Normal file
24
fluxer/packages/api/src/csam/ICsamReportSnapshotService.tsx
Normal 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>;
|
||||
}
|
||||
29
fluxer/packages/api/src/csam/ISynchronousCsamScanner.tsx
Normal file
29
fluxer/packages/api/src/csam/ISynchronousCsamScanner.tsx
Normal 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>;
|
||||
}
|
||||
303
fluxer/packages/api/src/csam/NcmecReporter.tsx
Normal file
303
fluxer/packages/api/src/csam/NcmecReporter.tsx
Normal 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;
|
||||
}
|
||||
254
fluxer/packages/api/src/csam/NcmecSubmissionService.tsx
Normal file
254
fluxer/packages/api/src/csam/NcmecSubmissionService.tsx
Normal 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(' ')};
|
||||
}
|
||||
97
fluxer/packages/api/src/csam/PhotoDnaHashClient.tsx
Normal file
97
fluxer/packages/api/src/csam/PhotoDnaHashClient.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
214
fluxer/packages/api/src/csam/PhotoDnaMatchService.tsx
Normal file
214
fluxer/packages/api/src/csam/PhotoDnaMatchService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
359
fluxer/packages/api/src/csam/SynchronousCsamScanner.tsx
Normal file
359
fluxer/packages/api/src/csam/SynchronousCsamScanner.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
110
fluxer/packages/api/src/csam/providers/CsamProviderFactory.tsx
Normal file
110
fluxer/packages/api/src/csam/providers/CsamProviderFactory.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
fluxer/packages/api/src/csam/providers/ICsamScanProvider.tsx
Normal file
71
fluxer/packages/api/src/csam/providers/ICsamScanProvider.tsx
Normal 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>;
|
||||
}
|
||||
357
fluxer/packages/api/src/csam/providers/PhotoDnaProvider.tsx
Normal file
357
fluxer/packages/api/src/csam/providers/PhotoDnaProvider.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
967
fluxer/packages/api/src/csam/tests/CsamBlockingBehavior.test.tsx
Normal file
967
fluxer/packages/api/src/csam/tests/CsamBlockingBehavior.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
});
|
||||
391
fluxer/packages/api/src/csam/tests/CsamLegalHoldService.test.tsx
Normal file
391
fluxer/packages/api/src/csam/tests/CsamLegalHoldService.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
494
fluxer/packages/api/src/csam/tests/CsamScanQueueService.test.tsx
Normal file
494
fluxer/packages/api/src/csam/tests/CsamScanQueueService.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
278
fluxer/packages/api/src/csam/tests/CsamTestUtils.tsx
Normal file
278
fluxer/packages/api/src/csam/tests/CsamTestUtils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
106
fluxer/packages/api/src/csam/tests/NcmecReporter.test.tsx
Normal file
106
fluxer/packages/api/src/csam/tests/NcmecReporter.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user