chore(moderation): remove the built-in NCMEC integration (#3332)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:39:04 +02:00
1 parent f390e610b9
commit 454fefdb92
146 files changed
+234 -4075

No files matched your search

@@ -12,9 +12,6 @@ pub struct Attachment {
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
#[derive(Debug, PartialEq)]
@@ -113,14 +110,6 @@ fn attachment_from_value(value: &Value) -> Attachment {
width: value["width"].as_u64().map(|n| n as u32),
height: value["height"].as_u64().map(|n| n as u32),
size: value["size"].as_u64(),
ncmec_status: value["ncmec_status"]
.as_str()
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
ncmec_failure_reason: value["ncmec_failure_reason"]
.as_str()
.map(ToOwned::to_owned),
}
}
@@ -168,10 +157,7 @@ mod tests {
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096,
"ncmec_status": "submitted",
"ncmec_report_id": "report-id",
"ncmec_failure_reason": "previous failure"
"size": 4096
}]
});
@@ -202,9 +188,6 @@ mod tests {
width: Some(640),
height: Some(480),
size: Some(4096),
ncmec_status: "submitted".into(),
ncmec_report_id: Some("report-id".into()),
ncmec_failure_reason: Some("previous failure".into()),
}],
missing_attachments: vec![],
}
@@ -290,9 +273,6 @@ mod tests {
width: None,
height: None,
size: None,
ncmec_status: "not_submitted".into(),
ncmec_report_id: None,
ncmec_failure_reason: None,
}]
);
}
@@ -13,47 +13,13 @@ use crate::utils::timestamps::format_admin_timestamp;
use super::message_data::{Attachment, Message};
#[derive(Clone, Copy, Debug, Default)]
pub struct MessageActions<'a> {
pub delete: bool,
pub ncmec: bool,
pub source_report_id: Option<&'a str>,
}
fn is_image(att: &Attachment) -> bool {
att.content_type
.as_deref()
.is_some_and(|ct| ct.starts_with("image/"))
}
fn ncmec_badge(att: &Attachment) -> Markup {
match att.ncmec_status.as_str() {
"submitted" => {
let label = att
.ncmec_report_id
.as_deref()
.map(|id| format!("NCMEC {id}"))
.unwrap_or_else(|| "Reported to NCMEC".into());
html! {
span class="rounded bg-green-100 px-2 py-0.5 text-[11px] text-green-800"
title=(label) { (label) }
}
}
"failed" => {
let title = att
.ncmec_failure_reason
.as_deref()
.unwrap_or("NCMEC report failed");
html! {
span class="rounded bg-red-100 px-2 py-0.5 text-[11px] text-red-800"
title=(title) { "NCMEC Failed" }
}
}
_ => html! {},
}
}
fn render_image_attachments(msg: &Message, actions: MessageActions<'_>) -> Markup {
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
if images.peek().is_none() {
return html! {};
@@ -79,47 +45,15 @@ fn render_image_attachments(msg: &Message, actions: MessageActions<'_>) -> Marku
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="font-medium text-blue-600 hover:underline" { (att.filename) }
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
(ncmec_badge(att))
}
@if actions.delete || actions.ncmec {
@if include_delete {
div class="flex flex-wrap gap-2" {
@if actions.delete {
button type="button"
class="delete-message-btn rounded bg-white px-2.5 py-1 \
text-red-600 text-xs shadow-sm ring-1 ring-neutral-200 \
transition-colors hover:bg-red-50 hover:text-red-700"
data-channel-id=(msg.channel_id)
data-message-id=(msg.id) { "Delete" }
}
@if actions.ncmec {
button type="button"
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
shadow-sm ring-1 ring-neutral-200 transition-colors \
hover:bg-neutral-100 disabled:cursor-not-allowed \
disabled:opacity-70"
data-channel-id=(msg.channel_id)
data-message-id=(msg.id)
data-attachment-id=(att.id)
data-filename=(att.filename)
data-content-type=(att.content_type.as_deref().unwrap_or(""))
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
data-author-id=(msg.author_id)
data-ncmec-status=(att.ncmec_status)
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
data-source-report-id=[actions.source_report_id]
disabled[att.ncmec_status == "submitted"]
title=(if att.ncmec_status == "submitted" {
"Already reported to NCMEC"
} else {
"Report this image to NCMEC"
}) {
@if att.ncmec_status == "submitted" {
"Reported to NCMEC"
} @else {
"Report to NCMEC"
}
}
}
button type="button"
class="delete-message-btn rounded bg-white px-2.5 py-1 \
text-red-600 text-xs shadow-sm ring-1 ring-neutral-200 \
transition-colors hover:bg-red-50 hover:text-red-700"
data-channel-id=(msg.channel_id)
data-message-id=(msg.id) { "Delete" }
}
}
}
@@ -147,7 +81,6 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="text-blue-600 hover:underline" { (att.filename) }
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
(ncmec_badge(att))
}
}
}
@@ -181,7 +114,7 @@ fn message_row(
base_path: &str,
avatar_url: &str,
msg: &Message,
actions: MessageActions<'_>,
include_delete: bool,
is_highlighted: bool,
is_grouped: bool,
) -> Markup {
@@ -213,14 +146,14 @@ fn message_row(
text-sm leading-snug" { (msg.content) }
}
@if !msg.attachments.is_empty() {
(render_image_attachments(msg, actions))
(render_image_attachments(msg, include_delete))
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
}
@if actions.delete && !has_images {
@if include_delete && !has_images {
div class="absolute top-0 right-2 hidden group-hover:block" {
button type="button"
class="delete-message-btn rounded bg-white px-2 py-0.5 \
@@ -296,7 +229,7 @@ fn message_row(
text-sm leading-snug" { (msg.content) }
}
@if !msg.attachments.is_empty() {
(render_image_attachments(msg, actions))
(render_image_attachments(msg, include_delete))
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
@@ -304,7 +237,7 @@ fn message_row(
span class="" { (msg.id) }
}
}
@if actions.delete && !has_images {
@if include_delete && !has_images {
div class="absolute top-1 right-2 hidden group-hover:block" {
button type="button"
class="delete-message-btn rounded bg-white px-2 py-0.5 \
@@ -322,7 +255,7 @@ pub fn message_list(
config: &AdminConfig,
base_path: &str,
messages: &[Message],
actions: MessageActions<'_>,
include_delete: bool,
highlight_message_id: Option<&str>,
) -> Markup {
html! {
@@ -337,7 +270,7 @@ pub fn message_list(
);
(message_row(
base_path, &avatar_url, msg,
actions, is_highlighted, is_grouped,
include_delete, is_highlighted, is_grouped,
))
}
}
@@ -385,38 +318,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
toast('error', 'Failed to delete message.');
});
}
function reportNcmec(b) {
var name = prompt('Type your full name to confirm you personally viewed this image and want to submit it to NCMEC.');
if (!name || !name.trim()) return;
var fields = new URLSearchParams();
fields.append('channel_id', b.dataset.channelId || '');
fields.append('message_id', b.dataset.messageId || '');
fields.append('attachment_id', b.dataset.attachmentId || '');
fields.append('filename', b.dataset.filename || '');
if (b.dataset.sourceReportId) fields.append('source_report_id', b.dataset.sourceReportId);
fields.append('reporter_full_name', name.trim());
fields.append('confirmed_viewed', 'true');
b.disabled = true;
b.textContent = 'Reporting...';
toast('info', 'Submitting NCMEC report...');
post('report-to-ncmec', fields).then(function(r) {
return r.json().catch(function() {
return null;
}).then(function(data) {
if (!r.ok || !data || data.success !== true) throw new Error(data && (data.error || data.message) || 'Failed to report attachment to NCMEC');
return data;
});
}).then(function(data) {
b.textContent = 'Reported to NCMEC';
b.dataset.ncmecStatus = 'submitted';
if (data.ncmec_report_id) b.dataset.ncmecReportId = data.ncmec_report_id;
toast('success', 'NCMEC report submitted.');
}).catch(function(err) {
b.disabled = false;
b.textContent = 'Report to NCMEC';
toast('error', err && err.message ? err.message : 'Failed to report attachment to NCMEC.');
});
}
document.addEventListener('click', function(e) {
var t = e.target;
if (!(t instanceof HTMLElement)) return;
@@ -424,12 +325,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
if (d instanceof HTMLButtonElement) {
e.preventDefault();
deleteMessage(d);
return;
}
var n = t.closest('.ncmec-report-btn');
if (n instanceof HTMLButtonElement && !n.disabled) {
e.preventDefault();
reportNcmec(n);
}
});
})();"#
@@ -11,7 +11,7 @@ use crate::{
submit_button,
},
message_data::{Message, ordered_messages, value_id},
message_list::{MessageActions, message_deletion_script, message_list},
message_list::{message_deletion_script, message_list},
page_container::{card, page_header},
},
layout::LayoutOptions,
@@ -193,7 +193,6 @@ pub struct MessagesPageParams<'a> {
pub prefill_channel_id: Option<&'a str>,
pub can_lookup: bool,
pub can_delete: bool,
pub can_report_ncmec: bool,
pub lookup_result: Option<&'a Value>,
pub browse_result: Option<&'a Value>,
pub search_result: Option<&'a Value>,
@@ -208,11 +207,6 @@ pub fn messages_page(
auth: &AuthContext,
params: &MessagesPageParams<'_>,
) -> Markup {
let actions = MessageActions {
delete: params.can_delete,
ncmec: params.can_report_ncmec,
source_report_id: None,
};
let content = html! {
(page_header("Message Tools", None))
@@ -223,19 +217,19 @@ pub fn messages_page(
@if let (Some(result), Some(channel_id), Some(query)) = (
params.search_result, params.browse_channel_id, params.search_query_text,
) {
(search_result_card(config, result, channel_id, query, actions))
(search_result_card(config, result, channel_id, query, params.can_delete))
}
@if params.search_result.is_none()
&& let (Some(result), Some(channel_id)) = (
params.browse_result, params.browse_channel_id,
) {
(browse_result_card(
config, result, channel_id, actions,
config, result, channel_id, params.can_delete,
params.csrf_token, params.context_limit,
))
}
@if let Some(result) = params.lookup_result {
(lookup_result_card(config, result, actions, params.context_limit))
(lookup_result_card(config, result, params.can_delete, params.context_limit))
}
@if params.can_lookup {
(browse_channel_form(config, params.csrf_token, params.prefill_channel_id))
@@ -266,7 +260,7 @@ pub fn messages_page(
pub fn browse_messages_fragment(
config: &AdminConfig,
result: &Value,
actions: MessageActions<'_>,
show_delete: bool,
highlight_message_id: Option<&str>,
) -> Markup {
let messages = response_messages(result);
@@ -286,7 +280,7 @@ pub fn browse_messages_fragment(
config,
&config.base_path,
&messages,
actions,
show_delete,
highlight_message_id,
))
}
@@ -297,7 +291,7 @@ fn browse_result_card(
config: &AdminConfig,
result: &Value,
channel_id: &str,
actions: MessageActions<'_>,
show_delete: bool,
csrf_token: &str,
context_limit: u32,
) -> Markup {
@@ -334,7 +328,7 @@ fn browse_result_card(
config,
channel_id,
messages: &messages,
actions,
show_delete,
has_more,
has_newer: false,
focus_message_id: None,
@@ -349,7 +343,7 @@ fn search_result_card(
result: &Value,
channel_id: &str,
query_text: &str,
actions: MessageActions<'_>,
show_delete: bool,
) -> Markup {
let messages = response_messages(result);
let total = result
@@ -375,7 +369,7 @@ fn search_result_card(
config,
&config.base_path,
&messages,
actions,
show_delete,
None,
))
}
@@ -385,7 +379,7 @@ fn search_result_card(
fn lookup_result_card(
config: &AdminConfig,
result: &Value,
actions: MessageActions<'_>,
show_delete: bool,
context_limit: u32,
) -> Markup {
let messages = response_messages(result);
@@ -407,7 +401,7 @@ fn lookup_result_card(
config,
channel_id,
messages: &messages,
actions,
show_delete,
has_more: true,
has_newer: true,
focus_message_id: message_id,
@@ -421,7 +415,7 @@ struct MessageScrollPane<'a> {
config: &'a AdminConfig,
channel_id: &'a str,
messages: &'a [Message],
actions: MessageActions<'a>,
show_delete: bool,
has_more: bool,
has_newer: bool,
focus_message_id: Option<&'a str>,
@@ -460,7 +454,7 @@ fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
pane.config,
&pane.config.base_path,
pane.messages,
pane.actions,
pane.show_delete,
pane.focus_message_id,
))
}
@@ -18,7 +18,7 @@ use crate::{
form::{FORM_CONTROL_CLASS, FORM_LABEL_CLASS, csrf_input, opt_out_checkbox},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{MessageActions, message_deletion_script, message_list},
message_list::{message_deletion_script, message_list},
nsfw_indicators::{
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
},
@@ -634,10 +634,14 @@ fn padded_discriminator(value: Option<&str>) -> Option<String> {
non_empty(value).map(|value| format!("{value:0>4}"))
}
fn snapshot_tag(username: Option<&str>, discriminator: Option<&str>) -> Option<String> {
fn snapshot_tag(
username: Option<&str>,
discriminator: Option<&str>,
is_bot: bool,
) -> Option<String> {
let username = non_empty(username)?;
Some(match padded_discriminator(discriminator) {
Some(discriminator) => format!("{username}#{discriminator}"),
Some(discriminator) => user_tag(username, &discriminator, is_bot),
None => username.to_owned(),
})
}
@@ -759,11 +763,16 @@ fn snapshot_user_group(
} else {
None
};
let tag = snapshot_tag(user.username.as_deref(), user.discriminator.as_deref());
let is_bot = live_user.is_some_and(|live| live.bot);
let tag = snapshot_tag(
user.username.as_deref(),
user.discriminator.as_deref(),
is_bot,
);
let tag_change = match live_user {
None => SnapshotChange::Unknown,
Some(live) => {
let live_tag = snapshot_tag(Some(&live.username), Some(&live.discriminator));
let live_tag = snapshot_tag(Some(&live.username), Some(&live.discriminator), is_bot);
if live_tag == tag {
SnapshotChange::Same
} else {
@@ -997,7 +1006,7 @@ fn nav_link(href: &str, label: &str) -> Markup {
fn message_context_section(
config: &AdminConfig,
report: &ReportEntry,
actions: MessageActions<'_>,
include_delete: bool,
csrf_token: Option<&str>,
) -> Markup {
let Some(values) = &report.message_context else {
@@ -1020,7 +1029,7 @@ fn message_context_section(
config,
&config.base_path,
&messages,
actions,
include_delete,
report.reported_message_id.as_deref(),
))
}
@@ -1031,16 +1040,6 @@ fn message_context_section(
}
}
fn report_message_actions<'a>(auth: &AuthContext, report: &'a ReportEntry) -> MessageActions<'a> {
MessageActions {
delete: has_acl(auth, acl::MESSAGE_DELETE),
ncmec: auth.admin_user.as_ref().is_some_and(|admin| {
acl::has_all_permissions(&admin.acls, acl::NCMEC_ATTACHMENT_REPORT)
}),
source_report_id: Some(&report.report_id),
}
}
pub fn report_detail_page(
config: &AdminConfig,
auth: &AuthContext,
@@ -1064,7 +1063,7 @@ pub fn report_detail_page(
(report_answers_section(report))
(reported_entity_section(config, report))
(profile_snapshot_section(config, report, live))
(message_context_section(config, report, report_message_actions(auth, report), Some(csrf_token)))
(message_context_section(config, report, has_acl(auth, acl::MESSAGE_DELETE), Some(csrf_token)))
(additional_info_section(report))
}
div class="space-y-6" {
@@ -1089,7 +1088,7 @@ pub fn report_detail_fragment(config: &AdminConfig, report: &ReportEntry) -> Mar
(basic_info_section_fragment(config, report))
(report_answers_section(report))
(reported_entity_section(config, report))
(message_context_section(config, report, MessageActions::default(), None))
(message_context_section(config, report, false, None))
(additional_info_section(report))
a href={(base) "/reports/" (&report.report_id)}
class="inline-flex min-h-[44px] w-full items-center justify-center \
@@ -1608,9 +1607,7 @@ mod tests {
#[test]
fn message_context_shows_webhook_authors_without_user_links() {
let config = test_config();
let markup =
message_context_section(&config, &webhook_report(), MessageActions::default(), None)
.into_string();
let markup = message_context_section(&config, &webhook_report(), false, None).into_string();
assert!(
markup.contains(r#"data-message-webhook="1700000000000000500""#),
"{markup}"
@@ -1668,7 +1665,6 @@ mod tests {
}))
}
const NCMEC_BUTTON: &str = r#"class="ncmec-report-btn"#;
const DELETE_BUTTON: &str = r#"class="delete-message-btn"#;
fn render_page(auth: &AuthContext, report: &ReportEntry) -> String {
@@ -1684,65 +1680,21 @@ mod tests {
}
#[test]
fn ncmec_button_sends_the_report_id_when_the_admin_has_the_acls() {
let auth = auth_with_acls(acl::NCMEC_ATTACHMENT_REPORT);
let markup = render_page(&auth, &image_report("child_safety"));
assert!(markup.contains(NCMEC_BUTTON), "{markup}");
assert!(
markup.contains(r#"data-source-report-id="1800000000000000009""#),
"{markup}"
);
assert!(
markup.contains("fields.append('source_report_id', b.dataset.sourceReportId)"),
"{markup}"
);
assert!(markup.contains(DELETE_BUTTON), "{markup}");
}
#[test]
fn ncmec_button_is_hidden_without_the_acls() {
fn message_delete_button_follows_the_acl() {
let auth = auth_with_acls(&[acl::REPORT_VIEW, acl::MESSAGE_DELETE]);
let markup = render_page(&auth, &image_report("child_safety"));
assert!(markup.contains("image.png"), "{markup}");
assert!(!markup.contains(NCMEC_BUTTON), "{markup}");
assert!(!markup.contains("data-source-report-id"), "{markup}");
assert!(markup.contains(DELETE_BUTTON), "{markup}");
for missing in acl::NCMEC_ATTACHMENT_REPORT {
let held = acl::NCMEC_ATTACHMENT_REPORT
.iter()
.copied()
.filter(|acl| acl != missing)
.collect::<Vec<_>>();
let markup = render_page(&auth_with_acls(&held), &image_report("child_safety"));
assert!(!markup.contains(NCMEC_BUTTON), "{missing}\n{markup}");
assert_eq!(
markup.contains(DELETE_BUTTON),
*missing != acl::MESSAGE_DELETE,
"{missing}\n{markup}"
);
}
let markup = render_page(&auth_with_acls(&[acl::WILDCARD]), &image_report("spam"));
assert!(markup.contains(DELETE_BUTTON), "{markup}");
let markup = render_page(&auth_with_acls(&[acl::REPORT_VIEW]), &image_report("spam"));
assert!(!markup.contains(NCMEC_BUTTON), "{markup}");
assert!(markup.contains("image.png"), "{markup}");
assert!(!markup.contains(DELETE_BUTTON), "{markup}");
let markup = render_page(&test_auth(), &image_report("child_safety"));
assert!(!markup.contains(NCMEC_BUTTON), "{markup}");
}
#[test]
fn ncmec_button_follows_the_acls_not_the_report_category() {
let auth = auth_with_acls(acl::NCMEC_ATTACHMENT_REPORT);
let markup = render_page(&auth, &image_report("spam"));
assert!(markup.contains(NCMEC_BUTTON), "{markup}");
assert!(
markup.contains(r#"data-source-report-id="1800000000000000009""#),
"{markup}"
);
let markup = render_page(&auth_with_acls(&[acl::WILDCARD]), &image_report("spam"));
assert!(markup.contains(NCMEC_BUTTON), "{markup}");
assert!(!markup.contains(DELETE_BUTTON), "{markup}");
}
#[test]
@@ -1750,7 +1702,6 @@ mod tests {
let markup =
report_detail_fragment(&test_config(), &image_report("child_safety")).into_string();
assert!(markup.contains("image.png"), "{markup}");
assert!(!markup.contains(NCMEC_BUTTON), "{markup}");
assert!(!markup.contains(DELETE_BUTTON), "{markup}");
}
@@ -1903,13 +1854,8 @@ mod tests {
#[test]
fn message_context_marks_bot_authors_only() {
let markup = message_context_section(
&test_config(),
&context_report(),
MessageActions::default(),
None,
)
.into_string();
let markup =
message_context_section(&test_config(), &context_report(), false, None).into_string();
assert!(
markup.contains(r#"data-message-author-bot="1700000000000000900""#),
"{markup}"
@@ -1952,9 +1898,7 @@ mod tests {
message("1700000000000000590", "Joined", "2026-10-05T16:45:00.000Z")
]
}));
let markup =
message_context_section(&test_config(), &grouped, MessageActions::default(), None)
.into_string();
let markup = message_context_section(&test_config(), &grouped, false, None).into_string();
assert!(markup.contains("Oct 5, 2026, 4:45 PM UTC"), "{markup}");
assert!(!markup.contains("2026-10-05T16:4"), "{markup}");
assert!(
@@ -1973,13 +1917,8 @@ mod tests {
#[test]
fn message_context_names_attachments_that_were_not_preserved() {
let markup = message_context_section(
&test_config(),
&context_report(),
MessageActions::default(),
None,
)
.into_string();
let markup =
message_context_section(&test_config(), &context_report(), false, None).into_string();
assert!(
markup.contains(r#"data-missing-attachment="1700000000000000610""#),
"{markup}"
@@ -1998,13 +1937,8 @@ mod tests {
fragment.contains("harbor-notes.pdf was not preserved in the report snapshot"),
"{fragment}"
);
let markup = message_context_section(
&test_config(),
&image_report("spam"),
MessageActions::default(),
None,
)
.into_string();
let markup = message_context_section(&test_config(), &image_report("spam"), false, None)
.into_string();
assert!(!markup.contains("data-missing-attachment"), "{markup}");
assert!(!markup.contains("data-message-author-bot"), "{markup}");
}