Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fdc0603
[196] Send HMA bank metadata in the request body
maarkN Sep 15, 2026
2246834
[196] Add a hash bank setting for reported NCMEC media
maarkN Sep 15, 2026
dda7dbb
[196] Add reported media to the hash bank once NCMEC accepts a report
maarkN Sep 15, 2026
e3047ca
Merge branch 'roostorg:main' into maarkn/196-add-reported-images-to-h…
maarkN Sep 15, 2026
7f31f62
[196] Make HMA URL assertions independent of the base URL
maarkN Sep 16, 2026
6fe382f
[196] Add CHANGELOG entry
maarkN Sep 17, 2026
14434a0
[196] Support HMA notes when adding content to a bank
maarkN Sep 17, 2026
9fd4122
[196] Check hash bank ownership in the resolver
maarkN Sep 17, 2026
0a83309
[196] Only bank reported media when the org has a bank selected
maarkN Sep 17, 2026
4fb0e40
[196] Bank reported media the same way as the preservation request
maarkN Sep 17, 2026
67d0a78
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 17, 2026
1e3e14b
[196] Address review feedback
maarkN Sep 17, 2026
05ccced
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 17, 2026
e5f1225
[196] Use the hash bank read before submitting the report
maarkN Sep 17, 2026
4422e09
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 17, 2026
3cc0687
[196] Bank reported media in its own BullMQ worker
maarkN Sep 19, 2026
3a241e7
[196] Log BullMQ worker errors and tighten the banking tests
maarkN Sep 19, 2026
f4edc7a
[196] Remove implemementation details from NCMEC docs
maarkN Sep 23, 2026
1e49f86
[196] Cut a long HMA note instead of failing the add
maarkN Sep 23, 2026
31d310a
[196] Truncate HMA note to 255 to avoid errors
maarkN Sep 23, 2026
26e5056
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 23, 2026
a28f240
[196] Take the reported media hash bank id as Int
maarkN Sep 23, 2026
34b8ed6
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 28, 2026
65aad73
Merge branch 'main' into maarkn/196-add-reported-images-to-hma-bank
maarkN Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ For more information about each release including git tags and artifacts, see [R

### Added

- Setting to add media from accepted NCMEC reports to a hash bank ([#1208](https://github.com/roostorg/coop/pull/1208) by [@maarkN](https://github.com/maarkN), closes [#196](https://github.com/roostorg/coop/issues/196))
- Optional policy and audit callbacks with bounded deadlines for review and item content responses ([#1270](https://github.com/roostorg/coop/pull/1270) by [@sunilatlas](https://github.com/sunilatlas), closes [#1269](https://github.com/roostorg/coop/issues/1269))
- Native OpenTelemetry manual-review counters, decision-source labels and elapsed timings ([#1286](https://github.com/roostorg/coop/pull/1286) by [@sunilatlas](https://github.com/sunilatlas), relates to [#1287](https://github.com/roostorg/coop/issues/1287))

Expand Down
13 changes: 13 additions & 0 deletions client/src/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 49 additions & 0 deletions client/src/webpages/settings/NCMECSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ gql`
contactPersonPhone
mediaReviewRequirement
minMediaToReview
reportedMediaHashBankId
}
hashBanks {
id
name
}
myOrg {
hasNCMECReportingEnabled
Expand Down Expand Up @@ -83,6 +88,7 @@ type NcmecSettings = {
contactPersonPhone: string;
mediaReviewRequirement: GQLNcmecMediaReviewRequirement;
minMediaToReview: string;
reportedMediaHashBankId: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as in another comment -- shouldn't this be a number?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value sent to the API is a number now, but this one is string because the Select. So I keep it as a string here and convert on submit like minMediaToReview above works the same way.

};

export default function NCMECSettings() {
Expand All @@ -104,6 +110,7 @@ export default function NCMECSettings() {
contactPersonPhone: '',
mediaReviewRequirement: GQLNcmecMediaReviewRequirement.All,
minMediaToReview: '1',
reportedMediaHashBankId: '',
});

const { loading, error, data } = useGQLNcmecOrgSettingsQuery({
Expand Down Expand Up @@ -149,6 +156,8 @@ export default function NCMECSettings() {
data.ncmecOrgSettings.mediaReviewRequirement ??
GQLNcmecMediaReviewRequirement.All,
minMediaToReview: String(data.ncmecOrgSettings.minMediaToReview ?? 1),
reportedMediaHashBankId:
data.ncmecOrgSettings.reportedMediaHashBankId?.toString() ?? '',
});
}
}, [data?.ncmecOrgSettings]);
Expand Down Expand Up @@ -237,6 +246,9 @@ export default function NCMECSettings() {
contactPersonPhone: settings.contactPersonPhone || null,
mediaReviewRequirement: settings.mediaReviewRequirement,
minMediaToReview: isMinimumPolicy ? parsedMinMedia : null,
reportedMediaHashBankId: settings.reportedMediaHashBankId
? Number(settings.reportedMediaHashBankId)
: null,
},
},
});
Expand Down Expand Up @@ -573,6 +585,43 @@ export default function NCMECSettings() {
</Text>
</div>

<div className="flex flex-col gap-2">
<Label
htmlFor="reportedMediaHashBankId"
className="text-sm font-medium"
>
Hash bank for reported media
</Label>
<Select
value={settings.reportedMediaHashBankId || '__none__'}
onValueChange={(value) =>
setSettings({
...settings,
reportedMediaHashBankId: value === '__none__' ? '' : value,
})
}
>
<SelectTrigger id="reportedMediaHashBankId">
<SelectValue placeholder="Don't add reported media to a bank" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
Don&apos;t add reported media to a bank
</SelectItem>
{(data?.hashBanks ?? []).map((bank) => (
<SelectItem key={bank.id} value={bank.id}>
{bank.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Text size="XS" className="text-gray-500">
Media from each report accepted by NCMEC is added to this bank so

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be a bit off -- should it be "Media from each report sent to NCMEC"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left it as "accepted," since that is the moment the media is stored, nothing is added if NCMEC rejects the report. This also aligns with the documentation you suggested in docs/integrations/ncmec.md, which states "Media from each CyberTip accepted by NCMEC", do you think that makes sense @taobojlen?

it can be matched if it is uploaded again. Only applies when
reports are sent to the NCMEC production endpoint.
</Text>
</div>

<div className="flex flex-col gap-2">
<Label
htmlFor="defaultInternetDetailType"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
ALTER TABLE ncmec_reporting.ncmec_org_settings
ADD COLUMN IF NOT EXISTS reported_media_hash_bank_id integer NULL;

DO $$
BEGIN
ALTER TABLE ncmec_reporting.ncmec_org_settings
ADD CONSTRAINT ncmec_org_settings_reported_media_hash_bank_fkey
FOREIGN KEY (reported_media_hash_bank_id)
REFERENCES public.hash_banks(id)
ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.reported_media_hash_bank_id IS
'When set, media from accepted production NCMEC reports is added to this hash bank.';
8 changes: 8 additions & 0 deletions docs/development/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ npm run runWorkerOrJob ItemProcessingWorker

Without this running, submitted items will be enqueued in Redis but not processed. Other available workers/jobs can be found in `server/iocContainer/services/workersAndJobs.ts`.

Media from accepted NCMEC reports is added to the configured hash bank by
another worker, which you only need when working on that flow:

```sh
cd server
npm run runWorkerOrJob ReportedMediaBankingWorker
```

To preview emails locally without configuring SES or SendGrid, add the
following to `server/.env`. The recipient, subject, and rendered content will
be printed in the server terminal. This transport requires
Expand Down
1 change: 1 addition & 0 deletions docs/integrations/ncmec.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Configure NCMEC reporting under **Settings** → **NCMEC Settings**.
| **Contact Person (for law enforcement)** | A contact person law enforcement can reach (other than the reporting contact email): first name, last name, email, phone. |
| **More Info URL** | URL for additional information about your reporting process (e.g. `https://yourcompany.com/ncmec-info`). Used as the web page URL when "Default internet detail type" is set to "Web page." |
| **Default NCMEC Queue** | When reviewers click "Enqueue to NCMEC," jobs are sent to this queue. Leave as "Use org default queue" to fall back to the organization's default queue. |
| **Hash Bank for Reported Media** | Media from each CyberTip accepted by NCMEC is added to this hash bank so it can be matched if it is uploaded again. Only applies to [production submissions](#test-vs-production-submissions). |
| **Default Internet Detail Type** | The incident context (channel/medium) included in every CyberTip: Web page, Email, Newsgroup, Chat/IM, Online gaming, Cell phone, Non-internet, or Peer-to-peer. |
| **NCMEC Additional Info Endpoint** | Webhook URL Coop calls before submitting a CyberTip to fetch enriched user and media metadata. See [Additional Info Endpoint](#additional-info-endpoint) below. Strongly recommended as without it, CyberTips are submitted with minimal user data. |
| **NCMEC Preservation Endpoint** | Webhook URL Coop calls after a successful CyberTip submission with the report ID. See [Preservation Endpoint](#preservation-endpoint) below. |
Expand Down
7 changes: 7 additions & 0 deletions server/graphql/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions server/graphql/modules/ncmec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const typeDefs = /* GraphQL */ `
contactPersonPhone: String
mediaReviewRequirement: NcmecMediaReviewRequirement
minMediaToReview: Int
reportedMediaHashBankId: Int
}

input NcmecOrgSettingsInput {
Expand All @@ -99,6 +100,7 @@ const typeDefs = /* GraphQL */ `
contactPersonPhone: String
mediaReviewRequirement: NcmecMediaReviewRequirement
minMediaToReview: Int
reportedMediaHashBankId: Int
}

type UpdateNcmecOrgSettingsResponse {
Expand Down Expand Up @@ -349,6 +351,19 @@ const Mutation: GQLMutationResolvers = {
const { mediaReviewRequirement, minMediaToReview } =
parseMediaReviewPolicy(input);

const reportedMediaHashBankId = input.reportedMediaHashBankId ?? null;

if (reportedMediaHashBankId !== null) {
const bank = await context.services.HMAHashBankService.getBankById(
user.orgId,
reportedMediaHashBankId,
);

if (!bank) {
throw userInputError('Selected hash bank was not found.');
}
}

await context.services.NcmecService.updateNcmecOrgSettings({
orgId: user.orgId,
username,
Expand All @@ -368,6 +383,7 @@ const Mutation: GQLMutationResolvers = {
contactPersonPhone: input.contactPersonPhone ?? null,
mediaReviewRequirement,
minMediaToReview,
reportedMediaHashBankId,
});

return { success: true };
Expand Down
1 change: 1 addition & 0 deletions server/graphql/modules/ncmecOrgSettingsValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type NcmecOrgSettingsInputShape = {
contactPersonPhone?: string | null;
mediaReviewRequirement?: string | null;
minMediaToReview?: number | null;
reportedMediaHashBankId?: number | null;
};

const VALID_NCMEC_MEDIA_REVIEW_REQUIREMENTS = ['ALL', 'MINIMUM'] as const;
Expand Down
12 changes: 12 additions & 0 deletions server/iocContainer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ import {
makeItemSubmissionBulkWrite,
type ItemSubmissionBulkWrite,
} from '../queues/itemSubmissionQueue.js';
import {
makeReportedMediaBankingEnqueue,
type ReportedMediaBankingEnqueue,
} from '../queues/reportedMediaBankingQueue.js';
import makeActionPublisher, {
type ActionPublisher,
type ActionTargetItem,
Expand Down Expand Up @@ -348,6 +352,8 @@ export interface Dependencies {

itemSubmissionQueueBulkWrite: ItemSubmissionBulkWrite;
itemSubmissionRetryQueueBulkWrite: ItemSubmissionBulkWrite;
/** Enqueues media of accepted NCMEC reports for hash banking. */
reportedMediaBankingEnqueue: ReportedMediaBankingEnqueue;
IORedis: IORedis.Redis | Cluster;
/**
* Dedicated ioredis client for the items-async enqueue path. Same Redis
Expand Down Expand Up @@ -740,6 +746,11 @@ export default async function getBottle(
bottle.factory('itemSubmissionRetryQueueBulkWrite', (container) =>
makeItemSubmissionBulkWrite(container.IORedis, ITEM_SUBMISSION_DLQ_NAME),
);
// Enqueued from the NCMEC submission path, so a Redis outage has to fail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not sure i follow this comment! can you explain what the intent is in case of a redis outage?

IMO, we should not add special handling for postgres or redis downtime. those two services are so fundamental to how coop works, we can safely assume they're up or everything breaks anyway.

// fast instead of buffering and holding up an accepted report.
bottle.factory('reportedMediaBankingEnqueue', (container) =>
makeReportedMediaBankingEnqueue(container.IORedisEnqueueNoBuffer),
);

// Loggers
register(bottle, 'RuleExecutionLogger', makeRuleExecutionLogger);
Expand Down Expand Up @@ -1745,6 +1756,7 @@ export default async function getBottle(
'Scylla',
'itemSubmissionQueueBulkWrite',
'itemSubmissionRetryQueueBulkWrite',
'reportedMediaBankingEnqueue',
'IORedis',
'IORedisEnqueueNoBuffer',
// Storage abstractions
Expand Down
3 changes: 3 additions & 0 deletions server/iocContainer/services/workersAndJobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../../workers_jobs/index.js';
import makeItemProcessingWorker from '../../workers_jobs/ItemProcessingWorker.js';
import makeRefreshMRTDecisionsMaterializedViewJob from '../../workers_jobs/RefreshMRTDecisionsMaterializedViewJob.js';
import makeReportedMediaBankingWorker from '../../workers_jobs/ReportedMediaBankingWorker.js';
import makeRetryFailedNcmecDecisionsJob from '../../workers_jobs/RetryFailedNcmecDecisionsJob.js';
import makeRunUserRulesJob from '../../workers_jobs/RunUserRulesJob.js';
import { type Dependencies } from '../index.js';
Expand All @@ -19,6 +20,7 @@ declare module '../index.js' {
// NB: worker deps cannot be renamed
// w/o breaking the deployment that starts them!
ItemProcessingWorker: Worker;
ReportedMediaBankingWorker: Worker;

// Jobs. Like workers, can't be renamed w/o breaking stuff.
// The distinction between jobs and workers is that workers run continuously,
Expand All @@ -34,6 +36,7 @@ declare module '../index.js' {

const workerAndJobFactories = {
ItemProcessingWorker: makeItemProcessingWorker,
ReportedMediaBankingWorker: makeReportedMediaBankingWorker,
RunUserRulesJob: makeRunUserRulesJob,
RefreshMRTDecisionsMaterializedViewJob:
makeRefreshMRTDecisionsMaterializedViewJob,
Expand Down
Loading
Loading