From fdc0603f5e0c34e58c1639482527dd18bf97a213 Mon Sep 17 00:00:00 2001 From: Marco Filho Date: Tue, 15 Sep 2026 06:06:00 -0300 Subject: [PATCH 01/17] [196] Send HMA bank metadata in the request body Co-Authored-By: Claude Opus 5 (1M context) --- server/services/hmaService/index.test.ts | 46 ++++++++++++++++++++++++ server/services/hmaService/index.ts | 20 +++++------ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/server/services/hmaService/index.test.ts b/server/services/hmaService/index.test.ts index a51b9be6e..fb8bf781d 100644 --- a/server/services/hmaService/index.test.ts +++ b/server/services/hmaService/index.test.ts @@ -383,4 +383,50 @@ describe('HmaService', () => { expect(result.credentials_schema).toBeNull(); }); }); + + describe('addContentToBank', () => { + it('sends the url as a query param and the metadata in the JSON body', async () => { + const fetchHTTP = jest + .fn() + .mockResolvedValue(ok({ id: 7, signals: { pdq: 'abc' } })); + const svc = makeService(fetchHTTP); + + const result = await svc.addContentToBank('COOP_ORG1_BANK', { + url: 'https://cdn.example.com/a.jpg?sig=1', + metadata: { + content_id: 'type1:item1', + json: { source: 'ncmec_report' }, + }, + }); + + expect(result).toEqual({ id: 7, signals: { pdq: 'abc' } }); + expect(fetchHTTP).toHaveBeenCalledTimes(1); + const call = fetchHTTP.mock.calls[0][0]; + const url = new URL(call.url); + expect(url.pathname).toBe('/c/bank/COOP_ORG1_BANK/content'); + expect([...url.searchParams.keys()]).toEqual(['url']); + expect(url.searchParams.get('url')).toBe( + 'https://cdn.example.com/a.jpg?sig=1', + ); + expect(call.method).toBe('post'); + expect(call.headers).toEqual({ 'Content-Type': 'application/json' }); + expect(jsonParse(call.body)).toEqual({ + metadata: { + content_id: 'type1:item1', + json: { source: 'ncmec_report' }, + }, + }); + }); + + it('throws when HMA rejects the content', async () => { + const fetchHTTP = jest.fn().mockResolvedValue(fail(400)); + const svc = makeService(fetchHTTP); + + await expect( + svc.addContentToBank('COOP_ORG1_BANK', { + url: 'https://cdn.example.com/a.jpg', + }), + ).rejects.toThrow('Failed to add content to bank: 400'); + }); + }); }); diff --git a/server/services/hmaService/index.ts b/server/services/hmaService/index.ts index be81a152d..3a61f2cfc 100644 --- a/server/services/hmaService/index.ts +++ b/server/services/hmaService/index.ts @@ -814,21 +814,17 @@ export class HmaService { let response; if (url) { - // URL-based content - const params = new URLSearchParams(); - params.append('url', url); - if (metadata) { - if (metadata.content_id) - params.append('content_id', metadata.content_id); - if (metadata.content_uri) - params.append('content_uri', metadata.content_uri); - if (metadata.json) - params.append('metadata', jsonStringify(metadata.json)); - } - + // HMA reads `metadata` from the JSON body only; query-param metadata is dropped. + const params = new URLSearchParams({ url }); response = await this.fetchHTTP({ url: `${this.hmaServiceUrl}/c/bank/${bankName}/content?${params.toString()}`, method: 'post', + ...(metadata + ? { + body: jsonStringify({ metadata }), + headers: { 'Content-Type': 'application/json' }, + } + : {}), handleResponseBody: 'as-json', }); } else { From 2246834e8ddd8475652f552e1b75c24e05d13c1d Mon Sep 17 00:00:00 2001 From: Marco Filho Date: Tue, 15 Sep 2026 06:10:00 -0300 Subject: [PATCH 02/17] [196] Add a hash bank setting for reported NCMEC media Co-Authored-By: Claude Opus 5 (1M context) --- client/src/graphql/generated.ts | 13 +++ .../src/webpages/settings/NCMECSettings.tsx | 47 +++++++++++ ..._media_hash_bank_to_ncmec_org_settings.sql | 11 +++ server/graphql/generated.ts | 7 ++ server/graphql/modules/ncmec.resolver.test.ts | 82 ++++++++++++++++++- server/graphql/modules/ncmec.ts | 10 +++ .../modules/ncmecOrgSettingsValidation.ts | 25 ++++++ server/services/ncmecService/dbTypes.ts | 1 + server/services/ncmecService/ncmecService.ts | 4 + .../ncmec-report-submission.integ.test.ts | 1 + .../test/integ/ncmec-submission.integ.test.ts | 1 + 11 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.09.15T04.40.33.add_reported_media_hash_bank_to_ncmec_org_settings.sql diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 9e79aa587..ad769f9ea 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -3147,6 +3147,7 @@ export type GQLNcmecOrgSettings = { readonly ncmecAdditionalInfoEndpoint?: Maybe; readonly ncmecPreservationEndpoint?: Maybe; readonly password: Scalars['String']['output']; + readonly reportedMediaHashBankId?: Maybe; readonly termsOfService?: Maybe; readonly username: Scalars['String']['output']; }; @@ -3167,6 +3168,7 @@ export type GQLNcmecOrgSettingsInput = { readonly ncmecAdditionalInfoEndpoint?: InputMaybe; readonly ncmecPreservationEndpoint?: InputMaybe; readonly password: Scalars['String']['input']; + readonly reportedMediaHashBankId?: InputMaybe; readonly termsOfService?: InputMaybe; readonly username: Scalars['String']['input']; }; @@ -25059,7 +25061,13 @@ export type GQLNcmecOrgSettingsQuery = { readonly contactPersonPhone?: string | null; readonly mediaReviewRequirement?: GQLNcmecMediaReviewRequirement | null; readonly minMediaToReview?: number | null; + readonly reportedMediaHashBankId?: string | null; } | null; + readonly hashBanks: ReadonlyArray<{ + readonly __typename: 'HashBank'; + readonly id: string; + readonly name: string; + }>; readonly myOrg?: { readonly __typename: 'Org'; readonly hasNCMECReportingEnabled: boolean; @@ -44311,6 +44319,11 @@ export const GQLNcmecOrgSettingsDocument = gql` contactPersonPhone mediaReviewRequirement minMediaToReview + reportedMediaHashBankId + } + hashBanks { + id + name } myOrg { hasNCMECReportingEnabled diff --git a/client/src/webpages/settings/NCMECSettings.tsx b/client/src/webpages/settings/NCMECSettings.tsx index 1fd50124c..096e2d279 100644 --- a/client/src/webpages/settings/NCMECSettings.tsx +++ b/client/src/webpages/settings/NCMECSettings.tsx @@ -48,6 +48,11 @@ gql` contactPersonPhone mediaReviewRequirement minMediaToReview + reportedMediaHashBankId + } + hashBanks { + id + name } myOrg { hasNCMECReportingEnabled @@ -83,6 +88,7 @@ type NcmecSettings = { contactPersonPhone: string; mediaReviewRequirement: GQLNcmecMediaReviewRequirement; minMediaToReview: string; + reportedMediaHashBankId: string; }; export default function NCMECSettings() { @@ -104,6 +110,7 @@ export default function NCMECSettings() { contactPersonPhone: '', mediaReviewRequirement: GQLNcmecMediaReviewRequirement.All, minMediaToReview: '1', + reportedMediaHashBankId: '', }); const { loading, error, data } = useGQLNcmecOrgSettingsQuery({ @@ -149,6 +156,8 @@ export default function NCMECSettings() { data.ncmecOrgSettings.mediaReviewRequirement ?? GQLNcmecMediaReviewRequirement.All, minMediaToReview: String(data.ncmecOrgSettings.minMediaToReview ?? 1), + reportedMediaHashBankId: + data.ncmecOrgSettings.reportedMediaHashBankId ?? '', }); } }, [data?.ncmecOrgSettings]); @@ -237,6 +246,7 @@ export default function NCMECSettings() { contactPersonPhone: settings.contactPersonPhone || null, mediaReviewRequirement: settings.mediaReviewRequirement, minMediaToReview: isMinimumPolicy ? parsedMinMedia : null, + reportedMediaHashBankId: settings.reportedMediaHashBankId || null, }, }, }); @@ -573,6 +583,43 @@ export default function NCMECSettings() { +
+ + + + Media from each report accepted by NCMEC is added to this bank so + it can be matched if it is uploaded again. Only applies when + reports are sent to the NCMEC production endpoint. + +
+