diff --git a/server/services/ncmecService/buildSubmitReportObject.test.ts b/server/services/ncmecService/buildSubmitReportObject.test.ts index df8d961..7cee8b4 100644 --- a/server/services/ncmecService/buildSubmitReportObject.test.ts +++ b/server/services/ncmecService/buildSubmitReportObject.test.ts @@ -18,6 +18,7 @@ function makeBuildReportInput( userAdditionalInfo?: BuildSubmitReportObjectInput['userAdditionalInfo']; orgSettings?: Partial; clampedIncidentDateTime?: string; + priorCTReports?: readonly number[]; } = {}, ): BuildSubmitReportObjectInput { const { reportParams: paramOverrides, ...rest } = overrides; @@ -46,6 +47,9 @@ function makeBuildReportInput( ...rest.orgSettings, }, clampedIncidentDateTime: rest.clampedIncidentDateTime ?? INCIDENT_DATE_TIME, + ...(rest.priorCTReports !== undefined + ? { priorCTReports: rest.priorCTReports } + : {}), }; } @@ -359,4 +363,44 @@ describe('buildSubmitReportObject', () => { expect(result.report.reporter.termsOfService).toBe('Acme ToS'); }); }); + + describe('priorCTReports', () => { + it('populates inside personOrUserReported after ipCaptureEvent', () => { + const result = buildSubmitReportObject( + makeBuildReportInput({ + reportParams: { + reportedUser: { + id: 'user-1', + typeId: 'user-type-1', + ipAddress: '203.0.113.7', + }, + }, + priorCTReports: [99, 102, 110], + }), + ); + expect(result.report.personOrUserReported?.priorCTReports).toEqual([ + 99, 102, 110, + ]); + expect(Object.keys(result.report.personOrUserReported!)).toEqual([ + 'espIdentifier', + 'espService', + 'screenName', + 'ipCaptureEvent', + 'priorCTReports', + ]); + }); + + it('omits the element when empty or unset', () => { + const unset = buildSubmitReportObject(makeBuildReportInput()); + expect(unset.report.personOrUserReported).not.toHaveProperty( + 'priorCTReports', + ); + const empty = buildSubmitReportObject( + makeBuildReportInput({ priorCTReports: [] }), + ); + expect(empty.report.personOrUserReported).not.toHaveProperty( + 'priorCTReports', + ); + }); + }); }); diff --git a/server/services/ncmecService/ncmecReporting.builders.test.ts b/server/services/ncmecService/ncmecReporting.builders.test.ts index 3d39578..58ccab8 100644 --- a/server/services/ncmecService/ncmecReporting.builders.test.ts +++ b/server/services/ncmecService/ncmecReporting.builders.test.ts @@ -1,5 +1,6 @@ import { buildFileDetailsObject, + deriveOriginalFileNameFromUrl, fileAnnotationArrayToNCMECFileAnnotation, NCMECEvent, NCMECFileAnnotation, @@ -7,6 +8,43 @@ import { const INCIDENT_DATE_TIME = '2026-05-27T18:00:00.000Z'; +describe('deriveOriginalFileNameFromUrl', () => { + it('returns the decoded last path segment', () => { + expect( + deriveOriginalFileNameFromUrl('https://cdn.example/a/b/cat.jpg'), + ).toBe('cat.jpg'); + expect( + deriveOriginalFileNameFromUrl('https://cdn.example/a/my%20file.png'), + ).toBe('my file.png'); + }); + + it('ignores query strings and fragments', () => { + expect( + deriveOriginalFileNameFromUrl('https://cdn.example/img.jpg?token=abc#x'), + ).toBe('img.jpg'); + }); + + it('returns undefined for paths without a usable last segment', () => { + expect( + deriveOriginalFileNameFromUrl('https://cdn.example/'), + ).toBeUndefined(); + expect( + deriveOriginalFileNameFromUrl('https://cdn.example'), + ).toBeUndefined(); + }); + + it('returns undefined for unparseable URLs', () => { + expect(deriveOriginalFileNameFromUrl('not a url')).toBeUndefined(); + expect(deriveOriginalFileNameFromUrl('')).toBeUndefined(); + }); + + it('falls back to the raw segment on malformed percent-encoding', () => { + expect( + deriveOriginalFileNameFromUrl('https://cdn.example/a/%E0%A4.jpg'), + ).toBe('%E0%A4.jpg'); + }); +}); + describe('fileAnnotationArrayToNCMECFileAnnotation', () => { it('returns undefined for empty or missing input', () => { expect(fileAnnotationArrayToNCMECFileAnnotation(undefined)).toBeUndefined(); @@ -59,6 +97,7 @@ describe('buildFileDetailsObject', () => { fileId: 'ncmec-file-1', fileViewedByEsp: true, exifViewedByEsp: true, + fileRelevance: 'Reported', industryClassification: 'A1', }, }); @@ -71,6 +110,7 @@ describe('buildFileDetailsObject', () => { // exttest rejects the report. const result = buildFileDetailsObject({ ...baseInput, + originalFileName: 'photo.jpg', media: { industryClassification: 'A1' as const, fileAnnotations: [NCMECFileAnnotation.GENERATIVE_AI], @@ -91,9 +131,11 @@ describe('buildFileDetailsObject', () => { expect(Object.keys(result.fileDetails)).toEqual([ 'reportId', 'fileId', + 'originalFileName', 'fileViewedByEsp', 'exifViewedByEsp', 'publiclyAvailable', + 'fileRelevance', 'fileAnnotations', 'industryClassification', 'originalFileHash', @@ -124,6 +166,28 @@ describe('buildFileDetailsObject', () => { ); }); + it('defaults fileRelevance to "Reported" and accepts an override', () => { + expect(buildFileDetailsObject(baseInput).fileDetails.fileRelevance).toBe( + 'Reported', + ); + expect( + buildFileDetailsObject({ + ...baseInput, + fileRelevance: 'Supplemental Reported', + }).fileDetails.fileRelevance, + ).toBe('Supplemental Reported'); + }); + + it('emits originalFileName when supplied, omits when not', () => { + expect( + buildFileDetailsObject({ ...baseInput, originalFileName: 'cat.jpg' }) + .fileDetails.originalFileName, + ).toBe('cat.jpg'); + expect(buildFileDetailsObject(baseInput).fileDetails).not.toHaveProperty( + 'originalFileName', + ); + }); + it('emits publiclyAvailable when set to true or false, omits when undefined', () => { const truthy = buildFileDetailsObject({ ...baseInput, diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index c99533f..aa49b1a 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -681,6 +681,8 @@ export type BuildSubmitReportObjectInput = { }; /** Latest media `createdAt`, already clamped to the past. */ clampedIncidentDateTime: string; + /** Prior accepted NCMEC report IDs for the reported user; renders as ``. */ + priorCTReports?: readonly number[]; }; /** Build the `Report` envelope NCMEC's `/submit` endpoint expects. Pure: no @@ -698,6 +700,7 @@ export function buildSubmitReportObject( userAdditionalInfo, orgSettings, clampedIncidentDateTime, + priorCTReports, } = input; const emailStringToNCMECEmail = (email: string) => ({ _text: email }); @@ -814,6 +817,9 @@ export function buildSubmitReportObject( reportedUserIpCaptureEvents.length > 0 ? { ipCaptureEvent: reportedUserIpCaptureEvents } : {}), + ...(priorCTReports && priorCTReports.length > 0 + ? { priorCTReports: [...priorCTReports] } + : {}), }, ...(reportAdditionalInfo !== undefined ? { additionalInfo: reportAdditionalInfo } @@ -825,6 +831,10 @@ export function buildSubmitReportObject( export type BuildFileDetailsObjectInput = { reportId: number; fileId: string; + /** Optional ``. Usually derived via `deriveOriginalFileNameFromUrl`. */ + originalFileName?: string; + /** ``. Defaults to `'Reported'`. */ + fileRelevance?: 'Reported' | 'Supplemental Reported'; media: Pick & { fileAnnotations?: readonly NCMECFileAnnotationType[]; }; @@ -838,6 +848,39 @@ export type BuildFileDetailsObjectInput = { originalFileHash?: readonly OriginalFileHash[]; }; +/** Decoded last path segment of `url`, or `undefined` if unavailable. + * Logs each fallback path via `ncmecDebugLog` so operators can triage + * unexpected URL shapes when `NCMEC_DEBUG=1` is enabled. */ +export function deriveOriginalFileNameFromUrl(url: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + ncmecDebugLog('deriveOriginalFileName.urlParseFailed', { url }); + return undefined; + } + const last = pathname + .split('/') + .filter((s) => s !== '') + .pop(); + if (!last) { + ncmecDebugLog('deriveOriginalFileName.emptyPath', { url }); + return undefined; + } + try { + const decoded = decodeURIComponent(last); + if (decoded.length === 0) { + ncmecDebugLog('deriveOriginalFileName.emptySegment', { url }); + return undefined; + } + return decoded; + } catch { + // Malformed percent-encoding: keep the raw segment. + ncmecDebugLog('deriveOriginalFileName.decodeFailed', { url, raw: last }); + return last; + } +} + /** Pure builder for the `FileDetails` envelope NCMEC's `/fileinfo` * endpoint expects. Used by `submitReport`'s `#upload` step and by * dry-run tooling that needs to serialize per-media XML without @@ -845,7 +888,15 @@ export type BuildFileDetailsObjectInput = { export function buildFileDetailsObject( input: BuildFileDetailsObjectInput, ): FileDetails { - const { reportId, fileId, media, additionalInfo, originalFileHash } = input; + const { + reportId, + fileId, + media, + additionalInfo, + originalFileHash, + originalFileName, + } = input; + const fileRelevance = input.fileRelevance ?? 'Reported'; const fileAnnotations = fileAnnotationArrayToNCMECFileAnnotation( media.fileAnnotations, ); @@ -853,11 +904,13 @@ export function buildFileDetailsObject( fileDetails: { reportId, fileId, + ...(originalFileName ? { originalFileName } : {}), fileViewedByEsp: true, exifViewedByEsp: true, ...(additionalInfo.publiclyAvailable !== undefined ? { publiclyAvailable: additionalInfo.publiclyAvailable } : {}), + fileRelevance, ...(fileAnnotations ? { fileAnnotations } : {}), industryClassification: media.industryClassification, ...(originalFileHash && originalFileHash.length > 0 @@ -1669,6 +1722,28 @@ export default class NcmecReporting { return firstReport != null; } + /** Prior accepted NCMEC report IDs for the user, most recent first. + * Non-numeric `report_id`s are skipped (XSD requires `xs:integer`). */ + async getPriorCTReportIds(params: { + orgId: string; + userId: string; + userItemTypeId: string; + }): Promise { + const { orgId, userId, userItemTypeId } = params; + const rows = await this.pgQuery + .selectFrom('ncmec_reporting.ncmec_reports') + .select(['report_id']) + .where('org_id', '=', orgId) + .where('user_id', '=', userId) + .where('user_item_type_id', '=', userItemTypeId) + .where('is_test', '=', false) + .orderBy('created_at', 'desc') + .execute(); + return rows + .map((r) => parseInt(r.report_id, 10)) + .filter((n) => Number.isFinite(n)); + } + async submitReport( reportParams: NCMECReportParams, isTest: boolean, @@ -1850,6 +1925,16 @@ export default class NcmecReporting { ); } + // Skip for test submissions: prod and exttest report IDs don't + // cross-reference. + const priorCTReports = isTest + ? [] + : await this.getPriorCTReportIds({ + orgId: reportParams.orgId, + userId: reportParams.reportedUser.id, + userItemTypeId: reportParams.reportedUser.typeId, + }); + const report = buildSubmitReportObject({ reportParams, userAdditionalInfo, @@ -1867,6 +1952,7 @@ export default class NcmecReporting { moreInfoUrl: ncmecConfig?.more_info_url, }, clampedIncidentDateTime, + priorCTReports, }); // For the five actions here @@ -2174,9 +2260,12 @@ export default class NcmecReporting { hmaHashes: media.hashes, webhookFileDetails: additionalInfo.fileDetails, }); + const originalFileName = + additionalInfo.fileName ?? deriveOriginalFileNameFromUrl(media.url); const fileDetailsObject = buildFileDetailsObject({ reportId: parseInt(reportId), fileId, + ...(originalFileName ? { originalFileName } : {}), media, additionalInfo, ...(originalFileHash ? { originalFileHash } : {}),