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

-1
View File
@@ -112,7 +112,6 @@ FLUXER_SEARCH_USERNAME=
FLUXER_SEARCH_PASSWORD=
FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=false
FLUXER_STRIPE_ENABLED=false
FLUXER_NCMEC_ENABLED=false
FLUXER_CLAMAV_ENABLED=false
FLUXER_DISCOVERY_ENABLED=true
FLUXER_SELF_HOSTED=true
-8
View File
@@ -142,14 +142,6 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_VISIONARIES_GUILD_ID=
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
# NCMEC CyberTipline reporting, off by default. All four values are required
# once it is on. The values below are examples.
#FLUXER_NCMEC_ENABLED=true
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
#FLUXER_NCMEC_USERNAME=
#FLUXER_NCMEC_PASSWORD=
#[email protected]
# Upload virus scanning, off by default. No ClamAV container ships, so point
# this at your own. The values below are examples.
#FLUXER_CLAMAV_ENABLED=true
-5
View File
@@ -132,11 +132,6 @@ x-fluxer-env: &fluxer-env
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
FLUXER_NCMEC_ENABLED: ${FLUXER_NCMEC_ENABLED:-}
FLUXER_NCMEC_BASE_URL: ${FLUXER_NCMEC_BASE_URL:-}
FLUXER_NCMEC_USERNAME: ${FLUXER_NCMEC_USERNAME:-}
FLUXER_NCMEC_PASSWORD: ${FLUXER_NCMEC_PASSWORD:-}
FLUXER_NCMEC_REPORTER_EMAIL: ${FLUXER_NCMEC_REPORTER_EMAIL:-}
FLUXER_CLAMAV_ENABLED: ${FLUXER_CLAMAV_ENABLED:-}
FLUXER_CLAMAV_HOST: ${FLUXER_CLAMAV_HOST:-}
FLUXER_CLAMAV_PORT: ${FLUXER_CLAMAV_PORT:-}
+11 -161
View File
@@ -4883,65 +4883,6 @@
]
}
},
"/admin/messages/ncmec-reports": {
"post": {
"operationId": "create_admin_ncmec_report",
"summary": "Report an attachment to NCMEC",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/NcmecAttachmentSubmitResultResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Submits a specific image attachment to NCMEC, creates an audit log entry, silently disables the user, triggers one archive for the user, and schedules content deletion after the archive completes.",
"security": [{"adminApiKey": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ReportAttachmentToNcmecRequest"}}}
}
}
},
"/admin/messages/shreds/{job_id}": {
"get": {
"operationId": "get_admin_message_shred",
@@ -10280,7 +10221,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -10591,37 +10532,9 @@
"content_type": {"nullable": true, "type": "string"},
"width": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"height": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"size": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}]
},
"ncmec_status": {
"description": "NCMEC submission status",
"x-enumNames": ["not_submitted", "submitting", "submitted", "failed"],
"x-enumDescriptions": [
"Report has not been submitted to NCMEC",
"Report submission to NCMEC is in progress",
"Report has been submitted to NCMEC",
"Report submission to NCMEC failed"
],
"enum": ["not_submitted", "submitting", "submitted", "failed"],
"type": "string"
},
"ncmec_report_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 256},
"ncmec_failure_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000}
"size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}]}
},
"required": [
"id",
"filename",
"url",
"nsfw",
"content_type",
"width",
"height",
"ncmec_status",
"ncmec_report_id",
"ncmec_failure_reason"
],
"required": ["id", "filename", "url", "nsfw", "content_type", "width", "height"],
"additionalProperties": false
}
},
@@ -10653,11 +10566,6 @@
"required": ["id", "filename", "nsfw", "content_type", "width", "height", "size"],
"additionalProperties": false
}
},
"user_prior_ncmec_report_ids": {
"maxItems": 100,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"required": [
@@ -11004,29 +10912,6 @@
}
]
},
"ReportAttachmentToNcmecRequest": {
"type": "object",
"properties": {
"channel_id": {"$ref": "#/components/schemas/SnowflakeType"},
"message_id": {"$ref": "#/components/schemas/SnowflakeType"},
"attachment_id": {"$ref": "#/components/schemas/SnowflakeType"},
"filename": {"type": "string", "minLength": 1, "maxLength": 255},
"source_report_id": {"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"reporter_full_name": {"type": "string", "minLength": 1, "maxLength": 200},
"confirmed_viewed": {"type": "boolean", "enum": [true]}
},
"required": ["channel_id", "message_id", "attachment_id", "filename", "reporter_full_name", "confirmed_viewed"]
},
"NcmecAttachmentSubmitResultResponse": {
"type": "object",
"properties": {
"success": {"type": "boolean", "enum": [true]},
"ncmec_report_id": {"type": "string", "minLength": 1, "maxLength": 256},
"audit_log_reason": {"type": "string", "minLength": 1, "maxLength": 4000}
},
"required": ["success", "ncmec_report_id", "audit_log_reason"],
"additionalProperties": false
},
"AdminMessageSearchResponse": {
"anyOf": [
{
@@ -13514,7 +13399,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -13532,7 +13417,7 @@
"type": "string"
},
"acls": {
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -13562,7 +13447,7 @@
"maximum": 365
},
"acls": {
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -13583,7 +13468,7 @@
"type": "string"
},
"acls": {
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -13596,7 +13481,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 106,
"maxItems": 105,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -13651,7 +13536,6 @@
"bulk:delete:user_messages",
"bulk:update:guild_features",
"bulk:update:user_flags",
"csam:submit_ncmec",
"discovery:remove",
"discovery:review",
"gateway:memory_stats",
@@ -14599,41 +14483,11 @@
"content_type": {"nullable": true, "type": "string"},
"width": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"height": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}]},
"ncmec_status": {
"description": "NCMEC submission status",
"x-enumNames": ["not_submitted", "submitting", "submitted", "failed"],
"x-enumDescriptions": [
"Report has not been submitted to NCMEC",
"Report submission to NCMEC is in progress",
"Report has been submitted to NCMEC",
"Report submission to NCMEC failed"
],
"enum": ["not_submitted", "submitting", "submitted", "failed"],
"type": "string"
},
"ncmec_report_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 256},
"ncmec_failure_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000}
"size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}]}
},
"required": [
"id",
"filename",
"url",
"nsfw",
"content_type",
"width",
"height",
"ncmec_status",
"ncmec_report_id",
"ncmec_failure_reason"
],
"required": ["id", "filename", "url", "nsfw", "content_type", "width", "height"],
"additionalProperties": false
}
},
"user_prior_ncmec_report_ids": {
"maxItems": 100,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 256}
}
},
"required": [
@@ -16766,7 +16620,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
@@ -17350,8 +17204,6 @@
"MAX_THREAD_MEMBERS",
"MAX_WEBHOOKS_PER_CHANNEL",
"MAX_WEBHOOKS_PER_GUILD",
"NCMEC_ALREADY_SUBMITTED",
"NCMEC_SUBMISSION_FAILED",
"MEDIA_METADATA_ERROR",
"METHOD_NOT_ALLOWED",
"MISSING_ACCESS",
@@ -17647,8 +17499,6 @@
"This thread has reached its member limit of {count}.",
"You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.",
"You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.",
"This content has already been submitted to NCMEC.",
"We couldn't submit the report to NCMEC. Please try again later.",
"Media metadata error.",
"Method not allowed.",
"You don't have access to this resource or feature.",
-41
View File
@@ -43,7 +43,6 @@ pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
pub const CSAM_SUBMIT_NCMEC: &str = "csam:submit_ncmec";
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
pub const DISCOVERY_REVIEW: &str = "discovery:review";
pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats";
@@ -150,7 +149,6 @@ pub const ALL_ACLS: &[&str] = &[
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_USER_FLAGS,
CSAM_SUBMIT_NCMEC,
DISCOVERY_REMOVE,
DISCOVERY_REVIEW,
GATEWAY_MEMORY_STATS,
@@ -215,13 +213,6 @@ pub const ALL_ACLS: &[&str] = &[
VOICE_SERVER_UPDATE,
];
pub const NCMEC_ATTACHMENT_REPORT: &[&str] = &[
CSAM_SUBMIT_NCMEC,
MESSAGE_DELETE,
USER_DELETE,
ARCHIVE_TRIGGER_USER,
];
pub fn has_permission(admin_acls: &[String], required: &str) -> bool {
let required_alias = acl_key_alias(required);
admin_acls.iter().any(|acl| {
@@ -236,10 +227,6 @@ pub fn has_any_permission(admin_acls: &[String], required: &[&str]) -> bool {
required.iter().any(|acl| has_permission(admin_acls, acl))
}
pub fn has_all_permissions(admin_acls: &[String], required: &[&str]) -> bool {
required.iter().all(|acl| has_permission(admin_acls, acl))
}
fn acl_key_alias(acl: &str) -> String {
acl.replace(':', "_").to_ascii_uppercase()
}
@@ -293,34 +280,6 @@ mod tests {
assert!(has_any_permission(&acls(&[]), &[]));
}
#[test]
fn has_all_permissions_needs_every_acl() {
assert!(has_all_permissions(&acls(&[USER_LOOKUP]), &[]));
assert!(has_all_permissions(
&acls(&[USER_LOOKUP, GUILD_LOOKUP]),
&[GUILD_LOOKUP, USER_LOOKUP]
));
assert!(!has_all_permissions(
&acls(&[USER_LOOKUP]),
&[GUILD_LOOKUP, USER_LOOKUP]
));
assert!(has_all_permissions(
&acls(&[WILDCARD]),
NCMEC_ATTACHMENT_REPORT
));
for missing in NCMEC_ATTACHMENT_REPORT {
let held = NCMEC_ATTACHMENT_REPORT
.iter()
.copied()
.filter(|acl| acl != missing)
.collect::<Vec<_>>();
assert!(
!has_all_permissions(&acls(&held), NCMEC_ATTACHMENT_REPORT),
"{missing}"
);
}
}
#[test]
fn has_any_permission_none_match() {
let user_acls = acls(&[USER_LOOKUP]);
+3 -3
View File
@@ -337,9 +337,9 @@ fn guild_update_response(
.map_err(ApiError::Parse)?,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from),
content_warning_text: guild.content_warning_text.map(String::from),
nsfw: None,
content_warning_level: None,
content_warning_text: None,
description: None,
vanity_url_code: None,
},
+1 -33
View File
@@ -5,8 +5,7 @@ use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
MessageShredResponse, MessageShredStatusResponse, NcmecAttachmentSubmitResult,
SearchChannelMessagesResponse,
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
};
impl AdminApiClient {
@@ -30,37 +29,6 @@ impl AdminApiClient {
Ok(())
}
pub async fn report_attachment_to_ncmec(
&self,
channel_id: &str,
message_id: &str,
attachment_id: &str,
filename: &str,
reporter_full_name: &str,
source_report_id: Option<&str>,
) -> ApiResult<NcmecAttachmentSubmitResult> {
let body = generated_types::ReportAttachmentToNcmecRequest {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
confirmed_viewed: true,
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?,
message_id: snowflake(message_id),
reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
reporter_full_name,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
source_report_id: source_report_id.map(snowflake),
};
let response = self
.generated()
.create_admin_ncmec_report(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn lookup_message(
&self,
channel_id: &str,
-6
View File
@@ -34,12 +34,6 @@ pub struct DeleteAllUserMessagesResponse {
pub extra: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NcmecAttachmentSubmitResult {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BrowseChannelResponse {
#[serde(flatten)]
+2 -46
View File
@@ -8,7 +8,6 @@ use crate::{
flash::{self, FlashData},
},
state::AppState,
templates::components::message_list::MessageActions,
utils::forms::MultiValueForm,
};
use axum::{
@@ -106,45 +105,6 @@ pub(crate) async fn messages_post(
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
}
}
"report-to-ncmec" => {
let attachment_id = form.clean("attachment_id");
let filename = form.clean("filename");
let reporter_full_name = form.clean("reporter_full_name");
let source_report_id = form.clean("source_report_id");
let confirmed_viewed = form.bool_value("confirmed_viewed");
let (Some(cid), Some(mid), Some(aid), Some(name), Some(reporter)) = (
&channel_id,
&message_id,
&attachment_id,
&filename,
&reporter_full_name,
) else {
return json_error(
StatusCode::BAD_REQUEST,
"Missing required NCMEC report fields",
);
};
if !confirmed_viewed {
return json_error(
StatusCode::BAD_REQUEST,
"Missing required NCMEC report fields",
);
}
match client
.report_attachment_to_ncmec(
cid,
mid,
aid,
name,
reporter,
source_report_id.as_deref(),
)
.await
{
Ok(resp) => Json(resp.data).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
}
}
_ => Redirect::to(&format!("{base}/messages")).into_response(),
}
}
@@ -322,11 +282,7 @@ pub(crate) async fn messages_browse_fragment(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let actions = MessageActions {
delete: acl::has_permission(admin_acls, acl::MESSAGE_DELETE),
ncmec: acl::has_all_permissions(admin_acls, acl::NCMEC_ATTACHMENT_REPORT),
source_report_id: None,
};
let can_delete = acl::has_permission(admin_acls, acl::MESSAGE_DELETE);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let result = client
.browse_channel(
@@ -339,7 +295,7 @@ pub(crate) async fn messages_browse_fragment(
match result {
Ok(resp) => {
let markup = crate::templates::pages::messages_page::browse_messages_fragment(
config, &resp.data, actions, None,
config, &resp.data, can_delete, None,
);
Html(markup.into_string()).into_response()
}
-2
View File
@@ -73,7 +73,6 @@ async fn messages_page(
.unwrap_or(&[]);
let can_lookup = acl::has_permission(admin_acls, acl::MESSAGE_LOOKUP);
let can_delete = acl::has_permission(admin_acls, acl::MESSAGE_DELETE);
let can_report_ncmec = acl::has_all_permissions(admin_acls, acl::NCMEC_ATTACHMENT_REPORT);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let channel_id = query.channel_id.as_deref().filter(|s| !s.is_empty());
let message_id = query.message_id.as_deref().filter(|s| !s.is_empty());
@@ -138,7 +137,6 @@ async fn messages_page(
prefill_channel_id: channel_id,
can_lookup,
can_delete,
can_report_ncmec,
lookup_result: lookup_result.as_ref(),
browse_result: browse_result.as_ref(),
search_result: search_result.as_ref(),
@@ -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}");
}
+33 -8
View File
@@ -957,8 +957,7 @@ fn deserialize_webhook_message_report() {
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": "a_1f2e3d",
"webhook_id": "1556008115710000001",
"user_prior_ncmec_report_ids": []
"webhook_id": "1556008115710000001"
},
{
"id": "1556008115697091780",
@@ -974,8 +973,7 @@ fn deserialize_webhook_message_report() {
"author_global_name": null,
"author_discriminator": "6741",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
}
]
}));
@@ -1164,8 +1162,7 @@ fn evidence_report_json() -> serde_json::Value {
"width": 640,
"height": 480,
"size": 4096
}],
"user_prior_ncmec_report_ids": []
}]
},
{
"id": "1556008115697091780",
@@ -1183,8 +1180,7 @@ fn evidence_report_json() -> serde_json::Value {
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
}
]
}))
@@ -1242,6 +1238,35 @@ fn deserialize_report_with_bot_flags_profile_snapshot_and_missing_attachments()
}
}
#[test]
fn deserialize_report_context_with_unknown_message_and_attachment_keys() {
let mut json = evidence_report_json();
json["message_context"][1]["retired_list"] = serde_json::json!([]);
json["message_context"][1]["attachments"] = serde_json::json!([{
"id": "1556008115697091791",
"url": "https://reports.example.test/attachments/image.png?sig=1",
"filename": "image.png",
"nsfw": false,
"content_type": "image/png",
"width": 64,
"height": 64,
"size": 4096,
"retired_status": "not_submitted",
"retired_id": null
}]);
let generated: generated_types::ReportAdminResponseSchema =
serde_json::from_value(json).expect("generated report type");
let report: types::ReportEntry =
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
.expect("hand-written report type from generated");
let context = report.message_context.as_ref().expect("message context");
assert_eq!(context[1]["attachments"][0]["filename"], "image.png");
assert_eq!(
context[1]["attachments"][0]["url"],
"https://reports.example.test/attachments/image.png?sig=1"
);
}
#[test]
fn deserialize_report_without_bot_flags_snapshot_or_hold() {
let nulls = report_json(serde_json::json!({
+9 -89
View File
@@ -429,79 +429,13 @@ async fn report_detail_renders_unknown_status_and_type_values() {
}
#[tokio::test]
async fn ncmec_submissions_forward_the_source_report_id() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/messages/ncmec-reports",
routing::post(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body);
json_response(json!({
"success": true,
"ncmec_report_id": "ncmec-1",
"audit_log_reason": "Submitted"
}))
}
}),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, body) = get_with_headers(&app, "/messages", &[]).await;
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("messages page did not set csrf_token cookie\n{body}"));
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let fields = "channel_id=1600000000000000101&message_id=1800000000000001001\
&attachment_id=1800000000000001002&filename=image.png\
&reporter_full_name=Casey%20Admin&confirmed_viewed=true";
for form in [
format!("_csrf={csrf_token}&{fields}&source_report_id=1800000000000000009"),
format!("_csrf={csrf_token}&{fields}"),
] {
let (status, _, response) = post_form_with_headers(
&app,
"/messages?action=report-to-ncmec",
&[("Cookie", &cookie), ("x-csrf-token", &csrf_token)],
&form,
)
.await;
assert_eq!(status, StatusCode::OK, "{response}");
assert!(response.contains("ncmec-1"), "{response}");
}
let received = received.lock().unwrap();
assert_eq!(received.len(), 2, "{received:?}");
assert_eq!(received[0]["source_report_id"], "1800000000000000009");
assert_eq!(received[0]["attachment_id"], "1800000000000001002");
assert_eq!(received[0]["confirmed_viewed"], true);
assert!(
received[1]
.get("source_report_id")
.is_none_or(Value::is_null),
"{received:?}"
);
}
#[tokio::test]
async fn message_tools_offer_ncmec_only_with_every_required_acl() {
let full = fluxer_admin::acl::NCMEC_ATTACHMENT_REPORT;
let cases: [(&[&str], bool, bool); 4] = [
(full, true, true),
(&["*"], true, true),
(&["csam:submit_ncmec", "message:lookup"], false, false),
(
&[
"csam:submit_ncmec",
"message:delete",
"message:lookup",
"user:delete",
],
false,
true,
),
async fn message_tools_offer_delete_only_with_the_acl() {
let cases: [(&[&str], bool); 3] = [
(&["*"], true),
(&["message:lookup"], false),
(&["message:delete", "message:lookup"], true),
];
for (acls, ncmec, delete) in cases {
for (acls, delete) in cases {
let app = setup_with_api(message_tools_api(acls)).await;
for path in [
"/messages?channel_id=1600000000000000101&message_id=1800000000000001001",
@@ -509,20 +443,11 @@ async fn message_tools_offer_ncmec_only_with_every_required_acl() {
] {
let body = get(&app, path, &[]).await;
assert!(body.contains("tools-image.png"), "{acls:?} {path}\n{body}");
assert_eq!(
body.contains(r#"class="ncmec-report-btn"#),
ncmec,
"{acls:?} {path}\n{body}"
);
assert_eq!(
body.contains(r#"class="delete-message-btn"#),
delete,
"{acls:?} {path}\n{body}"
);
assert!(
!body.contains("data-source-report-id"),
"{acls:?} {path}\n{body}"
);
}
}
}
@@ -682,8 +607,7 @@ fn evidence_report() -> Value {
"width": 640,
"height": 480,
"size": 4096
}],
"user_prior_ncmec_report_ids": []
}]
},
{
"id": "1800000000000000999",
@@ -701,8 +625,7 @@ fn evidence_report() -> Value {
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
}
]
})
@@ -1890,10 +1813,7 @@ fn message_tools_api(acls: &[&str]) -> Router {
"content_type": "image/png",
"width": 64,
"height": 64,
"size": 4096,
"ncmec_status": "not_submitted",
"ncmec_report_id": null,
"ncmec_failure_reason": null
"size": 4096
}]
});
let lookup = json!({"messages": [message.clone()], "message_id": "1800000000000001001"});
@@ -80,8 +80,7 @@
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159119835293",
@@ -104,8 +103,7 @@
"author_avatar": null,
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159124029598",
@@ -128,8 +126,7 @@
"author_avatar": "f829b914",
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159128223903",
@@ -152,8 +149,7 @@
"author_avatar": "f829b914",
"webhook_id": null,
"author_bot": false,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159140806818",
@@ -176,8 +172,7 @@
"author_avatar": null,
"webhook_id": "1556352159132418208",
"author_bot": null,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159153389733",
@@ -200,8 +195,7 @@
"author_avatar": null,
"webhook_id": null,
"author_bot": true,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159161778343",
@@ -224,8 +218,7 @@
"author_avatar": null,
"webhook_id": null,
"author_bot": true,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159212110003",
@@ -248,8 +241,7 @@
"author_avatar": null,
"webhook_id": "1556352159203721393",
"author_bot": null,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
},
{
"id": "1556352159186944173",
@@ -272,8 +264,7 @@
"author_avatar": null,
"webhook_id": null,
"author_bot": null,
"missing_attachments": [],
"user_prior_ncmec_report_ids": []
"missing_attachments": []
}
],
"message_responses": [
@@ -77,8 +77,7 @@
"author_global_name": null,
"author_discriminator": "7915",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449163617486",
@@ -99,8 +98,7 @@
"author_global_name": null,
"author_discriminator": "0943",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449167811791",
@@ -121,8 +119,7 @@
"author_global_name": null,
"author_discriminator": "9014",
"author_avatar": "f829b914",
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449172006096",
@@ -143,8 +140,7 @@
"author_global_name": null,
"author_discriminator": "9014",
"author_avatar": "f829b914",
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449184589011",
@@ -165,8 +161,7 @@
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556114449176200401",
"user_prior_ncmec_report_ids": []
"webhook_id": "1556114449176200401"
},
{
"id": "1556114449255892196",
@@ -187,8 +182,7 @@
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": "1556114449247503586",
"user_prior_ncmec_report_ids": []
"webhook_id": "1556114449247503586"
},
{
"id": "1556114449230726366",
@@ -209,8 +203,7 @@
"author_global_name": null,
"author_discriminator": "0000",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449205560536",
@@ -231,8 +224,7 @@
"author_global_name": null,
"author_discriminator": "1238",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
},
{
"id": "1556114449197171926",
@@ -253,8 +245,7 @@
"author_global_name": null,
"author_discriminator": "1238",
"author_avatar": null,
"webhook_id": null,
"user_prior_ncmec_report_ids": []
"webhook_id": null
}
],
"message_responses": [
-1
View File
@@ -61,7 +61,6 @@
"bowser": "catalog:",
"cassandra-driver": "catalog:",
"emoji-regex": "catalog:",
"fast-xml-parser": "catalog:",
"hi-base32": "catalog:",
"hono": "catalog:",
"html-entities": "catalog:",
-7
View File
@@ -383,13 +383,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
port: master.integrations.clamav.port,
failOpen: master.integrations.clamav.fail_open,
},
ncmec: {
enabled: master.integrations.ncmec.enabled,
baseUrl: master.integrations.ncmec.base_url,
username: master.integrations.ncmec.username,
password: master.integrations.ncmec.password,
reporterEmail: master.integrations.ncmec.reporter_email ?? '',
},
admin: {
oauthClientSecret: master.services.admin.oauth_client_secret,
},
-16
View File
@@ -149,12 +149,6 @@ import {
type WebhooksBySourceChannelRow,
} from '@app/api/database/types/ChannelTypes';
import {USER_CONNECTION_STORAGE_COLUMNS, type UserConnectionStorageRow} from '@app/api/database/types/ConnectionTypes';
import {
NCMEC_ATTACHMENT_SUBMISSION_COLUMNS,
NCMEC_USER_WORKFLOW_COLUMNS,
type NcmecAttachmentSubmissionRow,
type NcmecUserWorkflowRow,
} from '@app/api/database/types/CsamTypes';
import {
GUILD_DISCOVERY_BY_STATUS_COLUMNS,
GUILD_DISCOVERY_COLUMNS,
@@ -1294,16 +1288,6 @@ export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTr
primaryKey: ['attachment_id'],
defaultTtlSeconds: seconds('30 days'),
});
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
name: 'ncmec_attachment_submissions',
columns: NCMEC_ATTACHMENT_SUBMISSION_COLUMNS,
primaryKey: ['attachment_id'],
});
export const NcmecUserWorkflows = defineTable<NcmecUserWorkflowRow, 'user_id'>({
name: 'ncmec_user_workflows',
columns: NCMEC_USER_WORKFLOW_COLUMNS,
primaryKey: ['user_id'],
});
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
name: 'billing_customers',
columns: BILLING_CUSTOMER_COLUMNS,
+1 -7
View File
@@ -31,11 +31,7 @@ import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {InviteRepository} from '@app/api/invite/InviteRepository';
import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
import {JobAdminService} from '@app/api/jobs/JobAdminService';
import {
getGuildDiscoveryRepository,
getKVAccountDeletionQueue,
getNcmecSubmissionService,
} from '@app/api/middleware/ServiceSingletons';
import {getGuildDiscoveryRepository, getKVAccountDeletionQueue} from '@app/api/middleware/ServiceSingletons';
import type {IApplicationRepository} from '@app/api/oauth/repositories/IApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
@@ -140,7 +136,6 @@ export class AdminService {
channelRepository: this.channelRepository,
guildRepository: this.guildRepository,
auditService: this.auditService,
ncmecSubmissionService: getNcmecSubmissionService(),
});
this.messageShredService = new AdminMessageShredService({
apiContext: this.apiContext,
@@ -159,7 +154,6 @@ export class AdminService {
storageService: this.storageService,
auditService: this.auditService,
userCacheService: this.userCacheService,
ncmecSubmissionService: getNcmecSubmissionService(),
reporterResolutionNotifier,
});
this.voiceService = new AdminVoiceService({
@@ -2,7 +2,6 @@
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
import {createAttachmentID, createChannelID, createMessageID, createReportID} from '@app/api/BrandedTypes';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -25,13 +24,11 @@ import {
DeleteAllUserMessagesResponse,
MessageShredJobIdParam,
MessageShredResponse,
ReportAttachmentToNcmecRequest,
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
import {
DeleteMessageResponse,
LookupMessageResponse,
MessageShredStatusResponse,
NcmecAttachmentSubmitResultResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
ChannelIdMessageIdParam,
@@ -124,40 +121,6 @@ export function MessageAdminController(app: HonoApp) {
return ctx.json(response);
},
);
app.post(
'/admin/messages/ncmec-reports',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.CSAM_SUBMIT_NCMEC),
requireAdminACL(AdminACLs.MESSAGE_DELETE),
requireAdminACL(AdminACLs.USER_DELETE),
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
Validator('json', ReportAttachmentToNcmecRequest),
OpenAPI({
operationId: 'create_admin_ncmec_report',
summary: 'Report an attachment to NCMEC',
description:
'Submits a specific image attachment to NCMEC, creates an audit log entry, silently disables the user, triggers one archive for the user, and schedules content deletion after the archive completes.',
responseSchema: NcmecAttachmentSubmitResultResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const service = ctx.get('ncmecSubmissionService');
const adminUserId = ctx.get('adminUserId');
const body = ctx.req.valid('json');
const result = await service.submitAttachmentToNcmec({
channelId: createChannelID(body.channel_id),
messageId: createMessageID(body.message_id),
attachmentId: createAttachmentID(body.attachment_id),
filename: body.filename,
reporterFullName: body.reporter_full_name,
adminUserId,
sourceReportId: body.source_report_id ? createReportID(body.source_report_id) : null,
});
return ctx.json(result);
},
);
app.get(
'/admin/messages/shreds/:job_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
@@ -26,7 +26,6 @@ import {
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons';
import {getMessageSearchService} from '@app/api/SearchFactory';
@@ -49,7 +48,6 @@ interface AdminMessageServiceDeps {
channelRepository: IChannelRepository;
guildRepository: IGuildRepositoryAggregate;
auditService: AdminAuditService;
ncmecSubmissionService: NcmecSubmissionService;
}
interface ChannelNsfwContext {
@@ -92,10 +90,8 @@ export class AdminMessageService {
around: messageId,
})
).reverse();
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
const priorReports = await this.getPriorReportsForMessages(messageResponses);
const adminMessages = messageResponses.map((message) =>
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
this.mapMessageResponseToAdminMessage(message, channelNsfwContext),
);
return {
messages: adminMessages,
@@ -207,10 +203,8 @@ export class AdminMessageService {
after: afterId,
});
const messageResponses = afterId ? messages : [...messages].reverse();
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
const priorReports = await this.getPriorReportsForMessages(messageResponses);
const adminMessages = messageResponses.map((message) =>
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
this.mapMessageResponseToAdminMessage(message, channelNsfwContext),
);
return {
messages: adminMessages,
@@ -241,10 +235,8 @@ export class AdminMessageService {
messages: result.messages,
access: await this.getMessageResponseAccessForAdmin(channelId),
});
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
const priorReports = await this.getPriorReportsForMessages(messageResponses);
const adminMessages = messageResponses.map((message) =>
this.mapMessageResponseToAdminMessage(message, channelNsfwContext, attachmentStatuses, priorReports),
this.mapMessageResponseToAdminMessage(message, channelNsfwContext),
);
return {
messages: adminMessages,
@@ -253,15 +245,6 @@ export class AdminMessageService {
};
}
private async getAttachmentStatusesForMessages(
messages: Array<MessageResponse>,
): Promise<Map<string, NcmecAttachmentStatusResponse>> {
const attachmentIds = messages.flatMap((message) =>
(message.attachments ?? []).map((attachment) => createAttachmentID(BigInt(attachment.id))),
);
return this.deps.ncmecSubmissionService.getAttachmentStatuses(attachmentIds);
}
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
@@ -287,17 +270,7 @@ export class AdminMessageService {
});
}
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
}
private mapMessageResponseToAdminMessage(
message: MessageResponse,
channelNsfwContext: ChannelNsfwContext,
attachmentStatuses: Map<string, NcmecAttachmentStatusResponse>,
priorReports: Map<string, Array<string>>,
) {
private mapMessageResponseToAdminMessage(message: MessageResponse, channelNsfwContext: ChannelNsfwContext) {
return {
id: message.id,
channel_id: message.channel_id ?? '',
@@ -313,7 +286,6 @@ export class AdminMessageService {
author_avatar: message.author.avatar,
content: message.content ?? '',
timestamp: message.timestamp,
user_prior_ncmec_report_ids: priorReports.get(message.author.id) ?? [],
attachments:
message.attachments?.map((attachment) => ({
id: attachment.id,
@@ -324,9 +296,6 @@ export class AdminMessageService {
width: attachment.width ?? null,
height: attachment.height ?? null,
size: attachment.size == null ? null : assertSafeByteSize(attachment.size, 'admin message attachment size'),
ncmec_status: attachmentStatuses.get(attachment.id)?.status ?? 'not_submitted',
ncmec_report_id: attachmentStatuses.get(attachment.id)?.ncmec_report_id ?? null,
ncmec_failure_reason: attachmentStatuses.get(attachment.id)?.failure_reason ?? null,
})) ?? [],
};
}
@@ -24,7 +24,6 @@ import {
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
@@ -63,7 +62,6 @@ interface AdminReportServiceDeps {
storageService: IStorageService;
auditService: AdminAuditService;
userCacheService: UserCacheService;
ncmecSubmissionService: NcmecSubmissionService;
reporterResolutionNotifier: ReporterResolutionNotifier;
}
@@ -378,8 +376,6 @@ export class AdminReportService {
if (!includeContext) {
return baseResponse;
}
const attachmentStatusesById = await this.getAttachmentStatusesById(report);
const priorReportsByAuthor = await this.getPriorReportsForContext(report);
const authorBotFlags = await this.getContextAuthorBotFlags(report, requestCache);
const messageContext = (
await Promise.all(
@@ -389,8 +385,6 @@ export class AdminReportService {
report.reportedChannelId ?? null,
report.reportedGuildId ?? report.guildContextId ?? null,
reportLookupCache,
attachmentStatusesById,
priorReportsByAuthor,
authorBotFlags,
),
),
@@ -566,8 +560,6 @@ export class AdminReportService {
fallbackChannelId: ChannelID | null,
fallbackGuildId: GuildID | null,
reportLookupCache: ReportLookupCache,
attachmentStatusesById: Map<string, NcmecAttachmentStatusResponse>,
priorReportsByAuthor: Map<string, Array<string>>,
authorBotFlags: Map<string, boolean | null>,
) {
const channelId = message.channelId ?? fallbackChannelId;
@@ -581,9 +573,7 @@ export class AdminReportService {
message.attachments && message.attachments.length > 0
? (
await Promise.all(
message.attachments.map((attachment) =>
this.mapReportAttachmentToResponse(attachment, channelId, attachmentStatusesById),
),
message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)),
)
).filter(
(
@@ -597,9 +587,6 @@ export class AdminReportService {
width: number | null;
height: number | null;
size: number | null;
ncmec_status: string;
ncmec_report_id: string | null;
ncmec_failure_reason: string | null;
} => attachment !== null,
)
: [];
@@ -627,21 +614,9 @@ export class AdminReportService {
missing_attachments: message.missingAttachments
.filter((attachment) => attachment.attachment_id != null && attachment.filename)
.map(mapMissingAttachmentToResponse),
user_prior_ncmec_report_ids: message.authorId ? (priorReportsByAuthor.get(authorId) ?? []) : [],
};
}
private async getPriorReportsForContext(report: IARSubmission): Promise<Map<string, Array<string>>> {
const authorIds = new Set<string>();
for (const message of report.messageContext ?? []) {
if (message.authorId) authorIds.add(message.authorId.toString());
}
if (report.reportedUserId) authorIds.add(report.reportedUserId.toString());
if (authorIds.size === 0) return new Map();
const userIds = [...authorIds].map((value) => createUserID(BigInt(value)));
return this.deps.ncmecSubmissionService.getUserPriorReportIds(userIds);
}
private async getChannelNsfwState(
channelId: ChannelID | null,
reportLookupCache: ReportLookupCache,
@@ -700,7 +675,6 @@ export class AdminReportService {
private async mapReportAttachmentToResponse(
attachment: MessageAttachment,
channelId: ChannelID | null,
attachmentStatusesById: Map<string, NcmecAttachmentStatusResponse>,
): Promise<{
id: string;
filename: string;
@@ -710,9 +684,6 @@ export class AdminReportService {
width: number | null;
height: number | null;
size: number | null;
ncmec_status: string;
ncmec_report_id: string | null;
ncmec_failure_reason: string | null;
} | null> {
if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) {
return null;
@@ -736,9 +707,6 @@ export class AdminReportService {
width: attachment.width ?? null,
height: attachment.height ?? null,
size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null,
ncmec_status: attachmentStatusesById.get(attachment.attachment_id.toString())?.status ?? 'not_submitted',
ncmec_report_id: attachmentStatusesById.get(attachment.attachment_id.toString())?.ncmec_report_id ?? null,
ncmec_failure_reason: attachmentStatusesById.get(attachment.attachment_id.toString())?.failure_reason ?? null,
};
} catch (error) {
Logger.error(
@@ -749,13 +717,6 @@ export class AdminReportService {
return null;
}
private async getAttachmentStatusesById(report: IARSubmission) {
const attachmentIds = (report.messageContext ?? []).flatMap((message) =>
message.attachments.map((attachment) => attachment.attachment_id),
);
return this.deps.ncmecSubmissionService.getAttachmentStatuses(attachmentIds);
}
private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise<UserTagInfo | null> {
if (!userId) {
return null;
@@ -61,7 +61,6 @@ describe('Admin audit log access filter', () => {
expect(getAdminAuditAccess(action)).toBe('read');
}
expect(getAdminAuditAccess('update_flags')).toBe('write');
expect(getAdminAuditAccess('NCMEC Report')).toBe('write');
});
for (const search of ['disabled', 'enabled'] as const) {
@@ -116,47 +116,6 @@ export const MessageAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'POST',
route: '/admin/messages/ncmec-reports',
auditLogReason: expect.stringMatching(/^NCMEC Report \S+ - \S+$/),
async prepare({harness}) {
const {author, channelId} = await createAuthorChannel(harness);
const message = await createMessageWithImageAttachment(harness, author.token, channelId);
const attachment = message.attachments[0];
return {
request: {
path: '/admin/messages/ncmec-reports',
body: {
channel_id: channelId,
message_id: message.id,
attachment_id: attachment.id,
filename: attachment.filename,
reporter_full_name: 'Audit Coverage Reporter',
confirmed_viewed: true,
},
},
expected: {
action: 'NCMEC Report',
targetType: 'user',
targetId: author.userId,
metadata: {
channel_id: channelId,
message_id: message.id,
attachment_id: attachment.id,
reported_user_email: author.email,
reported_user_email_verified: 'true',
reported_user_date_of_birth: '2000-01-01',
reported_user_last_active_ip: '127.0.0.1',
attachment_upload_mode: 'form_data',
attachment_upload_request_ip: '127.0.0.1',
attachment_upload_requested_at: expect.any(String),
attachment_upload_ip_source: 'attachment upload request handled directly by Fluxer API',
},
},
};
},
},
{
method: 'GET',
route: '/admin/messages/shreds/:job_id',
@@ -12,10 +12,6 @@ const GET_UPLOAD_TRACE_BY_KEY_QUERY = AttachmentUploadTracesByKey.select({
where: AttachmentUploadTracesByKey.where.eq('upload_key'),
limit: 1,
});
const GET_UPLOAD_TRACE_BY_ATTACHMENT_QUERY = AttachmentUploadTracesByAttachment.select({
where: AttachmentUploadTracesByAttachment.where.eq('attachment_id'),
limit: 1,
});
export type AttachmentUploadMode = 'form_data' | 'presigned_singlepart' | 'presigned_multipart';
@@ -48,12 +44,6 @@ export class AttachmentUploadTraceRepository {
return await fetchOne<AttachmentUploadTraceByKeyRow>(GET_UPLOAD_TRACE_BY_KEY_QUERY.bind({upload_key: uploadKey}));
}
async getByAttachmentId(attachmentId: AttachmentID): Promise<AttachmentUploadTraceByAttachmentRow | null> {
return await fetchOne<AttachmentUploadTraceByAttachmentRow>(
GET_UPLOAD_TRACE_BY_ATTACHMENT_QUERY.bind({attachment_id: BigInt(attachmentId)}),
);
}
async getPendingUpload(input: GetPendingAttachmentUploadInput): Promise<AttachmentUploadTraceByKeyRow | null> {
const existing = await this.getByUploadKey(input.uploadKey);
if (
@@ -1,16 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
type ChannelID,
createAttachmentID,
createChannelID,
createGuildID,
createMessageID,
createUserID,
type MessageID,
type UserID,
} from '@app/api/BrandedTypes';
import {createChannelID, createGuildID, createMessageID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {CrosspostTaskNames} from '@app/api/channel/services/message/CrosspostPropagation';
@@ -26,7 +17,6 @@ import {
import {loadFixture, sendMessageWithAttachments} from '@app/api/channel/tests/AttachmentTestUtils';
import {sendChannelMessage} from '@app/api/channel/tests/ChannelTestUtils';
import {readMessageRow, writeMessageRow} from '@app/api/channel/tests/CrosspostTestUtils';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {MessageEmbed} from '@app/api/database/types/MessageTypes';
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
import {
@@ -38,7 +28,6 @@ import {
import {
getCacheService,
getChannelRepository,
getNcmecSubmissionService,
getPurgeQueue,
getStorageService,
} from '@app/api/middleware/ServiceSingletons';
@@ -157,11 +146,6 @@ class CrosspostQueueWorker implements IWorkerService {
}
}
interface FabricatedCopy {
copyId: string;
attachment: {id: string; filename: string} | null;
}
async function fabricateCopy(params: {
harness: ApiTestHarness;
owner: TestAccount;
@@ -169,8 +153,7 @@ async function fabricateCopy(params: {
world: AnnouncementWorld;
sourceId: string;
extraFlags?: number;
withImage?: boolean;
}): Promise<FabricatedCopy> {
}): Promise<{copyId: string}> {
const webhook = await createWebhook(params.harness, params.target, params.owner.token, 'Follower');
const {json: message} = await executeWebhook(
params.harness,
@@ -179,14 +162,10 @@ async function fabricateCopy(params: {
{content: 'copied update', wait: true},
200,
);
const sourceAttachments = params.withImage
? (await readMessageRow(params.world.a.ann.id, params.sourceId))!.attachments
: [];
const row = await readMessageRow(params.target, message!.id);
await writeMessageRow(row!, {
flags: MessageFlags.IS_CROSSPOST | (params.extraFlags ?? 0),
attachments:
sourceAttachments.length > 0 ? sourceAttachments.map((attachment) => attachment.toMessageAttachment()) : null,
attachments: null,
message_reference: {
channel_id: createChannelID(BigInt(params.world.a.ann.id)),
guild_id: createGuildID(BigInt(params.world.a.guild.id)),
@@ -194,11 +173,7 @@ async function fabricateCopy(params: {
type: MessageReferenceTypes.DEFAULT,
},
});
const attachment = sourceAttachments[0];
return {
copyId: message!.id,
attachment: attachment ? {id: attachment.id.toString(), filename: attachment.filename} : null,
};
return {copyId: message!.id};
}
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
@@ -206,9 +181,6 @@ async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
AdminACLs.AUTHENTICATE,
AdminACLs.MESSAGE_DELETE,
AdminACLs.MESSAGE_SHRED,
AdminACLs.CSAM_SUBMIT_NCMEC,
AdminACLs.USER_DELETE,
AdminACLs.ARCHIVE_TRIGGER_USER,
]);
}
@@ -219,17 +191,6 @@ async function adminDeleteMessage(harness: ApiTestHarness, admin: TestAccount, c
.execute();
}
async function deleteMessageSilently(channelId: string, messageId: string, fallbackUserId: string): Promise<void> {
const service = getNcmecSubmissionService() as unknown as {
deleteMessageSilently(channelId: ChannelID, messageId: MessageID, fallbackUserId: UserID): Promise<void>;
};
await service.deleteMessageSilently(
createChannelID(BigInt(channelId)),
createMessageID(BigInt(messageId)),
createUserID(BigInt(fallbackUserId)),
);
}
function blockedEmbedService(): EmbedService {
return {
processUrlWithCachePolicy: async (url: string) => ({
@@ -331,73 +292,6 @@ describe('Crosspost moderation', () => {
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
});
test('an NCMEC report on a copy records the source author and its takedown purges the family', async () => {
const {json: source} = await sendMessageWithAttachments(
harness,
world.a.member.token,
world.a.ann.id,
{content: 'update', attachments: [{id: 0, filename: 'yeah.png'}]},
[{index: 0, filename: 'yeah.png', data: loadFixture('yeah.png')}],
);
const {copyId, attachment} = await fabricateCopy({
harness,
owner: world.b.owner,
target: world.b.t1.id,
world,
sourceId: source.id,
withImage: true,
});
expect(attachment).not.toBeNull();
const admin = await createAdmin(harness);
await createBuilder(harness, admin.token)
.post('/admin/messages/ncmec-reports')
.body({
channel_id: world.b.t1.id,
message_id: copyId,
attachment_id: attachment!.id,
filename: attachment!.filename,
reporter_full_name: 'Crosspost Reporter',
confirmed_viewed: true,
})
.expect(HTTP_STATUS.OK)
.execute();
const submission = await new NcmecRepository().getAttachmentSubmission(
createAttachmentID(BigInt(attachment!.id)),
);
expect(submission?.user_id?.toString()).toBe(world.a.member.userId);
expect(submission?.message_id.toString()).toBe(copyId);
expect(submission?.channel_id.toString()).toBe(world.b.t1.id);
worker.clear();
const deletedBefore = deletedKeys().length;
await deleteMessageSilently(world.b.t1.id, copyId, world.a.member.userId);
expect(await readMessageRow(world.b.t1.id, copyId)).toBeNull();
expect(deletedKeys().slice(deletedBefore)).toEqual([]);
expect(
harness.storageService.hasObject(
Config.s3.buckets.cdn,
`attachments/${world.a.ann.id}/${attachment!.id}/${attachment!.filename}`,
),
).toBe(true);
const jobs = worker.syncJobs();
expect(jobs).toHaveLength(1);
expect(jobs[0]!.payload).toEqual({
channelId: world.a.ann.id,
messageId: source.id,
mode: 'purge',
deleteSource: true,
});
});
test('an NCMEC takedown of a published source purges its copies', async () => {
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
worker.clear();
await deleteMessageSilently(world.a.ann.id, source.id, world.a.member.userId);
const jobs = worker.syncJobs();
expect(jobs).toHaveLength(1);
expect(jobs[0]!.payload).toEqual({channelId: world.a.ann.id, messageId: source.id, mode: 'purge'});
});
test('a message shred of a published source purges its copies', async () => {
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
await publish(harness, world.a.member.token, world.a.ann.id, source.id);
@@ -585,35 +479,6 @@ describe('Crosspost moderation', () => {
expectNoTargetObjectsDeleted();
});
test('an NCMEC takedown of a delivered copy records the source author and removes the family', async () => {
const sourceId = await publishAndDeliver('image update', true);
const sourceKey = await sourceAttachmentKey(sourceId);
const [copyId] = await listCopies(c.t1.id, sourceId);
const copy = await readMessageRow(c.t1.id, copyId!);
const attachment = copy!.attachments[0]!;
expect(`attachments/${world.a.ann.id}/${attachment.id}/${attachment.filename}`).toBe(sourceKey);
const admin = await createAdmin(harness);
await createBuilder(harness, admin.token)
.post('/admin/messages/ncmec-reports')
.body({
channel_id: c.t1.id,
message_id: copyId,
attachment_id: attachment.id.toString(),
filename: attachment.filename,
reporter_full_name: 'Crosspost Reporter',
confirmed_viewed: true,
})
.expect(HTTP_STATUS.OK)
.execute();
const submission = await new NcmecRepository().getAttachmentSubmission(attachment.id);
expect(submission?.user_id?.toString()).toBe(world.a.member.userId);
await deleteMessageSilently(c.t1.id, copyId!, world.a.member.userId);
await worker.drain();
await expectFamilyGone(sourceId);
expect(deletedKeys().filter((key) => key === sourceKey)).toHaveLength(1);
expectNoTargetObjectsDeleted();
});
test('a blocked unfurl of a published source deletes every copy', async () => {
const sourceId = await publishAndDeliver(`see https://${BLOCKED_DOMAIN}/page`);
expect(await listCopies(c.t1.id, sourceId)).toHaveLength(1);
@@ -1,15 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
type ChannelID,
createChannelID,
createMessageID,
createReportID,
createUserID,
type MessageID,
type UserID,
} from '@app/api/BrandedTypes';
import {createReportID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
createChannel,
@@ -18,7 +10,6 @@ import {
sendMessageWithAttachments,
} from '@app/api/channel/tests/AttachmentTestUtils';
import {acceptInvite, createChannelInvite} from '@app/api/channel/tests/ChannelTestUtils';
import {getNcmecSubmissionService} from '@app/api/middleware/ServiceSingletons';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
@@ -210,13 +201,7 @@ describe('Embed attachment purge', () => {
}
async function createAdmin(): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.MESSAGE_DELETE,
AdminACLs.CSAM_SUBMIT_NCMEC,
AdminACLs.USER_DELETE,
AdminACLs.ARCHIVE_TRIGGER_USER,
]);
return setUserACLs(harness, await createTestAccount(harness), [AdminACLs.AUTHENTICATE, AdminACLs.MESSAGE_DELETE]);
}
function embedKeys(message: MessageResponse): Array<string> {
@@ -274,24 +259,9 @@ describe('Embed attachment purge', () => {
expect(deletedKeys()).toEqual(expect.arrayContaining(keys));
});
it('removes files shown only through an embed on a silent NCMEC delete', async () => {
const message = await sendEmbedOnly();
const keys = embedKeys(message);
const service = getNcmecSubmissionService() as unknown as {
deleteMessageSilently(channelId: ChannelID, messageId: MessageID, fallbackUserId: UserID): Promise<void>;
};
await service.deleteMessageSilently(
createChannelID(BigInt(channelId)),
createMessageID(BigInt(message.id)),
createUserID(BigInt(account.userId)),
);
expect(deletedKeys()).toEqual(expect.arrayContaining(keys));
});
it('keeps reported embed files as report evidence after the author deletes the message', async () => {
const message = await sendEmbedOnly();
const [imageKey] = embedKeys(message);
const [, , attachmentId, filename] = imageKey!.split('/');
const reporter = await createTestAccount(harness);
const invite = await createChannelInvite(harness, account.token, channelId);
await acceptInvite(harness, reporter.token, invite.code);
@@ -306,20 +276,6 @@ describe('Embed attachment purge', () => {
const row = await new ReportRepository().getReport(createReportID(BigInt(report.report_id)));
const context = row!.messageContext!.find((entry) => entry.messageId.toString() === message.id);
expect(context?.attachments.map((attachment) => attachment.filename).sort()).toEqual(['image.png', 'thumb.png']);
const admin = await createAdmin();
await createBuilder(harness, admin.token)
.post('/admin/messages/ncmec-reports')
.body({
channel_id: channelId,
message_id: message.id,
attachment_id: attachmentId,
filename,
reporter_full_name: 'Embed Reporter',
confirmed_viewed: true,
source_report_id: report.report_id,
})
.expect(HTTP_STATUS.OK)
.execute();
});
it('does not expose the ownership marker on embed media flags', async () => {
-7
View File
@@ -363,13 +363,6 @@ export interface APIConfig {
crosspost?: number;
};
};
ncmec: {
enabled: boolean;
baseUrl?: string;
username?: string;
password?: string;
reporterEmail?: string;
};
}
export interface BlueskyOAuthKeyConfig {
-498
View File
@@ -1,498 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {Logger} from '@app/api/Logger';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {ms} from 'itty-time';
const NCMEC_REQUEST_TIMEOUT_MS = ms('2 minutes');
type NcmecOperation = 'report' | 'evidence' | 'fileinfo' | 'finish' | 'retract';
type NcmecApiConfig =
| {
enabled: true;
baseUrl: string;
username: string;
password: string;
}
| {
enabled: false;
baseUrl: null;
username: null;
password: null;
};
interface NcmecApiDeps {
config: NcmecApiConfig;
fetch: typeof fetch;
}
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);
}
}
interface NcmecResponseMeta {
responseCode: number | null;
responseDescription: string | null;
requestId: string | null;
}
export interface NcmecApiClient {
submitReport(reportXml: string): Promise<string>;
uploadEvidence(
reportId: string,
body: Uint8Array,
filename: string,
): Promise<{
fileId: string;
md5: string | null;
}>;
submitFileDetails(fileDetailsXml: string): Promise<void>;
finish(reportId: string): Promise<{
reportId: string;
fileIds: Array<string>;
}>;
retract(reportId: string): Promise<void>;
}
export class NcmecReporter implements NcmecApiClient {
constructor(private readonly deps: NcmecApiDeps) {}
async submitReport(reportXml: string): Promise<string> {
return this.telemetered('report', async () => {
const {res, text} = await this.postXml('/submit', reportXml);
this.assertOk(res, text);
const reportId = takeTag(text, 'reportId');
if (!reportId) throw this.fail('NCMEC /submit returned no reportId.', res, text);
return reportId;
});
}
async uploadEvidence(
reportId: string,
body: Uint8Array,
filename: string,
): Promise<{
fileId: string;
md5: string | null;
}> {
return this.telemetered('evidence', async () => {
const form = new FormData();
form.append('id', reportId);
const blobBytes = new Uint8Array(body.byteLength);
blobBytes.set(body);
form.append('file', new Blob([blobBytes]), filename);
const {res, text} = await this.postForm('/upload', form);
this.assertOk(res, text);
const fileId = takeTag(text, 'fileId');
if (!fileId) throw this.fail('NCMEC /upload returned no fileId.', res, text);
return {fileId, md5: takeTag(text, 'hash')};
});
}
async submitFileDetails(fileDetailsXml: string): Promise<void> {
await this.telemetered('fileinfo', async () => {
const {res, text} = await this.postXml('/fileinfo', fileDetailsXml);
this.assertOk(res, text);
});
}
async finish(reportId: string): Promise<{
reportId: string;
fileIds: Array<string>;
}> {
return this.telemetered('finish', async () => {
const form = new FormData();
form.append('id', reportId);
const {res, text} = await this.postForm('/finish', form);
this.assertOk(res, text);
const returned = takeTag(text, 'reportId');
if (!returned) throw this.fail('NCMEC /finish returned no reportId.', res, text);
return {reportId: returned, fileIds: takeTags(text, 'fileId')};
});
}
async retract(reportId: string): Promise<void> {
await this.telemetered('retract', async () => {
const form = new FormData();
form.append('id', reportId);
const {res, text} = await this.postForm('/retract', form);
this.assertOk(res, text);
});
}
private postXml(
path: string,
body: string,
): Promise<{
res: Response;
text: string;
}> {
return this.post(path, {body, headers: {'Content-Type': 'text/xml; charset=utf-8'}});
}
private postForm(
path: string,
form: FormData,
): Promise<{
res: Response;
text: string;
}> {
return this.post(path, {body: form});
}
private async post(
path: string,
init: RequestInit,
): Promise<{
res: Response;
text: string;
}> {
const cfg = this.requireEnabled();
const res = await this.deps.fetch(`${cfg.baseUrl}${path}`, {
method: 'POST',
...init,
headers: {
Authorization: basicAuth(cfg.username, cfg.password),
...(init.headers ?? {}),
},
signal: AbortSignal.timeout(NCMEC_REQUEST_TIMEOUT_MS),
});
const text = await FetchUtils.streamToStringWithLimit(res.body, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.ncmecResponseBytes,
headers: res.headers,
url: res.url,
description: 'NCMEC response',
}).catch(() => '');
return {res, text};
}
private assertOk(res: Response, body: string): void {
const meta = parseResponseMeta(res, body);
if (res.ok && meta.responseCode === 0) return;
throw this.fail(
meta.responseCode === null
? `NCMEC request failed (http ${res.status}).`
: `NCMEC request failed (http ${res.status}, responseCode ${meta.responseCode}).`,
res,
body,
meta,
);
}
private fail(message: string, res: Response, body: string, meta = parseResponseMeta(res, body)): NcmecRequestError {
Logger.warn(
{
status: res.status,
responseCode: meta.responseCode,
description: meta.responseDescription,
body: body || '<no body>',
},
'NCMEC request failed',
);
return new NcmecRequestError(
message,
res.status,
meta.responseCode,
meta.responseDescription,
meta.requestId,
body || null,
);
}
private requireEnabled(): Extract<
NcmecApiConfig,
{
enabled: true;
}
> {
if (!this.deps.config.enabled) throw new Error('NCMEC reporting is disabled.');
return this.deps.config;
}
private async telemetered<T>(operation: NcmecOperation, fn: () => Promise<T>): Promise<T> {
if (!this.deps.config.enabled) {
throw new Error('NCMEC reporting is disabled.');
}
try {
const out = await fn();
return out;
} catch (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: requireConfigValue(Config.ncmec.username, 'NCMEC username'),
password: requireConfigValue(Config.ncmec.password, 'NCMEC password'),
};
}
function basicAuth(user: string, pass: string): string {
return `Basic ${Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64')}`;
}
function normaliseBaseUrl(raw: string | undefined | null): string {
const trimmed = (raw ?? '').trim();
if (!trimmed) throw new Error('NCMEC base URL is required when reporting is enabled.');
return trimmed.replace(/\/+$/, '');
}
function requireConfigValue(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 requireNonBlank(value: string | undefined | null, label: string): string {
const trimmed = (value ?? '').trim();
if (!trimmed) throw new Error(`${label} is required.`);
return trimmed;
}
function takeTag(xml: string, tag: string): string | null {
if (!xml) return null;
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i'));
return match?.[1]?.trim() || null;
}
function takeTags(xml: string, tag: string): Array<string> {
if (!xml) return [];
return [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'gi'))]
.map((match) => match[1]?.trim() ?? '')
.filter(Boolean);
}
function parseResponseCode(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function parseResponseMeta(res: Response, body: string): NcmecResponseMeta {
return {
responseCode: parseResponseCode(takeTag(body, 'responseCode')),
responseDescription: takeTag(body, 'responseDescription'),
requestId: res.headers.get('Request-ID'),
};
}
interface NcmecReportedUser {
id: bigint | null;
screenName: string | null;
displayName: string | null;
espService: string | null;
permanentlyDisabledAt: Date | null;
person: NcmecReportedUserPerson | null;
ipCaptureEvents: ReadonlyArray<NcmecIpCaptureEvent>;
}
interface NcmecReportedUserPerson {
emails: ReadonlyArray<NcmecContactEmail>;
phones: ReadonlyArray<NcmecContactPhone>;
dateOfBirth: string | null;
}
interface NcmecContactEmail {
address: string;
type?: 'Home' | 'Work' | 'Business' | 'Recovery';
verified?: boolean | null;
}
interface NcmecContactPhone {
number: string;
type?: 'Mobile' | 'Home' | 'Business' | 'Work' | 'Fax' | 'Internet' | 'Recovery';
verified?: boolean | null;
}
interface NcmecIpCaptureEvent {
ipAddress: string;
eventName?: 'Login' | 'Registration' | 'Purchase' | 'Upload' | 'Other' | 'Unknown';
dateTime?: Date | null;
possibleProxy?: boolean | null;
}
interface NcmecReportXmlInput {
attachmentUrl: string;
reportedAt: Date;
reporterFullName: string;
reporterEmail: string;
reportedUser: NcmecReportedUser;
priorNcmecReportIds: ReadonlyArray<string>;
additionalInfo: string | null;
}
interface NcmecFileDetailsXmlInput {
reportId: string;
fileId: string;
filename: string;
uploadedToEspTimestamp: Date | null;
fileViewedByEsp?: boolean;
ipCaptureEvent: NcmecIpCaptureEvent | null;
additionalInfo: string | null;
}
export function buildNcmecReportXml(input: NcmecReportXmlInput): string {
const schemaLocation = `${schemaRoot()}/xsd`;
const {firstName, lastName} = splitName(input.reporterFullName);
const reporterEmail = requireNonBlank(input.reporterEmail, 'NCMEC reporter email');
const screenName = input.reportedUser.screenName
? ` <screenName>${escapeXml(input.reportedUser.screenName)}</screenName>\n`
: '';
const displayName = input.reportedUser.displayName
? ` <displayName>${escapeXml(input.reportedUser.displayName)}</displayName>\n`
: '';
const reportedUserPerson = renderReportedUserPerson(input.reportedUser.person ?? null);
const espIdentifier =
input.reportedUser.id !== null
? ` <espIdentifier>${escapeXml(input.reportedUser.id.toString())}</espIdentifier>\n`
: '';
const espService = input.reportedUser.espService
? ` <espService>${escapeXml(input.reportedUser.espService)}</espService>\n`
: '';
const ipCaptureEvents = (input.reportedUser.ipCaptureEvents ?? [])
.map((event) => renderIpCaptureEvent(event, ' '))
.join('');
const priorReports = input.priorNcmecReportIds
.map((id) => ` <priorCTReports>${escapeXml(id)}</priorCTReports>\n`)
.join('');
const disabled = input.reportedUser.permanentlyDisabledAt
? ` <accountPermanentlyDisabled disabledDate="${escapeXml(input.reportedUser.permanentlyDisabledAt.toISOString())}" userNotified="false">true</accountPermanentlyDisabled>\n`
: '';
const allEmailsReported =
input.reportedUser.person && input.reportedUser.person.emails.length > 0
? ' <allEmailsReported>true</allEmailsReported>\n'
: '';
const reportAdditionalInfo = input.additionalInfo
? ` <additionalInfo>${escapeXml(input.additionalInfo)}</additionalInfo>\n`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<report xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="${escapeXml(schemaLocation)}">
<incidentSummary>
<incidentType>Child Pornography (possession, manufacture, and distribution)</incidentType>
<platform>Fluxer</platform>
<incidentDateTime>${escapeXml(input.reportedAt.toISOString())}</incidentDateTime>
</incidentSummary>
<internetDetails>
<webPageIncident>
<url>${escapeXml(input.attachmentUrl)}</url>
</webPageIncident>
</internetDetails>
<reporter>
<reportingPerson>
<firstName>${escapeXml(firstName)}</firstName>
<lastName>${escapeXml(lastName)}</lastName>
<email>${escapeXml(reporterEmail)}</email>
</reportingPerson>
</reporter>
<personOrUserReported>
${reportedUserPerson}${espIdentifier}${espService}${screenName}${displayName}${ipCaptureEvents}${priorReports}${disabled}${allEmailsReported} </personOrUserReported>
${reportAdditionalInfo}</report>`;
}
export function buildNcmecFileDetailsXml(input: NcmecFileDetailsXmlInput): string {
const schemaLocation = `${schemaRoot()}/xsd`;
const uploadedToEspTimestamp = input.uploadedToEspTimestamp
? ` <uploadedToEspTimestamp>${escapeXml(input.uploadedToEspTimestamp.toISOString())}</uploadedToEspTimestamp>\n`
: '';
const fileViewedByEsp = input.fileViewedByEsp === true ? ' <fileViewedByEsp>true</fileViewedByEsp>\n' : '';
const ipCaptureEvent = input.ipCaptureEvent ? renderIpCaptureEvent(input.ipCaptureEvent, ' ') : '';
const additionalInfo = input.additionalInfo
? ` <additionalInfo>${escapeXml(input.additionalInfo)}</additionalInfo>\n`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<fileDetails xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="${escapeXml(schemaLocation)}">
<reportId>${escapeXml(input.reportId)}</reportId>
<fileId>${escapeXml(input.fileId)}</fileId>
<originalFileName>${escapeXml(input.filename)}</originalFileName>
${uploadedToEspTimestamp}${fileViewedByEsp}${ipCaptureEvent}${additionalInfo}</fileDetails>`;
}
function schemaRoot(): string {
const trimmed = (Config.ncmec.baseUrl ?? '').trim().replace(/\/+$/, '');
return trimmed || 'https://report.cybertip.org/ispws';
}
function splitName(fullName: string): {
firstName: string;
lastName: string;
} {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return {firstName: 'Unknown', lastName: 'Reporter'};
if (parts.length === 1) return {firstName: parts[0]!, lastName: 'Reporter'};
return {firstName: parts[0]!, lastName: parts.slice(1).join(' ')};
}
function renderReportedUserPerson(input: NcmecReportedUserPerson | null): string {
if (!input) return '';
const phones = input.phones.map((phone) => renderPhone(phone)).join('');
const emails = input.emails.map((email) => renderEmail(email)).join('');
const dateOfBirth = input.dateOfBirth
? ` <dateOfBirth>${escapeXml(input.dateOfBirth)}</dateOfBirth>\n`
: '';
if (!phones && !emails && !dateOfBirth) {
return '';
}
return ` <personOrUserReportedPerson>\n${phones}${emails}${dateOfBirth} </personOrUserReportedPerson>\n`;
}
function renderPhone(phone: NcmecContactPhone): string {
const attrs: Array<string> = [];
if (phone.type) {
attrs.push(`type="${escapeXml(phone.type)}"`);
}
if (typeof phone.verified === 'boolean') {
attrs.push(`verified="${phone.verified ? 'true' : 'false'}"`);
}
const attrText = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
return ` <phone${attrText}>${escapeXml(phone.number)}</phone>\n`;
}
function renderEmail(email: NcmecContactEmail): string {
const attrs: Array<string> = [];
if (email.type) {
attrs.push(`type="${escapeXml(email.type)}"`);
}
if (typeof email.verified === 'boolean') {
attrs.push(`verified="${email.verified ? 'true' : 'false'}"`);
}
const attrText = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
return ` <email${attrText}>${escapeXml(email.address)}</email>\n`;
}
function renderIpCaptureEvent(event: NcmecIpCaptureEvent, indent: string): string {
const eventName = event.eventName ? `${indent} <eventName>${escapeXml(event.eventName)}</eventName>\n` : '';
const dateTime = event.dateTime
? `${indent} <dateTime>${escapeXml(event.dateTime.toISOString())}</dateTime>\n`
: '';
const possibleProxy =
typeof event.possibleProxy === 'boolean'
? `${indent} <possibleProxy>${event.possibleProxy ? 'true' : 'false'}</possibleProxy>\n`
: '';
return `${indent}<ipCaptureEvent>\n${indent} <ipAddress>${escapeXml(event.ipAddress)}</ipAddress>\n${eventName}${dateTime}${possibleProxy}${indent}</ipCaptureEvent>\n`;
}
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, UserID} from '@app/api/BrandedTypes';
import {fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import type {NcmecAttachmentSubmissionRow, NcmecUserWorkflowRow} from '@app/api/database/types/CsamTypes';
import {NcmecAttachmentSubmissions, NcmecUserWorkflows} from '@app/api/Tables';
const GET_ATTACHMENT_SUBMISSION_QUERY = NcmecAttachmentSubmissions.select({
where: NcmecAttachmentSubmissions.where.eq('attachment_id'),
limit: 1,
});
const GET_USER_WORKFLOW_QUERY = NcmecUserWorkflows.select({
where: NcmecUserWorkflows.where.eq('user_id'),
limit: 1,
});
export class NcmecRepository {
async getAttachmentSubmission(attachmentId: AttachmentID): Promise<NcmecAttachmentSubmissionRow | null> {
return await fetchOne<NcmecAttachmentSubmissionRow>(
GET_ATTACHMENT_SUBMISSION_QUERY.bind({attachment_id: BigInt(attachmentId)}),
);
}
async upsertAttachmentSubmission(row: NcmecAttachmentSubmissionRow): Promise<void> {
await upsertOne(NcmecAttachmentSubmissions.insert(row));
}
async getUserWorkflow(userId: UserID): Promise<NcmecUserWorkflowRow | null> {
return await fetchOne<NcmecUserWorkflowRow>(GET_USER_WORKFLOW_QUERY.bind({user_id: BigInt(userId)}));
}
async upsertUserWorkflow(row: NcmecUserWorkflowRow): Promise<void> {
await upsertOne(NcmecUserWorkflows.insert(row));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,202 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildNcmecFileDetailsXml,
buildNcmecReportXml,
createNcmecApiConfig,
NcmecReporter,
} from '@app/api/csam/NcmecReporter';
import {INVALID_REPORT_XML, MALFORMED_REPORT_XML} from '@app/api/test/fixtures/ncmec/NcmecXmlFixtures';
import {fakeNcmecServer} from '@app/api/test/Setup';
import {describe, expect, test} from 'vitest';
function reporter(): NcmecReporter {
return new NcmecReporter({config: createNcmecApiConfig(), fetch});
}
function validReportXml(overrides: {priorNcmecReportIds?: Array<string>} = {}): string {
return buildNcmecReportXml({
attachmentUrl: 'https://cdn.example.com/x.png',
reportedAt: new Date('2026-04-15T12:00:00Z'),
reporterFullName: 'Lilith Example',
reporterEmail: '[email protected]',
reportedUser: {
id: 42n,
screenName: 'evil-user',
displayName: 'Evil User',
espService: 'Fluxer',
permanentlyDisabledAt: null,
person: {
emails: [{address: '[email protected]', type: 'Home', verified: true}],
phones: [{number: '+15551234567', type: 'Mobile'}],
dateOfBirth: '1999-02-03',
},
ipCaptureEvents: [
{
ipAddress: '203.0.113.10',
eventName: 'Upload',
dateTime: new Date('2026-04-15T11:55:00Z'),
},
],
},
priorNcmecReportIds: overrides.priorNcmecReportIds ?? [],
additionalInfo: 'Test report.',
});
}
describe('NcmecReporter', () => {
test('submitReport returns a numeric report ID', async () => {
const reportId = await reporter().submitReport(validReportXml());
expect(reportId).toMatch(/^\d+$/);
expect(fakeNcmecServer.getReport(reportId)).not.toBeNull();
});
test('uploadEvidence returns the server-computed md5', async () => {
const r = reporter();
const reportId = await r.submitReport(validReportXml());
const upload = await r.uploadEvidence(reportId, new Uint8Array([1, 2, 3]), 'evidence.png');
expect(upload.fileId).toMatch(/^file-\d+$/);
expect(upload.md5).toMatch(/^[0-9a-f]{32}$/);
});
test('submitFileDetails marks the file as having details submitted', async () => {
const r = reporter();
const reportId = await r.submitReport(validReportXml());
const {fileId} = await r.uploadEvidence(reportId, new Uint8Array([4, 5, 6]), 'evidence.png');
await r.submitFileDetails(
buildNcmecFileDetailsXml({
reportId,
fileId,
filename: 'evidence.png',
uploadedToEspTimestamp: new Date('2026-04-15T11:55:00Z'),
fileViewedByEsp: true,
ipCaptureEvent: {
ipAddress: '203.0.113.10',
eventName: 'Upload',
dateTime: new Date('2026-04-15T11:55:00Z'),
},
additionalInfo: 'context',
}),
);
const record = fakeNcmecServer.getReport(reportId)!;
expect(record.files.find((f) => f.fileId === fileId)?.submittedDetails).toBe(true);
});
test('submitFileDetails throws when file details fail documented validation', async () => {
await expect(
reporter().submitFileDetails(`<?xml version="1.0" encoding="UTF-8"?>
<fileDetails xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<originalFileName>missing-ids.jpg</originalFileName>
</fileDetails>`),
).rejects.toThrow('responseCode 4100');
});
test('finish returns every uploaded file id', async () => {
const r = reporter();
const reportId = await r.submitReport(validReportXml());
const a = await r.uploadEvidence(reportId, new Uint8Array([7]), 'a.png');
const b = await r.uploadEvidence(reportId, new Uint8Array([8]), 'b.png');
const result = await r.finish(reportId);
expect(result.reportId).toBe(reportId);
expect(result.fileIds).toEqual([a.fileId, b.fileId]);
});
test('retract marks the report as retracted', async () => {
const r = reporter();
const reportId = await r.submitReport(validReportXml());
await r.retract(reportId);
expect(fakeNcmecServer.getReport(reportId)!.retracted).toBe(true);
});
test('submitReport throws when the server rejects the XML', async () => {
await expect(reporter().submitReport(INVALID_REPORT_XML)).rejects.toThrow('responseCode 4100');
});
test('submitReport surfaces the documented malformed-xml response code', async () => {
await expect(reporter().submitReport(MALFORMED_REPORT_XML)).rejects.toThrow('responseCode 4110');
});
test('submitReport throws when the server returns 401', async () => {
fakeNcmecServer.setFailure('unauthorized');
await expect(reporter().submitReport(validReportXml())).rejects.toThrow(/http 401/);
fakeNcmecServer.setFailure('none');
});
test('buildNcmecReportXml emits personOrUserReported with priorCTReports and espIdentifier', () => {
const xml = validReportXml({priorNcmecReportIds: ['NCMEC-100', 'NCMEC-200']});
expect(xml).toContain('<personOrUserReported>');
expect(xml).toContain('<personOrUserReportedPerson>');
expect(xml).toContain('<espIdentifier>42</espIdentifier>');
expect(xml).toContain('<espService>Fluxer</espService>');
expect(xml).toContain('<screenName>evil-user</screenName>');
expect(xml).toContain('<displayName>Evil User</displayName>');
expect(xml).toContain('<email type="Home" verified="true">[email protected]</email>');
expect(xml).toContain('<phone type="Mobile">+15551234567</phone>');
expect(xml).toContain('<dateOfBirth>1999-02-03</dateOfBirth>');
expect(xml).toContain('<ipAddress>203.0.113.10</ipAddress>');
expect(xml).toContain('<eventName>Upload</eventName>');
expect(xml).toContain('<priorCTReports>NCMEC-100</priorCTReports>');
expect(xml).toContain('<priorCTReports>NCMEC-200</priorCTReports>');
expect(xml).toContain('<allEmailsReported>true</allEmailsReported>');
expect(xml).toContain('<incidentType>Child Pornography (possession, manufacture, and distribution)</incidentType>');
expect(xml).toContain('<platform>Fluxer</platform>');
});
test('buildNcmecReportXml emits incident additionalInfo at the report root', () => {
const xml = validReportXml();
expect(xml).toContain('<additionalInfo>Test report.</additionalInfo>');
expect(xml).toMatch(/<\/personOrUserReported>\s*<additionalInfo>Test report\.<\/additionalInfo>\s*<\/report>/);
});
test('buildNcmecReportXml emits accountPermanentlyDisabled when the user was banned', () => {
const xml = buildNcmecReportXml({
attachmentUrl: 'https://cdn.example.com/x.png',
reportedAt: new Date('2026-04-15T12:00:00Z'),
reporterFullName: 'Lilith Example',
reporterEmail: '[email protected]',
reportedUser: {
id: 42n,
screenName: null,
displayName: null,
espService: null,
permanentlyDisabledAt: new Date('2026-04-14T08:00:00Z'),
person: null,
ipCaptureEvents: [],
},
priorNcmecReportIds: [],
additionalInfo: null,
});
expect(xml).toContain('<accountPermanentlyDisabled disabledDate="2026-04-14T08:00:00.000Z"');
});
test('buildNcmecFileDetailsXml emits upload metadata for the uploaded file', () => {
const xml = buildNcmecFileDetailsXml({
reportId: 'NCMEC-100',
fileId: 'file-100',
filename: 'evidence.png',
uploadedToEspTimestamp: new Date('2026-04-15T11:55:00Z'),
fileViewedByEsp: true,
ipCaptureEvent: {
ipAddress: '203.0.113.10',
eventName: 'Upload',
dateTime: new Date('2026-04-15T11:55:00Z'),
},
additionalInfo: 'Uploaded from Fluxer',
});
expect(xml).toContain('<uploadedToEspTimestamp>2026-04-15T11:55:00.000Z</uploadedToEspTimestamp>');
expect(xml).toContain('<fileViewedByEsp>true</fileViewedByEsp>');
expect(xml).toContain('<ipCaptureEvent>');
expect(xml).toContain('<ipAddress>203.0.113.10</ipAddress>');
expect(xml).toContain('<additionalInfo>Uploaded from Fluxer</additionalInfo>');
});
test('buildNcmecReportXml requires a reporter email address', () => {
expect(() =>
buildNcmecReportXml({
attachmentUrl: 'https://cdn.example.com/x.png',
reportedAt: new Date('2026-04-15T12:00:00Z'),
reporterFullName: 'Lilith Example',
reporterEmail: '' as string,
reportedUser: {
id: 42n,
screenName: null,
displayName: null,
espService: null,
permanentlyDisabledAt: null,
person: null,
ipCaptureEvents: [],
},
priorNcmecReportIds: [],
additionalInfo: null,
}),
).toThrow('NCMEC reporter email');
});
});
@@ -1,75 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface NcmecAttachmentSubmissionRow {
attachment_id: bigint;
user_id: bigint | null;
channel_id: bigint;
message_id: bigint;
filename: string;
source_report_id: bigint | null;
status: string;
ncmec_report_id: string | null;
ncmec_file_id: string | null;
submitted_at: Date | null;
submitted_by_admin_id: bigint | null;
reporter_full_name: string | null;
failure_reason: string | null;
archive_completed_at: Date | null;
content_deleted_at: Date | null;
created_at: Date;
updated_at: Date;
}
export const NCMEC_ATTACHMENT_SUBMISSION_COLUMNS = [
'attachment_id',
'user_id',
'channel_id',
'message_id',
'filename',
'source_report_id',
'status',
'ncmec_report_id',
'ncmec_file_id',
'submitted_at',
'submitted_by_admin_id',
'reporter_full_name',
'failure_reason',
'archive_completed_at',
'content_deleted_at',
'created_at',
'updated_at',
] as const satisfies ReadonlyArray<keyof NcmecAttachmentSubmissionRow>;
export interface NcmecUserWorkflowRow {
user_id: bigint;
deleted_at: Date | null;
deleted_by_admin_id: bigint | null;
deletion_private_reason: string | null;
deletion_ncmec_report_id: string | null;
archive_id: bigint | null;
archive_requested_at: Date | null;
archive_requested_by_admin_id: bigint | null;
archive_audit_log_reason: string | null;
archive_completed_at: Date | null;
deletion_job_queued_at: Date | null;
previous_report_ids: Set<string> | null;
created_at: Date;
updated_at: Date;
}
export const NCMEC_USER_WORKFLOW_COLUMNS = [
'user_id',
'deleted_at',
'deleted_by_admin_id',
'deletion_private_reason',
'deletion_ncmec_report_id',
'archive_id',
'archive_requested_at',
'archive_requested_by_admin_id',
'archive_audit_log_reason',
'archive_completed_at',
'deletion_job_queued_at',
'previous_report_ids',
'created_at',
'updated_at',
] as const satisfies ReadonlyArray<keyof NcmecUserWorkflowRow>;
@@ -37,7 +37,6 @@ const EXPECTED_LOADS: Readonly<Record<string, readonly [number, Coverage]>> = {
'channel/services/message/UserMessageDeletionService.ts': [2, 'admin_or_system'],
'channel/services/thread/ThreadCreationService.ts': [4, 'gated'],
'channel/services/thread/ThreadDeletionService.ts': [1, 'admin_or_system'],
'csam/NcmecSubmissionService.ts': [1, 'admin_or_system'],
'guild/services/channel/ChannelOperationsService.ts': [2, 'gated'],
'guild/services/GuildDiscoveryService.ts': [1, 'gated'],
'guild/services/data/GuildOperationsService.ts': [2, 'gated'],
@@ -92,7 +92,6 @@ import {
getKVBulkMessageDeletionQueue,
getKVThreadAutoArchiveQueue,
getLimitConfigService,
getNcmecSubmissionService,
getOAuth2TokenRepository,
getPasswordChangeRepository,
getPremiumStateReconciliationQueueService,
@@ -428,10 +427,6 @@ class RequestServices implements RequestScopedServices {
return getLimitConfigService();
}
get ncmecSubmissionService() {
return getNcmecSubmissionService();
}
get oauth2TokenRepository() {
return getOAuth2TokenRepository();
}
@@ -5,16 +5,12 @@ import {AdminApiKeyRepository} from '@app/api/admin/repositories/AdminApiKeyRepo
import {AdminArchiveRepository} from '@app/api/admin/repositories/AdminArchiveRepository';
import {AdminApiKeyService} from '@app/api/admin/services/AdminApiKeyService';
import {AdminArchiveService} from '@app/api/admin/services/AdminArchiveService';
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {Config} from '@app/api/Config';
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
import {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
import {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService';
import type {APIConfig} from '@app/api/config/APIConfig';
import {ConnectionRepository} from '@app/api/connection/ConnectionRepository';
import {createNcmecApiConfig, NcmecReporter} from '@app/api/csam/NcmecReporter';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import {DonationRepository} from '@app/api/donation/DonationRepository';
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
import {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
@@ -321,40 +317,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
}
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
const getNcmecRepository = singleton(() => new NcmecRepository());
export const getAttachmentUploadTraceRepository = singleton(() => new AttachmentUploadTraceRepository());
export const getNcmecSubmissionService = singleton(
() =>
new NcmecSubmissionService({
reportRepository: getReportRepository(),
ncmecApi: getNcmecReporter(),
ncmecRepository: getNcmecRepository(),
attachmentUploadTraceRepository: getAttachmentUploadTraceRepository(),
storageService: getStorageService(),
channelRepository: getChannelRepository(),
userRepository: getUserRepository(),
guildRepository: getGuildRepository(),
gatewayService: getGatewayService(),
userCacheService: createUserCacheService(),
adminArchiveService: new AdminArchiveService(
getAdminArchiveRepository(),
getUserRepository(),
getGuildRepository(),
getStorageService(),
getSnowflakeService(),
getWorkerService(),
),
adminAuditService: new AdminAuditService(getAdminRepository(), getSnowflakeService(), {
userRepository: getUserRepository(),
guildRepository: getGuildRepository(),
channelRepository: getChannelRepository(),
}),
purgeQueue: getPurgeQueue(),
workerService: getWorkerService(),
deletionQueue: getKVAccountDeletionQueue(),
}),
);
let _virusScanInitPromise: Promise<void> | null = null;
@@ -48,7 +48,6 @@ const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
'limitConfigService',
'mediaService',
'messageRequestService',
'ncmecSubmissionService',
'oauth2ApplicationsRequestService',
'oauth2RequestService',
'oauth2Service',
-4
View File
@@ -39734,8 +39734,6 @@
"MAX_THREAD_MEMBERS",
"MAX_WEBHOOKS_PER_CHANNEL",
"MAX_WEBHOOKS_PER_GUILD",
"NCMEC_ALREADY_SUBMITTED",
"NCMEC_SUBMISSION_FAILED",
"MEDIA_METADATA_ERROR",
"METHOD_NOT_ALLOWED",
"MISSING_ACCESS",
@@ -40031,8 +40029,6 @@
"This thread has reached its member limit of {count}.",
"You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per channel.",
"You've reached the maximum of {count, plural, one {# webhook} other {# webhooks}} per community.",
"This content has already been submitted to NCMEC.",
"We couldn't submit the report to NCMEC. Please try again later.",
"Media metadata error.",
"Method not allowed.",
"You don't have access to this resource or feature.",
@@ -11,7 +11,7 @@ import {
} from '@app/api/auth/tests/AuthTestUtils';
import {createGuildID, createReportID, createUserID} from '@app/api/BrandedTypes';
import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
import {Config} from '@app/api/Config';
import {Config, getConfig} from '@app/api/Config';
import {loadFixture} from '@app/api/channel/tests/AttachmentTestUtils';
import {
acceptInvite,
@@ -27,7 +27,7 @@ import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {GuildMemberRepository} from '@app/api/guild/repositories/GuildMemberRepository';
import {resetActivityEventsForTests, startActivityEvents} from '@app/api/infrastructure/activity/ActivityEvents';
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
import {getRateLimitService} from '@app/api/middleware/ServiceSingletons';
import {getInstanceConfigRepository, getRateLimitService} from '@app/api/middleware/ServiceSingletons';
import {getReportFlowVariant, type ReportFlowStepInput} from '@app/api/report/flows/ReportFlowRegistry';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
@@ -46,6 +46,7 @@ import {
executeWebhook,
executeWebhookWithAttachments,
} from '@app/api/webhook/tests/WebhookTestUtils';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
@@ -336,14 +337,7 @@ async function expectStoredAuthor(reportId: string, world: World, kind: AuthorKi
}
async function createAdmin(harness: ApiTestHarness): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REPORT_VIEW,
AdminACLs.CSAM_SUBMIT_NCMEC,
AdminACLs.MESSAGE_DELETE,
AdminACLs.USER_DELETE,
AdminACLs.ARCHIVE_TRIGGER_USER,
]);
return setUserACLs(harness, await createTestAccount(harness), [AdminACLs.AUTHENTICATE, AdminACLs.REPORT_VIEW]);
}
describe('Reports of webhook and bot messages', () => {
@@ -577,6 +571,33 @@ describe('Reports of webhook and bot messages', () => {
});
});
test('the webhook creator tag follows the tag style of the instance', async () => {
const config = getConfig();
const originalSelfHosted = config.instance.selfHosted;
config.instance.selfHosted = true;
try {
await getInstanceConfigRepository().setAccountIdentityMode(AccountIdentityModes.EMAIL, 'setup', TagStyles.NONE);
const world = await setupWorld(harness);
const report = await submitFlow(harness, world.reporter.token, world.channelId, world.messages.webhook)
.expect(HTTP_STATUS.OK)
.execute();
const admin = await createAdmin(harness);
const detail = await createBuilder<AdminReport>(harness, admin.token)
.get(`/admin/reports/${report.report_id}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(detail).toMatchObject({
reported_webhook_creator_id: world.owner.userId,
reported_webhook_creator_tag: world.creator.username,
reported_webhook_creator_username: world.creator.username,
reported_webhook_creator_discriminator: '0000',
});
} finally {
config.instance.selfHosted = originalSelfHosted;
getInstanceConfigRepository().clearCacheForTesting();
}
});
test('the admin API shows webhook fields and search filters by webhook', async () => {
const world = await setupWorld(harness);
const webhookReport = await submitFlow(harness, world.reporter.token, world.channelId, world.messages.webhook)
@@ -624,7 +645,6 @@ describe('Reports of webhook and bot messages', () => {
author_id: world.webhook.id,
author_username: WEBHOOK_USERNAME,
webhook_id: world.webhook.id,
user_prior_ncmec_report_ids: [],
});
const neighbor = detail.message_context!.find((entry) => entry.id === world.messages.user)!;
expect(neighbor).toMatchObject({author_id: world.author.userId, webhook_id: null});
@@ -751,7 +771,7 @@ describe('Reports of webhook and bot messages', () => {
});
});
test('webhook attachments are kept as evidence and can go to NCMEC through the report', async () => {
test('webhook attachments are kept as evidence after the message is deleted', async () => {
const world = await setupWorld(harness);
const {json: message} = await executeWebhookWithAttachments(harness, {
webhookId: world.webhook.id,
@@ -770,25 +790,15 @@ describe('Reports of webhook and bot messages', () => {
const key = `attachments/${world.channelId}/${attachment.id}/evidence.png`;
expect(harness.storageService.hasObject(Config.s3.buckets.reports, key)).toBe(true);
await deleteWebhookMessageByToken(harness, world.webhook.id, world.webhook.token, message!.id);
expect(harness.storageService.hasObject(Config.s3.buckets.reports, key)).toBe(true);
const admin = await createAdmin(harness);
await createBuilder(harness, admin.token)
.post('/admin/messages/ncmec-reports')
.body({
channel_id: world.channelId,
message_id: message!.id,
attachment_id: attachment.id,
filename: 'evidence.png',
reporter_full_name: 'Webhook Reporter',
confirmed_viewed: true,
source_report_id: result.report_id,
})
.expect(HTTP_STATUS.OK)
.execute();
const detail = await createBuilder<AdminReport>(harness, admin.token)
.get(`/admin/reports/${result.report_id}`)
.expect(HTTP_STATUS.OK)
.execute();
const evidence = detail.message_context!.find((entry) => entry.id === message!.id)!;
expect(evidence.attachments[0]?.ncmec_status).not.toBe('not_submitted');
expect(evidence.attachments.map((entry) => ({id: entry.id, filename: entry.filename}))).toEqual([
{id: attachment.id, filename: 'evidence.png'},
]);
});
});
-11
View File
@@ -23,7 +23,6 @@ import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {MockSnowflakeService} from '@app/api/test/mocks/MockSnowflakeService';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {RepositoryBackedMessageResponseDataService} from '@app/api/test/mocks/RepositoryBackedMessageResponseDataService';
import {fakeNcmecServer} from '@app/api/test/msw/handlers/NcmecHandlers';
import {server} from '@app/api/test/msw/server';
import {mapUserToPartialResponse} from '@app/api/user/UserMappers';
import {loadConfig} from '@fluxer/config/src/ConfigLoader';
@@ -184,13 +183,6 @@ const testApiConfig: APIConfig = {
sandboxUserIds: [],
sandboxEntitlesAll: false,
},
ncmec: {
...apiConfig.ncmec,
enabled: true,
baseUrl: fakeNcmecServer.baseUrl,
username: 'usr123',
password: 'pswd123',
},
};
testApiConfig.dev.relaxRegistrationRateLimits = true;
testApiConfig.dev.disableRateLimits = true;
@@ -211,8 +203,6 @@ setInjectedMessageResponseDataService(new RepositoryBackedMessageResponseDataSer
enableSearchTaskTracking();
export {fakeNcmecServer};
beforeAll(async () => {
server.listen({
onUnhandledRequest: 'error',
@@ -224,7 +214,6 @@ afterEach(async () => {
resetCassandraQueryExecutorForTesting();
getInstanceConfigRepository().clearCacheForTesting();
server.resetHandlers();
fakeNcmecServer.reset();
});
afterAll(async () => {
@@ -1,28 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const INVALID_REPORT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<report xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<reporter>
<reportingPerson>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>jsmith@example.com</email>
</reportingPerson>
</reporter>
</report>`;
export const MALFORMED_REPORT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<report xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<incidentSummary>
<incidentType>Child Pornography (possession, manufacture, and distribution)</incidentType>
<incidentDateTime>2012-10-15T08:00:00-07:00</incidentDateTime>
</incidentSummary>
<reporter>
<reportingPerson>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>jsmith@example.com</email>
</reportingPerson>
</reporter>
</report`;
@@ -1,284 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
import {XMLParser, XMLValidator} from 'fast-xml-parser';
import {HttpResponse, http, type RequestHandler} from 'msw';
type FakeNcmecFailure = 'none' | 'submit' | 'upload' | 'fileinfo' | 'finish' | 'retract' | 'unauthorized';
interface FakeNcmecFileRecord {
fileId: string;
filename: string;
size: number;
md5: string;
submittedDetails: boolean;
detailsXml: string | null;
}
interface FakeNcmecReportRecord {
reportId: string;
reportXml: string;
files: Array<FakeNcmecFileRecord>;
finished: boolean;
retracted: boolean;
authUsername: string;
}
const BASE_URL = 'https://ncmec.test/ispws';
const USERNAME = 'usr123';
const PASSWORD = 'pswd123';
const xmlParser = new XMLParser({ignoreAttributes: false, parseTagValue: false, trimValues: true});
class MswNcmecServer {
readonly baseUrl = BASE_URL;
private reportCounter = 1000000;
private fileCounter = 1;
private requestCounter = 1;
private readonly reports = new Map<string, FakeNcmecReportRecord>();
private failureMode: FakeNcmecFailure = 'none';
reset(): void {
this.reports.clear();
this.reportCounter = 1000000;
this.fileCounter = 1;
this.requestCounter = 1;
this.failureMode = 'none';
}
setFailure(mode: FakeNcmecFailure): void {
this.failureMode = mode;
}
getReports(): Array<FakeNcmecReportRecord> {
return [...this.reports.values()];
}
getReport(reportId: string): FakeNcmecReportRecord | null {
return this.reports.get(reportId) ?? null;
}
createHandlers(): Array<RequestHandler> {
return [
http.get(`${BASE_URL}/status`, ({request}) => this.handleStatus(request)),
http.get(`${BASE_URL}/xsd`, () => this.text(200, '<schema></schema>', 'text/plain; charset=utf-8')),
http.post(`${BASE_URL}/submit`, ({request}) => this.handleSubmit(request)),
http.post(`${BASE_URL}/upload`, ({request}) => this.handleUpload(request)),
http.post(`${BASE_URL}/fileinfo`, ({request}) => this.handleFileInfo(request)),
http.post(`${BASE_URL}/finish`, ({request}) => this.handleFinish(request)),
http.post(`${BASE_URL}/retract`, ({request}) => this.handleRetract(request)),
];
}
private handleStatus(request: Request): HttpResponse<string> {
const unauthorized = this.maybeUnauthorized(request);
if (unauthorized) return unauthorized;
return this.xml(
200,
ok(`<responseDescription>Remote User : ${USERNAME}, Remote Ip : 127.0.0.1</responseDescription>`),
);
}
private async handleSubmit(request: Request): Promise<HttpResponse<string>> {
const failure = this.effectiveFailure(request);
const unauthorized = this.maybeUnauthorized(request, failure);
if (unauthorized) return unauthorized;
if (failure === 'submit') return this.xml(200, errorXml(1100, 'Save failed'));
if (!isXmlContentType(request)) return this.xml(200, errorXml(4000, 'Invalid request'));
const body = await request.text();
if (XMLValidator.validate(body) !== true) return this.xml(200, errorXml(4110, 'Malformed XML submittal'));
const report = parseReportDocument(body);
if (!hasRequiredReportFields(report)) return this.xml(200, errorXml(4100, 'Validation failed'));
const reportId = String(this.reportCounter++);
this.reports.set(reportId, {
reportId,
reportXml: body,
files: [],
finished: false,
retracted: false,
authUsername: USERNAME,
});
return this.xml(200, ok(`<reportId>${reportId}</reportId>`));
}
private async handleUpload(request: Request): Promise<HttpResponse<string>> {
const failure = this.effectiveFailure(request);
const unauthorized = this.maybeUnauthorized(request, failure);
if (unauthorized) return unauthorized;
if (failure === 'upload') return this.xml(200, errorXml(1111, 'File upload failed'));
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const file = form.get('file');
const record = this.reports.get(reportId);
if (!reportId || !record) return this.xml(200, errorXml(5001, 'Report does not exist'));
if (record.retracted) return this.xml(200, errorXml(5101, 'Report already retracted'));
if (record.finished) return this.xml(200, errorXml(5102, 'Report already finished'));
if (!(file instanceof Blob)) return this.xml(200, errorXml(4200, 'Malformed file submittal'));
const buffer = Buffer.from(await file.arrayBuffer());
const md5 = crypto.createHash('md5').update(buffer).digest('hex');
const fileId = `file-${this.fileCounter++}`;
const fileRecord: FakeNcmecFileRecord = {
fileId,
filename: (file as File).name || 'upload.bin',
size: buffer.byteLength,
md5,
submittedDetails: false,
detailsXml: null,
};
record.files.push(fileRecord);
return this.xml(200, ok(`<reportId>${reportId}</reportId><fileId>${fileId}</fileId><hash>${md5}</hash>`));
}
private async handleFileInfo(request: Request): Promise<HttpResponse<string>> {
const failure = this.effectiveFailure(request);
const unauthorized = this.maybeUnauthorized(request, failure);
if (unauthorized) return unauthorized;
if (failure === 'fileinfo') return this.xml(200, errorXml(1300, 'Update failed'));
if (!isXmlContentType(request)) return this.xml(200, errorXml(4000, 'Invalid request'));
const body = await request.text();
if (XMLValidator.validate(body) !== true) return this.xml(200, errorXml(4110, 'Malformed XML submittal'));
const fileDetails = parseFileDetailsDocument(body);
if (!hasFileDetailsFields(fileDetails)) return this.xml(200, errorXml(4100, 'Validation failed'));
const reportId = nonBlankString(fileDetails?.reportId);
const fileId = nonBlankString(fileDetails?.fileId);
const record = reportId ? this.reports.get(reportId) : null;
if (!record) return this.xml(200, errorXml(5001, 'Report does not exist'));
const file = record.files.find((candidate) => candidate.fileId === fileId);
if (!file) return this.xml(200, errorXml(5002, 'File does not exist'));
file.submittedDetails = true;
file.detailsXml = body;
return this.xml(200, ok(`<reportId>${reportId}</reportId>`));
}
private async handleFinish(request: Request): Promise<HttpResponse<string>> {
const failure = this.effectiveFailure(request);
const unauthorized = this.maybeUnauthorized(request, failure);
if (unauthorized) return unauthorized;
if (failure === 'finish') return this.xml(200, errorXml(1300, 'Update failed'));
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const record = this.reports.get(reportId);
if (!record) return this.xml(200, errorXml(5001, 'Report does not exist'));
record.finished = true;
const files = record.files.map((file) => ` <fileId>${file.fileId}</fileId>`).join('\n');
return this.xml(
200,
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportDoneResponse>
<responseCode>0</responseCode>
<reportId>${reportId}</reportId>
<files>
${files}
</files>
</reportDoneResponse>`,
);
}
private async handleRetract(request: Request): Promise<HttpResponse<string>> {
const failure = this.effectiveFailure(request);
const unauthorized = this.maybeUnauthorized(request, failure);
if (unauthorized) return unauthorized;
if (failure === 'retract') return this.xml(200, errorXml(1300, 'Update failed'));
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const record = this.reports.get(reportId);
if (!record) return this.xml(200, errorXml(5001, 'Report does not exist'));
record.retracted = true;
return this.xml(200, ok(`<reportId>${reportId}</reportId>`));
}
private effectiveFailure(request: Request): FakeNcmecFailure {
return ((request.headers.get('x-test-fail') ?? '') || this.failureMode) as FakeNcmecFailure;
}
private maybeUnauthorized(request: Request, failure = this.failureMode): HttpResponse<string> | null {
if (failure === 'unauthorized') return this.text(401, '');
const auth = request.headers.get('authorization') ?? '';
if (!auth.toLowerCase().startsWith('basic ')) return this.text(401, '');
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf-8');
return decoded === `${USERNAME}:${PASSWORD}` ? null : this.text(401, '');
}
private xml(status: number, body: string): HttpResponse<string> {
return this.text(status, body, 'application/xml; charset=utf-8');
}
private text(status: number, body: string, contentType = 'text/plain; charset=utf-8'): HttpResponse<string> {
return new HttpResponse(body, {
status,
headers: {
'content-type': contentType,
'Request-ID': `req-${this.requestCounter++}`,
},
});
}
}
function ok(extra: string): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Success</responseDescription>
${extra}
</reportResponse>`;
}
function errorXml(code: number, description: string): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>${code}</responseCode>
<responseDescription>${description}</responseDescription>
</reportResponse>`;
}
function isXmlContentType(request: Request): boolean {
return (request.headers.get('content-type') ?? '').toLowerCase().includes('text/xml');
}
function parseReportDocument(xml: string): Record<string, unknown> | null {
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
return objectField(parsed, 'report');
}
function parseFileDetailsDocument(xml: string): Record<string, unknown> | null {
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
return objectField(parsed, 'fileDetails');
}
function hasRequiredReportFields(report: Record<string, unknown> | null): boolean {
const incidentSummary = objectField(report, 'incidentSummary');
const reporter = objectField(report, 'reporter');
const reportingPerson = objectField(reporter, 'reportingPerson');
return Boolean(
nonBlankString(incidentSummary?.incidentType) &&
nonBlankString(incidentSummary?.incidentDateTime) &&
nonBlankString(reportingPerson?.email),
);
}
function hasFileDetailsFields(fileDetails: Record<string, unknown> | null): boolean {
return Boolean(nonBlankString(fileDetails?.reportId) && nonBlankString(fileDetails?.fileId));
}
function objectField(value: unknown, key: string): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const field = (value as Record<string, unknown>)[key];
if (!field || typeof field !== 'object' || Array.isArray(field)) return null;
return field as Record<string, unknown>;
}
function nonBlankString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return null;
}
export const fakeNcmecServer = new MswNcmecServer();
export function createNcmecHandlers(): Array<RequestHandler> {
return fakeNcmecServer.createHandlers();
}
-2
View File
@@ -1,13 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createNcmecHandlers} from '@app/api/test/msw/handlers/NcmecHandlers';
import {createOnionooDetailsHandler} from '@app/api/test/msw/handlers/OnionooHandlers';
import {createOpenNsfwHandlers} from '@app/api/test/msw/handlers/OpenNsfwHandlers';
import {createPwnedPasswordsRangeHandler} from '@app/api/test/msw/handlers/PwnedPasswordsHandlers';
import {setupServer} from 'msw/node';
export const server = setupServer(
...createNcmecHandlers(),
...createOpenNsfwHandlers(),
createOnionooDetailsHandler(),
createPwnedPasswordsRangeHandler(),
-2
View File
@@ -18,7 +18,6 @@ import type {StreamService} from '@app/api/channel/services/StreamService';
import type {ThreadService} from '@app/api/channel/services/thread/ThreadService';
import type {ConnectionRequestService} from '@app/api/connection/ConnectionRequestService';
import type {ConnectionService} from '@app/api/connection/ConnectionService';
import type {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {DonationService} from '@app/api/donation/DonationService';
import type {FavoriteMemeRequestService} from '@app/api/favorite_meme/FavoriteMemeRequestService';
import type {FavoriteMemeService} from '@app/api/favorite_meme/FavoriteMemeService';
@@ -160,7 +159,6 @@ export interface HonoEnv {
reportService: ReportService;
reportRequestService: ReportRequestService;
contactChangeLogService: UserContactChangeLogService;
ncmecSubmissionService: NcmecSubmissionService;
requestCache: RequestCache;
rpcService: RpcService;
searchService: SearchService;
@@ -20,7 +20,6 @@ export const EXTERNAL_RESPONSE_LIMITS = {
oEmbedBytes: 256 * 1024,
pwnedPasswordsBytes: 1024 * 1024,
externalTemplateBytes: 512 * 1024,
ncmecResponseBytes: 64 * 1024,
fileBlocklistBytes: 25 * 1024 * 1024,
urlBlocklistBytes: 25 * 1024 * 1024,
} as const;
@@ -9,7 +9,6 @@ import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import type {ConnectionRepository} from '@app/api/connection/ConnectionRepository';
import {ConnectionService} from '@app/api/connection/ConnectionService';
import type {NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import {DonationRepository} from '@app/api/donation/DonationRepository';
import type {IDonationRepository} from '@app/api/donation/IDonationRepository';
import type {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
@@ -75,7 +74,6 @@ import {
getKVActivityTracker,
getKVBulkMessageDeletionQueue,
getLimitConfigService,
getNcmecSubmissionService,
getOAuth2TokenRepository,
getPremiumStateReconciliationQueueService,
getPurgeQueue,
@@ -167,7 +165,6 @@ export interface WorkerDependencies {
channelService: ChannelService;
guildAuditLogService: GuildAuditLogService;
contactChangeLogService: UserContactChangeLogService;
ncmecSubmissionService: NcmecSubmissionService;
donationRepository: IDonationRepository;
guildService: GuildService;
billingRepository: BillingRepository;
@@ -207,7 +204,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
const connectionService = new ConnectionService(connectionRepository, gatewayService);
const mediaService = getMediaService();
const discriminatorService = getDiscriminatorService();
const ncmecSubmissionService = getNcmecSubmissionService();
const avatarService = getAvatarService();
const entityAssetService = getEntityAssetService();
await ensureVirusScanInitialized();
@@ -329,7 +325,6 @@ export async function initializeWorkerDependencies(snowflakeService: ISnowflakeS
storeEntitlementService,
guildAuditLogService,
contactChangeLogService,
ncmecSubmissionService,
get stripe() {
return getStripeClient();
},
@@ -44,7 +44,6 @@ const LANE_CONFIG = {
'bulkDeleteUserMessagesScoped',
'deleteUserMessagesInGuildByTime',
'messageShred',
'finalizeNcmecAttachmentReport',
'harvestGuildData',
'harvestUserData',
'batchGuildAuditLogMessageDeletes',
@@ -61,7 +60,7 @@ const LANE_CONFIG = {
'bulkDeleteMessagesForUsers',
'drainActivitySpool',
] as const,
retiredTasks: ['sendScheduledMessage'],
retiredTasks: ['sendScheduledMessage', 'finalizeNcmecAttachmentReport'],
concurrency: 8,
maxAckPending: 50,
ackWaitMs: 60000,
@@ -20,7 +20,6 @@ import expireAttachments from '@app/api/worker/tasks/ExpireAttachments';
import expireReportSnapshots from '@app/api/worker/tasks/ExpireReportSnapshots';
import expireStaleJobs from '@app/api/worker/tasks/ExpireStaleJobs';
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
import finalizeNcmecAttachmentReport from '@app/api/worker/tasks/FinalizeNcmecAttachmentReport';
import flushUserActivityBuffer from '@app/api/worker/tasks/FlushUserActivityBuffer';
import handleMentionChunk from '@app/api/worker/tasks/HandleMentionChunk';
import handleMentions from '@app/api/worker/tasks/HandleMentions';
@@ -93,7 +92,6 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
expireReportSnapshots,
expireStaleJobs,
extractEmbeds,
finalizeNcmecAttachmentReport,
handleMentions,
handleMentionChunk,
harvestGuildData,
@@ -1,26 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAttachmentID} from '@app/api/BrandedTypes';
import {Logger} from '@app/api/Logger';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
const PayloadSchema = z.object({
attachmentId: z.string(),
requeueCount: z.number().int().min(0).optional(),
});
const finalizeNcmecAttachmentReport: WorkerTaskHandler = async (payload, helpers) => {
const validated = PayloadSchema.parse(payload);
helpers.logger.debug({payload: validated}, 'Processing finalizeNcmecAttachmentReport task');
const attachmentId = createAttachmentID(BigInt(validated.attachmentId));
try {
const deps = getWorkerDependencies();
await deps.ncmecSubmissionService.finalizeAttachmentReport(attachmentId, validated.requeueCount ?? 0);
} catch (error) {
Logger.error({error, attachmentId: attachmentId.toString()}, 'Failed to finalize NCMEC attachment report');
throw error;
}
};
export default finalizeNcmecAttachmentReport;
@@ -70,7 +70,7 @@ describe('WorkerLaneConfig', () => {
expect(retired).toEqual({
realtime: [],
unfurl: [],
lifecycle: ['sendScheduledMessage'],
lifecycle: ['sendScheduledMessage', 'finalizeNcmecAttachmentReport'],
batch: [],
crosspost: [],
});
@@ -140,7 +140,7 @@ The acting credential must already have every value in `acls`, unless it has `*`
| --- | --- | --- |
| name<sup>1</sup> | string | The name given to the key (1-100 characters) |
| expires_in_days?<sup>2</sup> | integer | The number of days until the key expires (1-365) |
| acls<sup>3</sup> | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key, each a registry value (at most 106) |
| acls<sup>3</sup> | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key, each a registry value (at most 105) |
<sup>1</sup> A value that is empty after trimming is rejected, so whitespace alone is not a name
@@ -218,7 +218,7 @@ An update never rotates the credential, and no field on this route changes the e
| Field | Type | Description |
| --- | --- | --- |
| name?<sup>1</sup> | string | The replacement name for the key (1-100 characters) |
| acls?<sup>2</sup> | array[string] | The complete replacement set of [ACLs](/admin-api/#acl-registry), each a registry value (at most 106) |
| acls?<sup>2</sup> | array[string] | The complete replacement set of [ACLs](/admin-api/#acl-registry), each a registry value (at most 105) |
<sup>1</sup> A value that is empty after trimming is rejected
@@ -36,7 +36,7 @@ Admin authorisation runs before request validation on every operation except [Qu
An operation names one or more ACLs and admits the request when the caller holds at least one of them. The wildcard `*` satisfies every requirement on its own.
Where an operation stacks several requirements, Fluxer evaluates each separately and the caller must satisfy all of them. [Create NCMEC report](/admin-api/messages/#create-ncmec-report) is the widest example and stacks `csam:submit_ncmec`, `message:delete`, `user:delete`, and `archive:trigger:user` as independent requirements.
Where an operation stacks several requirements, Fluxer evaluates each separately and the caller must satisfy all of them. [List users](/admin-api/users/#list-users) is one example and stacks `user:view:email` or `user:view:ip` on top of `user:lookup` when a request uses a selector that needs one.
The effective ACL set of a session or an Admin OAuth2 bearer credential is the set stored on the account. The effective set of an Admin API key is the set stored on the key, subject to the owner check above. [Set user ACLs](/admin-api/users/#set-user-acls) writes an account's set. Holding `admin:authenticate` or `*` is the whole definition of an account that can reach the Admin API.
@@ -61,7 +61,7 @@ A body with none of those fields resolves to no ACL at all and applies no change
Fluxer checks each ACL an Admin grants against the ACLs that Admin holds. [Set user ACLs](/admin-api/users/#set-user-acls) and [Create Admin API key](/admin-api/api-keys/#create-admin-api-key) both refuse to write an ACL the acting Admin does not itself hold, with 403 `MISSING_ACL`. A wildcard holder is exempt. Set user ACLs also refuses the acting Admin's own account with 403 `ACCESS_DENIED`, and it looks up the target account before it checks the granted ACLs, so an unknown ID fails first with 404 `UNKNOWN_USER`.
[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 106 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`.
[Set user ACLs](/admin-api/users/#set-user-acls), [Create Admin API key](/admin-api/api-keys/#create-admin-api-key), and [Update Admin API key](/admin-api/api-keys/#update-admin-api-key) each accept at most 105 ACLs and validate every entry against the registry, so a value outside it returns 400 `INVALID_FORM_BODY`.
:::caution[`*` satisfies every present and future ACL]
A key whose owning account holds `*` skips the owner check. An Admin holding `*` can grant any ACL.
@@ -311,7 +311,6 @@ An entry with any other action has `access` set to `write`.
| Value | Description |
| --- | --- |
| NCMEC Report | An attachment was submitted through the NCMEC reporting integration |
| annotate_ban | A note was appended to the current ban of an account |
| approve_discovery_application | A discovery application was approved |
| approve_registration | A pending registration was approved |
@@ -482,7 +481,7 @@ Returns every Admin permission string the instance recognises, in [ACL registry]
| Field | Type | Description |
| --- | --- | --- |
| acls<sup>1</sup> | array[string] | The permission strings the Admin API recognises (at most 106 entries) |
| acls<sup>1</sup> | array[string] | The permission strings the Admin API recognises (at most 105 entries) |
<sup>1</sup> The response is the registry itself and does not vary with the caller's own ACL set
@@ -550,7 +549,6 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
| bulk:delete:user_messages | Queues the bulk message deletion task |
| bulk:update:guild_features | Queues the bulk guild feature task |
| bulk:update:user_flags | Queues the bulk account flag task |
| csam:submit_ncmec | Submits an attachment through the NCMEC reporting integration |
| discovery:remove | Removes a guild from [discovery](/admin-api/discovery/) |
| discovery:review | Reads, reviews, and edits discovery applications, categories, and listings |
| gateway:memory_stats | Reads main [Gateway](/admin-api/gateway/) statistics, memory statistics, and voice state counts |
@@ -1,18 +1,18 @@
---
# SPDX-License-Identifier: AGPL-3.0-or-later
title: Admin messages
description: Message browsing and search, permanent deletion, shred jobs, and NCMEC submission.
description: Message browsing and search, permanent deletion, and shred jobs.
---
import RouteHeader from '@/components/RouteHeader.astro';
Admin message routes read, search, and permanently delete the messages of one channel. Others report the progress of a shred job and submit one attachment to NCMEC, the National Center for Missing and Exploited Children.
Admin message routes read, search, and permanently delete the messages of one channel. Another reports the progress of a shred job.
Shredding and bulk deletion for a whole account live under [Admin users](/admin-api/users/).
## Admin message object
A flattened moderation view of one message. It has the author identity, the content and timestamp, the attachments with their NCMEC submission state, and the NSFW context of the channel and guild.
A flattened moderation view of one message. It has the author identity, the content and timestamp, the attachments, and the NSFW context of the channel and guild.
Every operation that returns Admin message objects also returns the matching public message objects in a parallel `message_responses` array, so a client that needs the full wire shape joins on `id`.
@@ -40,14 +40,11 @@ Every operation that returns Admin message objects also returns the matching pub
| content | string | The message content (0-4,000 characters) |
| timestamp | ISO8601 timestamp | When the message was sent |
| attachments | array[[Admin message attachment](#admin-message-attachment-object) object] | The attachments of the message (at most 10) |
| user_prior_ncmec_report_ids?<sup>3</sup> | array[string] | The NCMEC report IDs previously filed against the author, ascending (at most 100) |
<sup>1</sup> Every object in one response has the same values, taken from the channel named in the request path or query. All are null when the channel no longer exists
<sup>2</sup> Always absent from the objects these operations return. The same member is set on the [report message context object](/admin-api/reports/#report-message-context-object)
<sup>3</sup> Written on every object these operations return, and empty when no NCMEC report has been filed against the author
### Example
```json
@@ -66,14 +63,13 @@ Every operation that returns Admin message objects also returns the matching pub
"author_avatar": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"content": "read the pinned message",
"timestamp": "2026-03-04T18:19:07.220Z",
"attachments": [],
"user_prior_ncmec_report_ids": []
"attachments": []
}
```
### Admin message attachment object
An attachment entry has the ordinary attachment metadata together with the NCMEC submission state Fluxer holds for it.
An attachment entry has the ordinary attachment metadata.
#### Structure
@@ -86,26 +82,9 @@ An attachment entry has the ordinary attachment metadata together with the NCMEC
| content_type | ?string | The recorded MIME type, or null when none was recorded |
| width | ?integer | The pixel width, or null when no dimensions were recorded |
| height | ?integer | The pixel height, or null when no dimensions were recorded |
| size?<sup>4</sup> | ?integer | The attachment size in bytes, or null when no size was recorded |
| ncmec_status | string | The [NCMEC submission status](#ncmec-submission-status) of this attachment |
| ncmec_report_id<sup>5</sup> | ?string | The report ID NCMEC assigned (1-256 characters), or null when none has been assigned |
| ncmec_failure_reason<sup>6</sup> | ?string | The recorded reason the last submission failed (1-4,000 characters), or null |
| size?<sup>3</sup> | ?integer | The attachment size in bytes, or null when no size was recorded |
<sup>4</sup> Always emitted, so it is null and never absent when no size was recorded
<sup>5</sup> Assigned only once NCMEC accepts the report, so it is null while `ncmec_status` is `not_submitted` and after a failed submission
<sup>6</sup> Set only when `ncmec_status` is `failed`, and it stores the raw upstream error. The sanitised wording appears in the failing response body instead
#### NCMEC submission status
| Value | Description |
| --- | --- |
| not_submitted | The attachment has never been submitted to NCMEC |
| submitted | The attachment was submitted to NCMEC and NCMEC accepted the report |
| failed | The submission failed, the opened report was retracted, and the attachment can be submitted again |
A submission that is still running is stored as `submitting` and returned unchanged, so tolerate a status outside this table. An attachment in that state is refused a second submission by [Create NCMEC report](#create-ncmec-report).
<sup>3</sup> Always emitted, so it is null and never absent when no size was recorded
## Message shred status object
@@ -396,73 +375,3 @@ One [Admin audit entry](/admin-api/#admin-audit-entry-object) is recorded with t
### Rate limit
100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
## Create NCMEC report
<RouteHeader method="POST" path="/v1/admin/messages/ncmec-reports" />
Submits one image or video attachment to NCMEC and starts the account enforcement workflow for its author. Requires `csam:submit_ncmec`, `message:delete`, `user:delete`, and `archive:trigger:user`.
A caller missing more than one of the four is refused with 403 `MISSING_ACL` naming the first it does not hold, in the order listed. The wildcard satisfies them all.
This operation ignores `X-Audit-Log-Reason`. The reason on every audit entry the workflow produces is built from the assigned NCMEC report ID and the channel and attachment IDs, and it is returned as `audit_log_reason`.
### JSON body
| Field | Type | Description |
| --- | --- | --- |
| channel_id | snowflake | The channel the attachment was sent in |
| message_id | snowflake | The message the attachment belongs to |
| attachment_id | snowflake | The attachment to submit |
| filename | string | The exact filename of the attachment (1-255 characters) |
| source_report_id?<sup>1</sup> | snowflake | The report whose frozen evidence has the attachment |
| reporter_full_name | string | The full legal name of the submitting Admin (1-200 characters) |
| confirmed_viewed<sup>2</sup> | boolean | Confirmation that the submitting Admin has viewed the attachment |
<sup>1</sup> Required when the live message no longer exists or has no author, in which case the attachment is read from that report's frozen evidence. Supplying it otherwise only records the report ID
<sup>2</sup> The literal value `true` is the only accepted value. Any other value fails body validation
### Response body
| Field | Type | Description |
| --- | --- | --- |
| success | boolean | Always true |
| ncmec_report_id | string | The report ID NCMEC assigned to this submission (1-256 characters) |
| audit_log_reason<sup>3</sup> | string | The reason recorded on every audit entry the workflow produced (1-4,000 characters) |
<sup>3</sup> The NCMEC report ID followed by the channel ID and attachment ID, so one string identifies the submission in the audit log and at NCMEC
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | response body | Attachment was submitted and the workflow advanced |
| 400 | [error response](/admin-api/#error-response) | The resolved attachment is neither an image nor a video and the request returns `INVALID_FORM_BODY` against `attachment_id` |
| 404<sup>1</sup> | [error response](/admin-api/#error-response) | `UNKNOWN_MESSAGE` when nothing resolves the attachment, or `UNKNOWN_REPORT` when `source_report_id` names no report |
| 409 | [error response](/admin-api/#error-response) | `NCMEC_ALREADY_SUBMITTED` when a submission for the attachment is already running or has already succeeded |
| 500 | [error response](/admin-api/#error-response) | Submission fails and the request returns `NCMEC_SUBMISSION_FAILED` |
<sup>1</sup> The attachment resolves from a live message with an author, or from frozen report evidence
:::danger[A successful request files a real NCMEC report]
The request disables the author's account and schedules that account for deletion. Nothing withdraws a completed submission. Fluxer retracts the report only when a step after it was opened fails.
:::
### Side effects
Fluxer resolves the attachment from the live message when that message still exists and has an author, and from the frozen evidence named by `source_report_id` otherwise. Fluxer accepts the attachment only when its resolved content type begins with `image/` or `video/`.
The attachment is `submitting` while the request runs, then `submitted` with its report ID on success. A failed submission attempts to retract the opened report, marks the attachment `failed` with the raw reason, and returns `NCMEC_SUBMISSION_FAILED`. A failed retraction is logged without changing that recorded state.
When the resolved attachment has an author whose account an earlier NCMEC submission has not already disabled, Fluxer sets the account's deleted and disabled flags, clears any temporary ban, and records the deletion reason for child sexual content. It then schedules deletion 60 days ahead, deletes every authentication session, sends [User Update](/gateway/events/#user-update) to that account, and triggers a user archive for that account. When the account's public user fields change, Fluxer also sends [Guild Member Update](/gateway/events/#guild-member-update) to each guild the account is in.
Message content is deleted only after that archive completes. The archive is re-checked every 15 seconds by default, for at most 240 attempts. Once the archive is complete the reported message is deleted, its attachments are purged, and [Message Delete](/gateway/events/#message-delete) is sent.
A successful submission records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `NCMEC Report`. It targets the reported user, or targets the source report when the attachment resolved to no author. Triggering the archive records a second entry with the action `trigger_user_archive` targeting the same user, skipped when the account already has an archive taken after the enforcement. Both entries record the reason returned as `audit_log_reason`.
An attachment that resolved to no author is submitted to NCMEC and audited. No account is disabled, no archive is triggered, and no message content is deleted.
### Rate limit
100 requests per minute for each authenticated user, on the shared `admin:message:operation` bucket.
@@ -22,7 +22,7 @@ Fluxer reads the tag, username, display name, and discriminator of both accounts
## Admin report object
A report is written once, when the reporter files it. [Update report](#update-report) moves it from PENDING to RESOLVED with an optional comment. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for any reason other than a user request also resolves every pending report against the deleted account, with no public comment, and sends each reporter the generic notice [Update report](#update-report) describes. A report is deleted at the end of its [retention period](#report-retention).
A report is written once, when the reporter files it. [Update report](#update-report) moves it from PENDING to RESOLVED with an optional comment. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one of the abuse reasons listed there also resolves the pending reports against the deleted account, with no public comment, and sends each reporter the generic notice [Update report](#update-report) describes. A report in the `child_safety`, `underage_user`, or `self_harm` category is never resolved this way and stays PENDING. A report is deleted at the end of its [retention period](#report-retention).
[List reports](#list-reports) returns this object without message context. [Get report](#get-report) returns the [Admin report detail object](#admin-report-detail-object), which extends it.
@@ -32,7 +32,7 @@ A report is written once, when the reporter files it. [Update report](#update-re
| --- | --- | --- |
| report_id | snowflake | The ID of the report |
| reporter_id<sup>1</sup> | ?snowflake | The account that filed the report |
| reporter_tag<sup>1</sup> <sup>2</sup> | ?string | The reporter username and four-digit discriminator joined by `#` |
| reporter_tag<sup>1</sup> <sup>2</sup> <sup>18</sup> | ?string | The reporter username and four-digit discriminator joined by `#` |
| reporter_username<sup>1</sup> <sup>2</sup> | ?string | The username of the reporter |
| reporter_global_name<sup>1</sup> <sup>2</sup> | ?string | The display name of the reporter, or null when unset |
| reporter_discriminator<sup>1</sup> <sup>2</sup> | ?string | The zero-padded four-digit reporter discriminator |
@@ -50,7 +50,7 @@ A report is written once, when the reporter files it. [Update report](#update-re
| reporter_good_faith_confirmed?<sup>7</sup> <sup>13</sup> | ?boolean | Whether the reporter confirmed the notice in good faith |
| additional_info | ?string | The free-text detail the reporter supplied |
| reported_user_id<sup>14</sup> | ?snowflake | The reported account, or null for a guild report |
| reported_user_tag<sup>2</sup> | ?string | The reported username and four-digit discriminator joined by `#` |
| reported_user_tag<sup>2</sup> <sup>18</sup> | ?string | The reported username and four-digit discriminator joined by `#` |
| reported_user_username<sup>2</sup> | ?string | The username of the reported account |
| reported_user_global_name<sup>2</sup> | ?string | The display name of the reported account, or null when unset |
| reported_user_discriminator<sup>2</sup> | ?string | The zero-padded four-digit reported account discriminator |
@@ -67,7 +67,7 @@ A report is written once, when the reporter files it. [Update report](#update-re
| reported_webhook_guild_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The guild the webhook belonged to |
| reported_webhook_created_at?<sup>7</sup> <sup>14</sup> | ?ISO8601 timestamp | When the webhook was created, derived from its ID |
| reported_webhook_creator_id?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> | ?snowflake | The account that created the webhook |
| reported_webhook_creator_tag?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The creator username and four-digit discriminator joined by `#` |
| reported_webhook_creator_tag?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> <sup>18</sup> | ?string | The creator username and four-digit discriminator joined by `#` |
| reported_webhook_creator_username?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The username of the creator |
| reported_webhook_creator_global_name?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The display name of the creator, or null when unset |
| reported_webhook_creator_discriminator?<sup>4</sup> <sup>7</sup> <sup>14</sup> <sup>15</sup> <sup>16</sup> | ?string | The zero-padded four-digit creator discriminator |
@@ -128,6 +128,8 @@ A report is written once, when the reporter files it. [Update report](#update-re
<sup>17</sup> Null when the report names no reported account, which includes a report about a message a webhook sent
<sup>18</sup> On an instance with the `none` tag style the tag of an account that is not a bot is the bare username, with no `#0000`
### Example
```json
@@ -235,15 +237,14 @@ A message report captures the reported message together with at most 25 messages
| content | string | The captured message content, empty when the message had none |
| timestamp<sup>6</sup> | ISO8601 timestamp | When the message was sent |
| attachments<sup>7</sup> | array[[report message attachment](#report-message-attachment-object) object] | The captured attachment copies |
| author_id<sup>9</sup> | snowflake | The author of the message |
| author_username<sup>9</sup> | string | The captured author username |
| author_id<sup>8</sup> | snowflake | The author of the message |
| author_username<sup>8</sup> | string | The captured author username |
| author_global_name<sup>4</sup> | ?string | The display name of the author |
| author_discriminator<sup>9</sup> | string | The captured zero-padded four-digit author discriminator |
| author_avatar<sup>9</sup> | ?string | The captured author avatar hash, or null when unset |
| webhook_id?<sup>3</sup> <sup>10</sup> | ?snowflake | The [webhook](/http-api/webhooks/) that sent the message |
| author_bot?<sup>3</sup> <sup>11</sup> | ?boolean | Whether the author account is a bot |
| missing_attachments?<sup>3</sup> <sup>12</sup> | array[[report missing attachment](#report-missing-attachment-object) object] | Attachments that could not be copied into the report |
| user_prior_ncmec_report_ids?<sup>3</sup> <sup>8</sup> | array[string] | The NCMEC report IDs previously filed against the author |
| author_discriminator<sup>8</sup> | string | The captured zero-padded four-digit author discriminator |
| author_avatar<sup>8</sup> | ?string | The captured author avatar hash, or null when unset |
| webhook_id?<sup>3</sup> <sup>9</sup> | ?snowflake | The [webhook](/http-api/webhooks/) that sent the message |
| author_bot?<sup>3</sup> <sup>10</sup> | ?boolean | Whether the author account is a bot |
| missing_attachments?<sup>3</sup> <sup>11</sup> | array[[report missing attachment](#report-missing-attachment-object) object] | Attachments that could not be copied into the report |
<sup>1</sup> The channel of the captured entry, otherwise the reported channel. An entry for which neither records a channel is left out of `message_context`
@@ -259,15 +260,13 @@ A message report captures the reported message together with at most 25 messages
<sup>7</sup> Fluxer drops an evidence copy that records no file name, resolves to no channel, or receives no download URL, so this array can be shorter than the original
<sup>8</sup> Sorted ascending, with at most 100 entries, and empty when no NCMEC report has been filed against the author or a webhook sent the message
<sup>8</sup> For a message a webhook sent, the webhook ID, the name and avatar hash the message was sent with, and the discriminator `0000`, as the [message object](/http-api/messages/#message-object) shows the author of a webhook message
<sup>9</sup> For a message a webhook sent, the webhook ID, the name and avatar hash the message was sent with, and the discriminator `0000`, as the [message object](/http-api/messages/#message-object) shows the author of a webhook message
<sup>9</sup> Null for a message an account sent
<sup>10</sup> Null for a message an account sent
<sup>10</sup> Read from the author account when the response is produced. Null for a message a webhook sent and for an author account that no longer resolves
<sup>11</sup> Read from the author account when the response is produced. Null for a message a webhook sent and for an author account that no longer resolves
<sup>12</sup> Empty when every attachment was copied, and on a report filed before missing attachments were recorded. An attachment is listed here when its copy failed or, for a file embedded in the message, when the file was no longer stored
<sup>11</sup> Empty when every attachment was copied, and on a report filed before missing attachments were recorded. An attachment is listed here when its copy failed or, for a file embedded in the message, when the file was no longer stored
### Report message attachment object
@@ -277,27 +276,14 @@ A message report captures the reported message together with at most 25 messages
| --- | --- | --- |
| id | snowflake | The ID of the attachment |
| filename | string | The captured file name |
| url<sup>13</sup> | string | The download URL for the evidence copy |
| url<sup>12</sup> | string | The download URL for the evidence copy |
| nsfw | ?boolean | Whether the attachment was marked NSFW |
| content_type | ?string | The captured MIME type, or null when unknown |
| width | ?integer | The pixel width, or null for a non-image attachment |
| height | ?integer | The pixel height, or null for a non-image attachment |
| size?<sup>3</sup> | ?integer | The size in bytes, or null when unknown |
| ncmec_status<sup>14</sup> | string | The [NCMEC submission status](#ncmec-submission-statuses) |
| ncmec_report_id | ?string | The NCMEC report ID, or null when the attachment was never submitted |
| ncmec_failure_reason | ?string | The failure detail, or null unless `ncmec_status` is failed |
<sup>13</sup> The URL expires after five minutes. Fetch the report again for fresh attachment links
<sup>14</sup> An in-flight submission is stored as `submitting`, a value the registry below does not list
#### NCMEC submission statuses
| Value | Description |
| --- | --- |
| not_submitted | The attachment has never been submitted to NCMEC |
| submitted | The attachment has been submitted to NCMEC |
| failed | Submission to NCMEC was attempted and failed |
<sup>12</sup> The URL expires after five minutes. Fetch the report again for fresh attachment links
### Report missing attachment object
@@ -313,9 +299,9 @@ An attachment of a captured message that Fluxer could not copy into the report.
| content_type | ?string | The captured MIME type, or null when unknown |
| width | ?integer | The pixel width, or null for a non-image attachment |
| height | ?integer | The pixel height, or null for a non-image attachment |
| size<sup>15</sup> | ?integer | The size in bytes, or null when unknown |
| size<sup>13</sup> | ?integer | The size in bytes, or null when unknown |
<sup>15</sup> `0` for a file embedded in the message that was no longer stored when the report was filed
<sup>13</sup> `0` for a file embedded in the message that was no longer stored when the report was filed
## Report profile snapshot object
@@ -710,7 +696,7 @@ Resolving a report notifies the reporter whatever `notify_reporter` says, as the
The report moves to RESOLVED and records `resolved_at`, `resolved_by_admin_id`, and the public comment.
Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with target type `report`, target ID equal to the report ID, and action `resolve_report`. The metadata is `report_id`, `report_type`, `notify_reporter`, `reporter_dm_sent`, `reporter_email_sent`, and `reporter_dsa_email_sent`. When Fluxer does not attempt a notice, `reporter_notice_skipped` names the reason: `reporter_account_deleted`, `reporter_report_banned`, `reporter_lookup_failed`, or `no_reporter_email`.
Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with target type `report`, target ID equal to the report ID, and action `resolve_report`. The metadata is `report_id`, `report_type`, `notify_reporter`, `reporter_dm_sent`, `reporter_email_sent`, and `reporter_dsa_email_sent`, plus `resolution` when the request sets one. When Fluxer does not attempt a notice, `reporter_notice_skipped` names the reason: `reporter_account_deleted`, `reporter_report_banned`, `reporter_lookup_failed`, or `no_reporter_email`.
Fluxer notifies the reporter of every resolution. A reporter with an account receives a system direct message, and an email when the account has an address. A Digital Services Act notice has no reporter account, so its verified address receives a decision email with the report ID, the comment when one is shared, and how to appeal. Nothing is sent when the reporter account is deleted or barred from reporting.
@@ -59,7 +59,7 @@ When the caller lacks `user:view:email`, `user:view:dob`, or `user:view:ip`, Flu
| deletion_audit_log_reason | ?string | The private reason stored with the pending deletion, and null without `audit_log:view` |
| deletion_scheduled_by | ?snowflake | The ID of the account that scheduled the pending deletion, or null when it was not recorded |
| deletion_scheduled_at | ?ISO8601 timestamp | The time the pending deletion was scheduled, or null when it was not recorded |
| acls<sup>7</sup> | array[string] | Effective [Admin ACLs](/admin-api/#acl-registry), with at most 106 entries |
| acls<sup>7</sup> | array[string] | Effective [Admin ACLs](/admin-api/#acl-registry), with at most 105 entries |
| traits<sup>8</sup> | array[string] | The free-form operator labels set on the account, with at most 100 entries |
| has_totp<sup>9</sup> | boolean | Whether a TOTP authenticator is registered |
| authenticator_types<sup>9</sup> <sup>12</sup> | array[integer] | Registered [authenticator types](/http-api/users/#authenticator-types), with at most 10 entries |
@@ -135,6 +135,7 @@ The `flags` field of the [Admin user object](#admin-user-object) is a 64-bit bit
| 1 &lt;&lt; 4 | FRIENDLY_BOT | Bot accepts friend requests from users |
| 1 &lt;&lt; 5 | FRIENDLY_BOT_MANUAL_APPROVAL | Bot requires manual approval for friend requests |
| 1 &lt;&lt; 6 | SPAMMER | Account is flagged as a spammer |
| 1 &lt;&lt; 7 | PROFILE_HIDDEN | Profile details are hidden from other users |
| 1 &lt;&lt; 33 | HIGH_GLOBAL_RATE_LIMIT | Account has elevated global rate limits |
| 1 &lt;&lt; 34 | DELETED | Account has been deleted |
| 1 &lt;&lt; 36 | SELF_DELETED | Account was self-deleted |
@@ -1147,7 +1148,7 @@ A path naming the acting account fails with 403 `ACCESS_DENIED` before the accou
| Field | Type | Description |
| --- | --- | --- |
| acls<sup>1</sup> | array[string] | Replacement [Admin ACLs](/admin-api/#acl-registry), with at most 106 values |
| acls<sup>1</sup> | array[string] | Replacement [Admin ACLs](/admin-api/#acl-registry), with at most 105 values |
<sup>1</sup> Required. A value outside the [ACL registry](/admin-api/#acl-registry) fails body validation, and a repeated value is collapsed
@@ -1595,9 +1596,9 @@ Fluxer cancels a Stripe subscription on the account without proration and refund
The account holder is emailed the deadline and the supplied `public_reason` when `notify_user` is true and the account has an email address. The wording depends on `reason_code`: `USER_REQUESTED` and `INACTIVITY` get a neutral notice, and every other code gets an enforcement notice. A failed email is logged and the schedule is still stored.
For every reason other than `USER_REQUESTED`, Fluxer also blocks the account's email address and resolves the pending reports against the account. These enforcement steps are best-effort and can fail without cancelling the deletion schedule.
For every reason other than `USER_REQUESTED`, Fluxer also blocks the account's email address. For the reasons `SPAM`, `CHEATING_OR_EXPLOITATION`, `COORDINATED_RAIDING`, `AUTOMATION_OR_SELFBOT`, `SCAM_OR_SOCIAL_ENGINEERING`, `HARASSMENT_OR_BULLYING`, `BAN_EVASION`, `TOKEN_OR_CREDENTIAL_SCAM`, `HATE_SPEECH_OR_EXTREMIST_CONTENT`, `MALICIOUS_LINKS_OR_MALWARE`, and `IMPERSONATION_OR_FAKE_IDENTITY`, Fluxer also resolves the pending reports against the account. A pending report in the `child_safety`, `underage_user`, or `self_harm` category is left for a moderator and stays pending. Every other reason leaves all reports pending. These enforcement steps are best-effort and can fail without cancelling the deletion schedule.
[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days`, `reason_code`, `pending_deletion_at`, `notify_user`, and `notification_sent`. An attempted email adds `notification_template`. A replacement adds `replaced_pending_deletion_at`, `replaced_scheduled_by`, `replaced_scheduled_at`, and `replaced_reason_code` for the schedule it replaced. The identifier bans record their own blocklist entries. For every reason other than `USER_REQUESTED`, Fluxer also records a second entry with action `auto_resolve_reports_on_deletion`, target type `user`, and the metadata keys `outcome`, `found_count`, `resolved_count`, `failed_count`, and `notified_count`. `outcome` is `completed`, `search_unavailable` when the instance has no report search backend and every report stays pending, or `enumeration_failed` when listing the pending reports failed part way and only those already found were resolved. Each resolved report sends its reporter the generic notice [Update report](/admin-api/reports/#update-report) describes, with no public comment.
[User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days`, `reason_code`, `pending_deletion_at`, `notify_user`, and `notification_sent`. An attempted email adds `notification_template`. A replacement adds `replaced_pending_deletion_at`, `replaced_scheduled_by`, `replaced_scheduled_at`, and `replaced_reason_code` for the schedule it replaced. The identifier bans record their own blocklist entries. For each reason that resolves reports, Fluxer also records a second entry with action `auto_resolve_reports_on_deletion`, target type `user`, and the metadata keys `outcome`, `found_count`, `resolved_count`, `failed_count`, and `notified_count`. `found_count` leaves out the reports in the three categories that stay pending. No such entry is written for any other reason. `outcome` is `completed`, `search_unavailable` when the instance has no report search backend and every report stays pending, or `enumeration_failed` when listing the pending reports failed part way and only those already found were resolved. Each resolved report sends its reporter the generic notice [Update report](/admin-api/reports/#update-report) describes, with no public comment.
### Rate limit
@@ -770,7 +770,7 @@ One visible message was deleted.
| guild_id? | snowflake | Guild the channel belongs to |
| member?<sup>2</sup> | [guild member](/http-api/guild-members/#guild-member-object) object | The author's guild member object, present in a guild channel |
<sup>1</sup> Both fields are omitted when an instance administrator deleted the message through the Admin API, when Fluxer deleted it after a CSAM report, when Fluxer deleted it because content moderation blocked a link preview in it, or when Fluxer removed a published message and its copies together, and `author_id` is also omitted for a message with no author
<sup>1</sup> Both fields are omitted when an instance administrator deleted the message through the Admin API, when Fluxer deleted it because content moderation blocked a link preview in it, or when Fluxer removed a published message and its copies together, and `author_id` is also omitted for a message with no author
<sup>2</sup> The `user` field is removed from it, and the whole field is absent when `author_id` is absent or the author is no longer a member
@@ -774,14 +774,6 @@ The requested scope isn't supported or you don't have permission to request it
You don't have the permissions required to perform this action
### `NCMEC_ALREADY_SUBMITTED`
This content has already been submitted to NCMEC
### `NCMEC_SUBMISSION_FAILED`
We couldn't submit the report to NCMEC
### `NOT_A_BOT_APPLICATION`
This application isn't a bot
@@ -1843,10 +1835,6 @@ You must start a session before sending messages
Name can't be empty after normalization
### `NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO`
Only image or video attachments can be reported to NCMEC
### `NEW_EMAIL_MUST_BE_DIFFERENT`
New email must be different from your current email
@@ -516,7 +516,7 @@ Default `http://localhost:3900`. The S3 API address. `media-proxy` defaults to e
#### `FLUXER_S3_PUBLIC_ENDPOINT`
No default. The host substituted into presigned URLs, which are the only place it appears. Compose forwards it from `.env`, and uses `FLUXER_S3_ENDPOINT` when it is unset, except on `app-proxy`, which gets only a value `.env` sets and adds it to its Content Security Policy.
No default. The host substituted into presigned URLs, which are the only place it appears. Compose forwards it from `.env`, and uses `FLUXER_S3_ENDPOINT` when it is unset, except on `app-proxy`, which gets only a value `.env` sets and adds it to its Content Security Policy. `admin` reads it to find the origin that report downloads are presigned against. See `FLUXER_S3_BUCKET_REPORTS`.
#### `FLUXER_S3_FORCE_PATH_STYLE`
@@ -548,7 +548,7 @@ Default `fluxer-uploads`. Raw uploads. `media-proxy` defaults to `uploads`, `app
#### `FLUXER_S3_BUCKET_REPORTS`
Default `fluxer-reports`. Abuse report evidence. Read by `api` and `worker`. Compose forwards it from `.env` with a default of `fluxer-reports`, so every service and `seaweedfs-init` name the same bucket.
Default `fluxer-reports`. Abuse report evidence. Read by `api`, `worker`, and `admin`. Compose forwards it from `.env` with a default of `fluxer-reports`, so every service and `seaweedfs-init` name the same bucket. `admin` allows one image origin for report attachments and profile snapshots in its Content Security Policy, the origin `api` presigns report downloads against. It works that origin out from this name, `FLUXER_S3_PUBLIC_ENDPOINT` or else `FLUXER_S3_ENDPOINT`, `FLUXER_S3_FORCE_PATH_STYLE` and `FLUXER_S3_BUCKET_UPLOADS`, so give `admin` the same values as `api`. The images load only when that origin is an address browsers can reach.
#### `FLUXER_S3_BUCKET_HARVESTS`
@@ -1002,26 +1002,6 @@ With prices saved in the dashboard, add the retired ID to its legacy prices befo
All are optional.
#### `FLUXER_NCMEC_ENABLED`
Default `false`. NCMEC reporting. Compose forwards it from `.env`.
#### `FLUXER_NCMEC_BASE_URL`
Default empty. The reporting endpoint, `https://report.cybertip.org/ispws` in production. Required when reporting is on. Compose forwards it from `.env`.
#### `FLUXER_NCMEC_USERNAME`
Default empty. The reporting login. Required when reporting is on. Compose forwards it from `.env`.
#### `FLUXER_NCMEC_PASSWORD`
Default empty. The reporting password. Required when reporting is on. Compose forwards it from `.env`.
#### `FLUXER_NCMEC_REPORTER_EMAIL`
Default empty. The contact address on reports. Required when reporting is on. Compose forwards it from `.env`.
#### `FLUXER_CLAMAV_ENABLED`
Default `false`. Upload virus scanning. Compose forwards it from `.env`, and no ClamAV container ships.
@@ -1046,7 +1026,7 @@ With feeds off, Fluxer checks none of this data. The first worker start with fee
#### `FLUXER_REPORT_RETENTION_DRY_RUN`
Default unset, which keeps the dry run. Read by `worker`. The daily [report retention](/admin-api/reports/#report-retention) job deletes reports 365 days after they were filed, with their evidence copies and search entries, unless a legal hold is in place. Until this is `false` or `0`, the job only logs what it would delete. Any other value, or no value, keeps the dry run. Check the counts in the `Processed report retention` log line before you turn deletion on, because deleted evidence cannot be restored. Compose does not forward it, so add it to `worker` through an override file.
Default unset, which keeps the dry run. Read by `worker`. The daily [report retention](/admin-api/reports/#report-retention) job deletes reports 365 days after they were filed, with their evidence copies and search entries, unless a legal hold is in place. Until this is `false` or `0`, the job only logs what it would delete. Any other value, or no value, keeps the dry run. Check the counts in the `Processed report retention` log line before you turn deletion on, because deleted evidence cannot be restored. Compose forwards it from `.env`.
#### `FLUXER_BREACHED_PASSWORD_CHECK_ENABLED`
@@ -1735,10 +1715,6 @@ Compose writes each of these from the `.env` name given, so setting the name its
`BUILD_VERSION`, `FLUXER_BUILD_VERSION` and `RELEASE_CHANNEL` describe the shipped build. `POD_NAME` and `HOSTNAME` come from the platform.
#### Report retention
`FLUXER_REPORT_RETENTION_DRY_RUN`, so the [report retention](/admin-api/reports/#report-retention) job stays in dry run until an override sets it on `worker`.
#### Test and development switches
`FLUXER_TEST_MODE_ENABLED`, `FLUXER_TEST_HARNESS_TOKEN`, `FLUXER_DISABLE_RATE_LIMITS`, `FLUXER_VALIDATE_RESPONSES` and `FLUXER_RELAX_REGISTRATION_RATE_LIMITS`. None of them belongs on a production instance.
@@ -2197,7 +2173,7 @@ Volume names are prefixed with the Compose project name, so `postgres-data` is `
| --- | --- | --- |
| fluxer | `FLUXER_S3_BUCKET_CDN` | Avatars, guild and entity assets, themes, entrance sounds, memes, and processed attachments |
| fluxer-uploads | `FLUXER_S3_BUCKET_UPLOADS` | Raw attachment uploads, before processing |
| fluxer-reports | `FLUXER_S3_BUCKET_REPORTS` | Abuse report evidence, and NCMEC payloads where that integration is on |
| fluxer-reports | `FLUXER_S3_BUCKET_REPORTS` | Abuse report evidence |
| fluxer-harvests | `FLUXER_S3_BUCKET_HARVESTS` | User and guild data archives |
`FLUXER_S3_BUCKET_STATIC` names an optional bucket. The shipped stack does not use or create it.
@@ -76,7 +76,7 @@ An edit of a published message reaches every copy that still exists, including c
Deleting a published message keeps each copy and edits it: the content becomes `[Original message deleted]`, attachments, embeds, and stickers are removed, `edited_timestamp` is set, and the `SOURCE_MESSAGE_DELETED` flag is added. The files of the published message are deleted with it. Deleting a copy removes that copy alone, and the files it showed stay with the published message.
When an instance administrator or a CSAM report removes a published message, every copy is deleted outright. When one of them removes a copy, the published message and every other copy are deleted too, so identical content leaves every guild at once. A published message that link preview moderation deletes takes its copies with it the same way.
When an instance administrator removes a published message, every copy is deleted outright. When an instance administrator removes a copy, the published message and every other copy are deleted too, so identical content leaves every guild at once. A published message that link preview moderation deletes takes its copies with it the same way.
## Cleanup
-6
View File
@@ -197,12 +197,6 @@ function defaultConfig(): MasterConfig {
prices: {},
legacy_prices: {},
},
ncmec: {
enabled: false,
base_url: '',
username: '',
password: '',
},
clamav: {
enabled: false,
host: '127.0.0.1',
-7
View File
@@ -221,13 +221,6 @@ export interface MasterConfig {
prices?: Record<string, string | undefined>;
legacy_prices?: Record<string, Array<string> | undefined>;
};
ncmec: {
enabled: boolean;
base_url: string;
username: string;
password: string;
reporter_email?: string;
};
clamav: {
enabled: boolean;
host: string;
@@ -224,11 +224,6 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_STRIPE_PRICE_GIFT_1_YEAR_INR: {path: ['integrations', 'stripe', 'prices', 'gift_1_year_inr']},
FLUXER_STRIPE_PRICE_GIFT_1_YEAR_PLN: {path: ['integrations', 'stripe', 'prices', 'gift_1_year_pln']},
FLUXER_STRIPE_PRICE_GIFT_1_YEAR_TRY: {path: ['integrations', 'stripe', 'prices', 'gift_1_year_try']},
FLUXER_NCMEC_ENABLED: {path: ['integrations', 'ncmec', 'enabled'], parse: parseBoolean},
FLUXER_NCMEC_BASE_URL: {path: ['integrations', 'ncmec', 'base_url']},
FLUXER_NCMEC_USERNAME: {path: ['integrations', 'ncmec', 'username']},
FLUXER_NCMEC_PASSWORD: {path: ['integrations', 'ncmec', 'password']},
FLUXER_NCMEC_REPORTER_EMAIL: {path: ['integrations', 'ncmec', 'reporter_email']},
FLUXER_CLAMAV_ENABLED: {path: ['integrations', 'clamav', 'enabled'], parse: parseBoolean},
FLUXER_CLAMAV_HOST: {path: ['integrations', 'clamav', 'host']},
FLUXER_CLAMAV_PORT: {path: ['integrations', 'clamav', 'port'], parse: parseInteger},
-1
View File
@@ -44,7 +44,6 @@ export const AdminACLs = {
BULK_DELETE_USER_MESSAGES: 'bulk:delete:user_messages',
BULK_UPDATE_GUILD_FEATURES: 'bulk:update:guild_features',
BULK_UPDATE_USER_FLAGS: 'bulk:update:user_flags',
CSAM_SUBMIT_NCMEC: 'csam:submit_ncmec',
DISCOVERY_REMOVE: 'discovery:remove',
DISCOVERY_REVIEW: 'discovery:review',
GATEWAY_MEMORY_STATS: 'gateway:memory_stats',
-2
View File
@@ -167,8 +167,6 @@ export const APIErrorCodes = {
MAX_THREAD_MEMBERS: 'MAX_THREAD_MEMBERS',
MAX_WEBHOOKS_PER_CHANNEL: 'MAX_WEBHOOKS_PER_CHANNEL',
MAX_WEBHOOKS_PER_GUILD: 'MAX_WEBHOOKS_PER_GUILD',
NCMEC_ALREADY_SUBMITTED: 'NCMEC_ALREADY_SUBMITTED',
NCMEC_SUBMISSION_FAILED: 'NCMEC_SUBMISSION_FAILED',
MEDIA_METADATA_ERROR: 'MEDIA_METADATA_ERROR',
METHOD_NOT_ALLOWED: 'METHOD_NOT_ALLOWED',
MISSING_ACCESS: 'MISSING_ACCESS',
@@ -1,3 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const CATEGORY_CHILD_SAFETY = 'child_safety' as const;
@@ -166,7 +166,6 @@ export const ValidationErrorCodes = {
NO_ORIGINAL_EMAIL_ON_RECORD: 'NO_ORIGINAL_EMAIL_ON_RECORD',
NO_UPLOADED_PARTS_TO_FINALIZE: 'NO_UPLOADED_PARTS_TO_FINALIZE',
NO_VALID_MEDIA_IN_MESSAGE: 'NO_VALID_MEDIA_IN_MESSAGE',
NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO: 'NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO',
NOT_A_VALID_UNICODE_EMOJI: 'NOT_A_VALID_UNICODE_EMOJI',
ORIGINAL_EMAIL_ALREADY_VERIFIED: 'ORIGINAL_EMAIL_ALREADY_VERIFIED',
ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST: 'ORIGINAL_EMAIL_MUST_BE_VERIFIED_FIRST',
@@ -1,12 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
export class NcmecAlreadySubmittedError extends ConflictError {
constructor() {
super({
code: APIErrorCodes.NCMEC_ALREADY_SUBMITTED,
});
}
}
@@ -1,13 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
export class NcmecSubmissionFailedError extends InternalServerError {
constructor(reason?: string) {
super({
code: APIErrorCodes.NCMEC_SUBMISSION_FAILED,
...(reason != null && {data: {reason}}),
});
}
}
@@ -208,8 +208,6 @@ export const ErrorCodeToI18nKey = {
[APIErrorCodes.MAX_STICKERS]: 'stickers_and_emojis.max_stickers_reached',
[APIErrorCodes.MAX_WEBHOOKS_PER_CHANNEL]: 'webhooks.max_webhooks_per_channel_reached',
[APIErrorCodes.MAX_WEBHOOKS_PER_GUILD]: 'webhooks.max_webhooks_per_guild_reached',
[APIErrorCodes.NCMEC_ALREADY_SUBMITTED]: 'content_and_safety.ncmec_already_submitted',
[APIErrorCodes.NCMEC_SUBMISSION_FAILED]: 'content_and_safety.ncmec_submission_failed',
[APIErrorCodes.MEDIA_METADATA_ERROR]: 'media_and_memes.media_metadata_error',
[APIErrorCodes.METHOD_NOT_ALLOWED]: 'http.method_not_allowed',
[ValidationErrorCodes.MEDIA_ALREADY_IN_FAVORITE_MEMES]: 'media_and_memes.media_already_in_favorites',
@@ -437,8 +435,6 @@ export const ErrorCodeToI18nKey = {
[ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER]:
'admin_and_system.user_must_be_bot_for_system_user',
[ValidationErrorCodes.USER_IS_NOT_BANNED]: 'moderation_and_reports.user_is_not_banned',
[ValidationErrorCodes.NCMEC_ATTACHMENT_MUST_BE_IMAGE_OR_VIDEO]:
'moderation_and_reports.ncmec_attachment_must_be_image_or_video',
[ValidationErrorCodes.EMAIL_ALREADY_IN_USE]: 'email.email_already_in_use',
[ValidationErrorCodes.EMAIL_IS_REQUIRED]: 'email.email_required',
[ValidationErrorCodes.EMAIL_LENGTH_INVALID]: 'email.length_invalid',
@@ -262,8 +262,6 @@ export const ERROR_I18N_MESSAGES = {
'Connection verification failed. Make sure the verification token is correctly placed.',
'content_and_safety.content_blocked': 'This content was blocked by safety systems.',
'content_and_safety.explicit_content_cannot_be_sent': "Explicit content can't be sent.",
'content_and_safety.ncmec_already_submitted': 'This content has already been submitted to NCMEC.',
'content_and_safety.ncmec_submission_failed': "We couldn't submit the report to NCMEC. Please try again later.",
'content_and_safety.nsfw_age_restricted': 'NSFW content is age restricted.',
'discovery.already_applied': 'This community has already applied for Discovery.',
'discovery.application_already_reviewed': 'This Discovery application has already been reviewed.',
@@ -445,8 +443,6 @@ export const ERROR_I18N_MESSAGES = {
'moderation_and_reports.invalid_dsa_verification_code': 'Invalid DSA verification code.',
'moderation_and_reports.invalid_report_flow_answers':
"These answers don't match the report form. Start the report again.",
'moderation_and_reports.ncmec_attachment_must_be_image_or_video':
'Only image or video attachments can be reported to NCMEC.',
'moderation_and_reports.report_already_resolved': 'Report already resolved.',
'moderation_and_reports.report_banned': "You've been banned from submitting reports.",
'moderation_and_reports.report_flow_outdated':
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "فشل التحقق من الاتصال. تأكد من وضع رمز التحقق في المكان الصحيح.",
"content_and_safety.content_blocked": "حظرت أنظمة السلامة هذا المحتوى.",
"content_and_safety.explicit_content_cannot_be_sent": "لا يمكن إرسال المحتوى الصريح.",
"content_and_safety.ncmec_already_submitted": "تم إرسال هذا المحتوى بالفعل إلى NCMEC.",
"content_and_safety.ncmec_submission_failed": "لم نتمكن من إرسال البلاغ إلى NCMEC. حاول مجددًا لاحقًا.",
"content_and_safety.nsfw_age_restricted": "محتوى NSFW مقيّد بالعمر.",
"discovery.already_applied": "لقد تقدم هذا المجتمع بالفعل بطلب الاكتشاف.",
"discovery.application_already_reviewed": "تمت مراجعة طلب الاكتشاف هذا بالفعل.",
@@ -381,7 +379,6 @@ const ERROR_I18N_AR_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "رمز تحقق DSA غير صالح.",
"moderation_and_reports.invalid_report_flow_answers": "هذه الإجابات لا تتطابق مع نموذج الإبلاغ. ابدأ البلاغ من جديد.",
"moderation_and_reports.ip_ban_declined": "لا يمكن إضافة عنوان IP هذا إلى قائمة الحظر.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "يمكن الإبلاغ إلى NCMEC عن مرفقات الصور أو الفيديو فقط.",
"moderation_and_reports.report_already_resolved": "تم حل البلاغ بالفعل.",
"moderation_and_reports.report_banned": "تم حظرك من إرسال البلاغات.",
"moderation_and_reports.report_flow_outdated": "تغيّر نموذج الإبلاغ أثناء تعبئتك له. ابدأ البلاغ من جديد.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Проверката на връзката се провали. Увери се, че токенът за проверка е поставен правилно.",
"content_and_safety.content_blocked": "Това съдържание беше блокирано от системите за безопасност.",
"content_and_safety.explicit_content_cannot_be_sent": "Изрично съдържание не може да бъде изпратено.",
"content_and_safety.ncmec_already_submitted": "Това съдържание вече е изпратено до NCMEC.",
"content_and_safety.ncmec_submission_failed": "Не успяхме да изпратим сигнала до NCMEC. Опитай отново по-късно.",
"content_and_safety.nsfw_age_restricted": "NSFW съдържанието е с възрастово ограничение.",
"discovery.already_applied": "Тази общност вече е кандидатствала за Откриване.",
"discovery.application_already_reviewed": "Това заявление за Откриване вече е разгледано.",
@@ -381,7 +379,6 @@ const ERROR_I18N_BG_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Невалиден код за потвърждение на DSA.",
"moderation_and_reports.invalid_report_flow_answers": "Тези отговори не съответстват на формуляра за сигнал. Започни сигнала отначало.",
"moderation_and_reports.ip_ban_declined": "Този IP адрес не може да бъде добавен към списъка с блокирани адреси.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Само прикачени файлове с изображения или видеоклипове могат да се докладват на NCMEC.",
"moderation_and_reports.report_already_resolved": "Сигналът вече е разгледан.",
"moderation_and_reports.report_banned": "Имаш забрана да подаваш сигнали.",
"moderation_and_reports.report_flow_outdated": "Формулярът за сигнал се промени, докато го попълваше. Започни сигнала отначало.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Ověření propojení se nezdařilo. Zkontrolujte, zda je ověřovací token správně umístěn.",
"content_and_safety.content_blocked": "Tento obsah byl zablokován bezpečnostními systémy.",
"content_and_safety.explicit_content_cannot_be_sent": "Explicitní obsah nelze odeslat.",
"content_and_safety.ncmec_already_submitted": "Tento obsah již byl odeslán NCMEC.",
"content_and_safety.ncmec_submission_failed": "Nepodařilo se odeslat nahlášení organizaci NCMEC. Zkuste to znovu později.",
"content_and_safety.nsfw_age_restricted": "Obsah NSFW je věkově omezený.",
"discovery.already_applied": "Tato komunita již požádala o zařazení do Objevování.",
"discovery.application_already_reviewed": "Tato žádost o zařazení do Objevování už byla zkontrolována.",
@@ -381,7 +379,6 @@ const ERROR_I18N_CS_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Neplatný ověřovací kód DSA.",
"moderation_and_reports.invalid_report_flow_answers": "Tyto odpovědi neodpovídají formuláři nahlášení. Začněte nahlášení znovu.",
"moderation_and_reports.ip_ban_declined": "Tuto IP adresu nelze přidat na seznam blokovaných.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Organizaci NCMEC lze nahlásit pouze přílohy s obrázky nebo videi.",
"moderation_and_reports.report_already_resolved": "Nahlášení již bylo vyřešeno.",
"moderation_and_reports.report_banned": "Máte zakázáno odesílat nahlášení.",
"moderation_and_reports.report_flow_outdated": "Formulář nahlášení se během jeho vyplňování změnil. Nahlášení začněte znovu.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Verifikation af forbindelsen mislykkedes. Sørg for, at verifikationstokenet er placeret korrekt.",
"content_and_safety.content_blocked": "Dette indhold blev blokeret af sikkerhedssystemer.",
"content_and_safety.explicit_content_cannot_be_sent": "Eksplicit indhold kan ikke sendes.",
"content_and_safety.ncmec_already_submitted": "Dette indhold er allerede indsendt til NCMEC.",
"content_and_safety.ncmec_submission_failed": "Vi kunne ikke indsende anmeldelsen til NCMEC. Prøv igen senere.",
"content_and_safety.nsfw_age_restricted": "NSFW-indhold er aldersbegrænset.",
"discovery.already_applied": "Dette fællesskab har allerede ansøgt om at blive vist i Opdag.",
"discovery.application_already_reviewed": "Denne ansøgning til Opdag er allerede blevet behandlet.",
@@ -381,7 +379,6 @@ const ERROR_I18N_DA_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Ugyldig DSA-bekræftelseskode.",
"moderation_and_reports.invalid_report_flow_answers": "Disse svar passer ikke til anmeldelsesformularen. Start anmeldelsen forfra.",
"moderation_and_reports.ip_ban_declined": "Denne IP-adresse kan ikke føjes til bloklisten.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Kun vedhæftede billeder eller videoer kan anmeldes til NCMEC.",
"moderation_and_reports.report_already_resolved": "Anmeldelsen er allerede afsluttet.",
"moderation_and_reports.report_banned": "Du er blevet udelukket fra at indsende anmeldelser.",
"moderation_and_reports.report_flow_outdated": "Anmeldelsesformularen blev ændret, mens du udfyldte den. Start anmeldelsen forfra.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Die Verbindungsüberprüfung ist fehlgeschlagen. Stelle sicher, dass das Verifizierungs-Token richtig platziert ist.",
"content_and_safety.content_blocked": "Dieser Inhalt wurde von den Sicherheitssystemen blockiert.",
"content_and_safety.explicit_content_cannot_be_sent": "Explizite Inhalte können nicht gesendet werden.",
"content_and_safety.ncmec_already_submitted": "Dieser Inhalt wurde bereits an NCMEC gemeldet.",
"content_and_safety.ncmec_submission_failed": "Wir konnten die Meldung nicht an NCMEC senden. Versuch es bitte später noch einmal.",
"content_and_safety.nsfw_age_restricted": "NSFW-Inhalte sind altersbeschränkt.",
"discovery.already_applied": "Diese Community hat bereits die Aufnahme unter „Entdecken“ beantragt.",
"discovery.application_already_reviewed": "Dieser Antrag auf Aufnahme unter „Entdecken“ wurde bereits geprüft.",
@@ -381,7 +379,6 @@ const ERROR_I18N_DE_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Ungültiger DSA-Bestätigungscode.",
"moderation_and_reports.invalid_report_flow_answers": "Diese Antworten passen nicht zum Meldeformular. Starte die Meldung erneut.",
"moderation_and_reports.ip_ban_declined": "Diese IP-Adresse kann nicht zur Blockierliste hinzugefügt werden.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Nur Bild- oder Video-Anhänge können bei NCMEC gemeldet werden.",
"moderation_and_reports.report_already_resolved": "Meldung bereits erledigt.",
"moderation_and_reports.report_banned": "Du bist für das Einreichen von Meldungen gesperrt.",
"moderation_and_reports.report_flow_outdated": "Das Meldeformular hat sich geändert, während du es ausgefüllt hast. Starte die Meldung erneut.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Η επαλήθευση σύνδεσης απέτυχε. Βεβαιώσου ότι το token επαλήθευσης έχει τοποθετηθεί σωστά.",
"content_and_safety.content_blocked": "Αυτό το περιεχόμενο αποκλείστηκε από τα συστήματα ασφαλείας.",
"content_and_safety.explicit_content_cannot_be_sent": "Το ακατάλληλο περιεχόμενο δεν μπορεί να σταλεί.",
"content_and_safety.ncmec_already_submitted": "Αυτό το περιεχόμενο έχει ήδη σταλεί στο NCMEC.",
"content_and_safety.ncmec_submission_failed": "Δεν μπορέσαμε να στείλουμε την αναφορά στο NCMEC. Δοκίμασε ξανά αργότερα.",
"content_and_safety.nsfw_age_restricted": "Το περιεχόμενο NSFW έχει ηλικιακό περιορισμό.",
"discovery.already_applied": "Αυτή η κοινότητα έχει ήδη υποβάλει αίτηση για την Ανακάλυψη.",
"discovery.application_already_reviewed": "Αυτή η αίτηση για την Ανακάλυψη έχει ήδη εξεταστεί.",
@@ -381,7 +379,6 @@ const ERROR_I18N_EL_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Μη έγκυρος κωδικός επαλήθευσης DSA.",
"moderation_and_reports.invalid_report_flow_answers": "Αυτές οι απαντήσεις δεν ταιριάζουν με τη φόρμα αναφοράς. Ξεκίνα ξανά την αναφορά.",
"moderation_and_reports.ip_ban_declined": "Αυτή η διεύθυνση IP δεν μπορεί να προστεθεί στη λίστα αποκλεισμού.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Στο NCMEC μπορούν να αναφερθούν μόνο συνημμένα εικόνας ή βίντεο.",
"moderation_and_reports.report_already_resolved": "Η αναφορά έχει ήδη διευθετηθεί.",
"moderation_and_reports.report_banned": "Έχεις αποκλειστεί από την υποβολή αναφορών.",
"moderation_and_reports.report_flow_outdated": "Η φόρμα αναφοράς άλλαξε ενώ τη συμπλήρωνες. Ξεκίνα ξανά την αναφορά.",
@@ -215,8 +215,6 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Connection verification failed. Make sure the verification token is correctly placed.",
"content_and_safety.content_blocked": "This content was blocked by safety systems.",
"content_and_safety.explicit_content_cannot_be_sent": "Explicit content can't be sent.",
"content_and_safety.ncmec_already_submitted": "This content has already been submitted to NCMEC.",
"content_and_safety.ncmec_submission_failed": "We couldn't submit the report to NCMEC. Please try again later.",
"content_and_safety.nsfw_age_restricted": "NSFW content is age restricted.",
"discovery.already_applied": "This community has already applied for Discovery.",
"discovery.application_already_reviewed": "This Discovery application has already been reviewed.",
@@ -381,7 +379,6 @@ const ERROR_I18N_EN_GB_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Invalid DSA verification code.",
"moderation_and_reports.invalid_report_flow_answers": "These answers don't match the report form. Start the report again.",
"moderation_and_reports.ip_ban_declined": "This IP address cannot be added to the blocklist.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Only image or video attachments can be reported to NCMEC.",
"moderation_and_reports.report_already_resolved": "Report already resolved.",
"moderation_and_reports.report_banned": "You've been banned from submitting reports.",
"moderation_and_reports.report_flow_outdated": "The report form changed while you were filling it in. Start the report again.",
@@ -215,8 +215,6 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Falló la verificación de la conexión. Asegúrate de que el token de verificación esté colocado correctamente.",
"content_and_safety.content_blocked": "Este contenido fue bloqueado por sistemas de seguridad.",
"content_and_safety.explicit_content_cannot_be_sent": "No se puede enviar contenido explícito.",
"content_and_safety.ncmec_already_submitted": "Este contenido ya fue remitido a NCMEC.",
"content_and_safety.ncmec_submission_failed": "No se pudo enviar el reporte a NCMEC. Inténtalo de nuevo más tarde.",
"content_and_safety.nsfw_age_restricted": "El contenido NSFW tiene restricción de edad.",
"discovery.already_applied": "Esta comunidad ya ha solicitado aparecer en Descubrir.",
"discovery.application_already_reviewed": "Esta solicitud para aparecer en Descubrir ya ha sido revisada.",
@@ -381,7 +379,6 @@ const ERROR_I18N_ES_419_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Código de verificación DSA no válido.",
"moderation_and_reports.invalid_report_flow_answers": "Estas respuestas no coinciden con el formulario de reporte. Vuelve a iniciar el reporte.",
"moderation_and_reports.ip_ban_declined": "Esta dirección IP no se puede agregar a la lista de bloqueo.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Solo se pueden reportar a NCMEC archivos adjuntos de imagen o video.",
"moderation_and_reports.report_already_resolved": "El reporte ya se resolvió.",
"moderation_and_reports.report_banned": "Se te ha prohibido enviar reportes.",
"moderation_and_reports.report_flow_outdated": "El formulario de reporte cambió mientras lo completabas. Vuelve a iniciar el reporte.",
@@ -215,8 +215,6 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "La verificación de la conexión ha fallado. Asegúrate de que el token de verificación esté colocado correctamente.",
"content_and_safety.content_blocked": "Este contenido ha sido bloqueado por los sistemas de seguridad.",
"content_and_safety.explicit_content_cannot_be_sent": "No se puede enviar contenido explícito.",
"content_and_safety.ncmec_already_submitted": "Este contenido ya ha sido enviado a NCMEC.",
"content_and_safety.ncmec_submission_failed": "No hemos podido enviar la denuncia a NCMEC. Inténtalo de nuevo más tarde.",
"content_and_safety.nsfw_age_restricted": "El contenido NSFW tiene restricción de edad.",
"discovery.already_applied": "Esta comunidad ya ha solicitado aparecer en Descubrir.",
"discovery.application_already_reviewed": "Esta solicitud para aparecer en Descubrir ya ha sido revisada.",
@@ -381,7 +379,6 @@ const ERROR_I18N_ES_ES_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Código de verificación DSA no válido.",
"moderation_and_reports.invalid_report_flow_answers": "Estas respuestas no coinciden con el formulario de denuncia. Vuelve a empezar la denuncia.",
"moderation_and_reports.ip_ban_declined": "Esta dirección IP no se puede añadir a la lista de bloqueo.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Solo se pueden denunciar adjuntos de imagen o vídeo a NCMEC.",
"moderation_and_reports.report_already_resolved": "La denuncia ya se ha resuelto.",
"moderation_and_reports.report_banned": "Se te ha prohibido enviar denuncias.",
"moderation_and_reports.report_flow_outdated": "El formulario de denuncia ha cambiado mientras lo rellenabas. Vuelve a empezar la denuncia.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Yhteyden vahvistus epäonnistui. Varmista, että vahvistustunnus on sijoitettu oikeaan paikkaan.",
"content_and_safety.content_blocked": "Turvajärjestelmät estivät tämän sisällön.",
"content_and_safety.explicit_content_cannot_be_sent": "Seksuaalisesti eksplisiittistä sisältöä ei voi lähettää.",
"content_and_safety.ncmec_already_submitted": "Tämä sisältö on jo toimitettu NCMEC:lle.",
"content_and_safety.ncmec_submission_failed": "Ilmoituksen lähettäminen NCMEC:lle epäonnistui. Yritä uudelleen myöhemmin.",
"content_and_safety.nsfw_age_restricted": "NSFW-sisällöllä on ikärajoitus.",
"discovery.already_applied": "Tämä yhteisö on jo hakenut Tutustu-näkymään.",
"discovery.application_already_reviewed": "Tämä hakemus Tutustu-näkymään on jo käsitelty.",
@@ -381,7 +379,6 @@ const ERROR_I18N_FI_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Virheellinen DSA-vahvistuskoodi.",
"moderation_and_reports.invalid_report_flow_answers": "Nämä vastaukset eivät vastaa ilmoituslomaketta. Aloita ilmoitus uudelleen.",
"moderation_and_reports.ip_ban_declined": "Tätä IP-osoitetta ei voi lisätä estolistalle.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "NCMEC:lle voi ilmoittaa vain kuva- tai videoliitteistä.",
"moderation_and_reports.report_already_resolved": "Ilmoitus on jo ratkaistu.",
"moderation_and_reports.report_banned": "Sinut on estetty lähettämästä ilmoituksia.",
"moderation_and_reports.report_flow_outdated": "Ilmoituslomake muuttui, kun täytit sitä. Aloita ilmoitus uudelleen.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "La vérification de la connexion a échoué. Vérifiez que le jeton de vérification est placé correctement.",
"content_and_safety.content_blocked": "Ce contenu a été bloqué par les systèmes de sécurité.",
"content_and_safety.explicit_content_cannot_be_sent": "Le contenu explicite ne peut pas être envoyé.",
"content_and_safety.ncmec_already_submitted": "Ce contenu a déjà été soumis à NCMEC.",
"content_and_safety.ncmec_submission_failed": "Impossible d’envoyer le signalement au NCMEC. Réessayez plus tard.",
"content_and_safety.nsfw_age_restricted": "Le contenu NSFW est soumis à une restriction d'âge.",
"discovery.already_applied": "Cette communauté a déjà demandé à figurer dans Découverte.",
"discovery.application_already_reviewed": "Cette demande d’inscription dans Découverte a déjà été examinée.",
@@ -381,7 +379,6 @@ const ERROR_I18N_FR_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Code de vérification DSA invalide.",
"moderation_and_reports.invalid_report_flow_answers": "Ces réponses ne correspondent pas au formulaire de signalement. Veuillez recommencer le signalement.",
"moderation_and_reports.ip_ban_declined": "Cette adresse IP ne peut pas être ajoutée à la liste de blocage.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Seules les pièces jointes de type image ou vidéo peuvent être signalées au NCMEC.",
"moderation_and_reports.report_already_resolved": "Signalement déjà résolu.",
"moderation_and_reports.report_banned": "Vous n’êtes plus autorisé à envoyer des signalements.",
"moderation_and_reports.report_flow_outdated": "Le formulaire de signalement a changé pendant que vous le remplissiez. Veuillez recommencer le signalement.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "אימות החיבור נכשל. נא לוודא שאסימון האימות ממוקם נכון.",
"content_and_safety.content_blocked": "תוכן זה נחסם על ידי מערכות הבטיחות.",
"content_and_safety.explicit_content_cannot_be_sent": "לא ניתן לשלוח תוכן מפורש.",
"content_and_safety.ncmec_already_submitted": "תוכן זה כבר הוגש ל-NCMEC.",
"content_and_safety.ncmec_submission_failed": "לא הצלחנו להגיש את הדיווח ל-NCMEC. ניתן לנסות שוב מאוחר יותר.",
"content_and_safety.nsfw_age_restricted": "תוכן NSFW מוגבל בגיל.",
"discovery.already_applied": "קהילה זו כבר הגישה בקשה לגילוי.",
"discovery.application_already_reviewed": "בקשת הגילוי הזו כבר נבדקה.",
@@ -381,7 +379,6 @@ const ERROR_I18N_HE_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "קוד אימות DSA שגוי.",
"moderation_and_reports.invalid_report_flow_answers": "התשובות האלה לא תואמות לטופס הדיווח. יש להתחיל את הדיווח מחדש.",
"moderation_and_reports.ip_ban_declined": "לא ניתן להוסיף כתובת IP זו לרשימת החסימה.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "ניתן לדווח ל-NCMEC רק על קבצים מצורפים שהם תמונות או סרטונים.",
"moderation_and_reports.report_already_resolved": "הדיווח כבר טופל.",
"moderation_and_reports.report_banned": "נחסמת מלהגיש דיווחים.",
"moderation_and_reports.report_flow_outdated": "טופס הדיווח השתנה במהלך המילוי. יש להתחיל את הדיווח מחדש.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "कनेक्शन वेरिफ़िकेशन फेल हो गया। पक्का करें कि वेरिफ़िकेशन टोकन सही जगह पर रखा गया है।",
"content_and_safety.content_blocked": "इस कंटेंट को सेफ्टी सिस्टम ने ब्लॉक कर दिया है।",
"content_and_safety.explicit_content_cannot_be_sent": "एक्सप्लिसिट कंटेंट नहीं भेजा जा सकता।",
"content_and_safety.ncmec_already_submitted": "यह कंटेंट पहले ही NCMEC को भेजा जा चुका है।",
"content_and_safety.ncmec_submission_failed": "हम NCMEC को रिपोर्ट भेज नहीं पाए। कृपया बाद में फिर से कोशिश करें।",
"content_and_safety.nsfw_age_restricted": "NSFW कंटेंट उम्र के हिसाब से प्रतिबंधित है।",
"discovery.already_applied": "इस कम्युनिटी ने पहले ही डिस्कवरी के लिए अप्लाई कर दिया है।",
"discovery.application_already_reviewed": "इस डिस्कवरी आवेदन की पहले ही समीक्षा हो चुकी है।",
@@ -381,7 +379,6 @@ const ERROR_I18N_HI_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "अमान्य DSA वेरिफ़िकेशन कोड।",
"moderation_and_reports.invalid_report_flow_answers": "ये जवाब रिपोर्ट फ़ॉर्म से मेल नहीं खाते। रिपोर्ट को फिर से शुरू करें।",
"moderation_and_reports.ip_ban_declined": "इस IP पते को ब्लॉकलिस्ट में नहीं जोड़ा जा सकता।",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "सिर्फ़ इमेज या वीडियो अटैचमेंट ही NCMEC को रिपोर्ट किए जा सकते हैं।",
"moderation_and_reports.report_already_resolved": "रिपोर्ट पहले ही हल हो चुकी है।",
"moderation_and_reports.report_banned": "आपको रिपोर्ट सबमिट करने से बैन कर दिया गया है।",
"moderation_and_reports.report_flow_outdated": "रिपोर्ट फ़ॉर्म भरते समय बदल गया था। कृपया रिपोर्ट दोबारा शुरू करें।",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Provjera veze nije uspjela. Uvjeri se da je token za provjeru ispravno postavljen.",
"content_and_safety.content_blocked": "Sigurnosni sustavi blokirali su ovaj sadržaj.",
"content_and_safety.explicit_content_cannot_be_sent": "Eksplicitni sadržaj ne može se poslati.",
"content_and_safety.ncmec_already_submitted": "Ovaj sadržaj je već poslan NCMEC-u.",
"content_and_safety.ncmec_submission_failed": "Nismo mogli poslati izvješće NCMEC-u. Pokušaj ponovno kasnije.",
"content_and_safety.nsfw_age_restricted": "NSFW sadržaj je dobno ograničen.",
"discovery.already_applied": "Ova zajednica već je podnijela prijavu za otkrivanje.",
"discovery.application_already_reviewed": "Ova prijava za otkrivanje već je pregledana.",
@@ -381,7 +379,6 @@ const ERROR_I18N_HR_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Nevažeći DSA kod za provjeru.",
"moderation_and_reports.invalid_report_flow_answers": "Ovi odgovori ne odgovaraju obrascu prijave. Započni prijavu ponovno.",
"moderation_and_reports.ip_ban_declined": "Ovu IP adresu nije moguće dodati na popis blokiranih.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "NCMEC-u se mogu prijaviti samo slikovni ili video privitci.",
"moderation_and_reports.report_already_resolved": "Prijava je već riješena.",
"moderation_and_reports.report_banned": "Zabranjeno ti je podnositi prijave.",
"moderation_and_reports.report_flow_outdated": "Obrazac prijave promijenio se tijekom ispunjavanja. Započni prijavu ponovno.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "A kapcsolat ellenőrzése sikertelen. Ellenőrizd, hogy az ellenőrző token a helyén van.",
"content_and_safety.content_blocked": "Ezt a tartalmat a biztonsági rendszerek blokkolták.",
"content_and_safety.explicit_content_cannot_be_sent": "Explicit tartalom nem küldhető.",
"content_and_safety.ncmec_already_submitted": "Ezt a tartalmat már beküldtük az NCMEC-nek.",
"content_and_safety.ncmec_submission_failed": "Nem sikerült beküldeni a bejelentést az NCMEC-nek. Próbáld meg újra később.",
"content_and_safety.nsfw_age_restricted": "Az NSFW-tartalom korhatáros.",
"discovery.already_applied": "Ez a közösség már jelentkezett a Felfedezésbe.",
"discovery.application_already_reviewed": "Ezt a Felfedezésbe való jelentkezést már elbírálták.",
@@ -381,7 +379,6 @@ const ERROR_I18N_HU_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Érvénytelen DSA-ellenőrzőkód.",
"moderation_and_reports.invalid_report_flow_answers": "Ezek a válaszok nem egyeznek a bejelentési űrlappal. Kezdd elölről a bejelentést.",
"moderation_and_reports.ip_ban_declined": "Ez az IP-cím nem adható hozzá a tiltólistához.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Csak kép- vagy videómellékleteket lehet jelenteni az NCMEC-nek.",
"moderation_and_reports.report_already_resolved": "A bejelentést már lezárták.",
"moderation_and_reports.report_banned": "Eltiltottak a bejelentések küldésétől.",
"moderation_and_reports.report_flow_outdated": "A bejelentési űrlap megváltozott, amíg kitöltötted. Kezdd elölről a bejelentést.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Verifikasi koneksi gagal. Pastikan token verifikasi ditempatkan dengan benar.",
"content_and_safety.content_blocked": "Konten ini diblokir oleh sistem keamanan.",
"content_and_safety.explicit_content_cannot_be_sent": "Konten eksplisit tidak bisa dikirim.",
"content_and_safety.ncmec_already_submitted": "Konten ini sudah dilaporkan ke NCMEC.",
"content_and_safety.ncmec_submission_failed": "Laporan tidak bisa dikirim ke NCMEC. Coba lagi nanti.",
"content_and_safety.nsfw_age_restricted": "Konten NSFW dibatasi usia.",
"discovery.already_applied": "Komunitas ini sudah mendaftar ke Discovery.",
"discovery.application_already_reviewed": "Pengajuan Discovery ini sudah ditinjau.",
@@ -381,7 +379,6 @@ const ERROR_I18N_ID_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Kode verifikasi DSA tidak valid.",
"moderation_and_reports.invalid_report_flow_answers": "Jawaban ini tidak sesuai dengan formulir laporan. Mulai ulang laporanmu.",
"moderation_and_reports.ip_ban_declined": "Alamat IP ini tidak dapat ditambahkan ke daftar blokir.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Hanya lampiran berupa gambar atau video yang dapat dilaporkan ke NCMEC.",
"moderation_and_reports.report_already_resolved": "Laporan sudah ditangani.",
"moderation_and_reports.report_banned": "Kamu dilarang mengirim laporan.",
"moderation_and_reports.report_flow_outdated": "Formulir laporan berubah saat kamu mengisinya. Mulai ulang laporanmu.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Verifica della connessione fallita. Assicurati che il token di verifica sia posizionato correttamente.",
"content_and_safety.content_blocked": "Questo contenuto è stato bloccato dai sistemi di sicurezza.",
"content_and_safety.explicit_content_cannot_be_sent": "Non è possibile inviare contenuti sessualmente espliciti.",
"content_and_safety.ncmec_already_submitted": "Questo contenuto è già stato inviato a NCMEC.",
"content_and_safety.ncmec_submission_failed": "Impossibile inviare la segnalazione a NCMEC. Riprova più tardi.",
"content_and_safety.nsfw_age_restricted": "Il contenuto NSFW ha restrizioni di età.",
"discovery.already_applied": "Questa community ha già inviato una candidatura per Scopri.",
"discovery.application_already_reviewed": "Questa candidatura per Scopri è già stata esaminata.",
@@ -381,7 +379,6 @@ const ERROR_I18N_IT_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Codice di verifica DSA non valido.",
"moderation_and_reports.invalid_report_flow_answers": "Queste risposte non corrispondono al modulo di segnalazione. Ricomincia la segnalazione.",
"moderation_and_reports.ip_ban_declined": "Questo indirizzo IP non può essere aggiunto all'elenco di blocco.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Solo allegati di immagini o video possono essere segnalati a NCMEC.",
"moderation_and_reports.report_already_resolved": "La segnalazione è già stata risolta.",
"moderation_and_reports.report_banned": "Non hai più il permesso di inviare segnalazioni.",
"moderation_and_reports.report_flow_outdated": "Il modulo di segnalazione è cambiato mentre lo stavi compilando. Ricomincia la segnalazione.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "接続の検証に失敗しました。検証トークンが正しく配置されているかご確認ください。",
"content_and_safety.content_blocked": "このコンテンツは安全システムによってブロックされました。",
"content_and_safety.explicit_content_cannot_be_sent": "不適切なコンテンツは送信できません。",
"content_and_safety.ncmec_already_submitted": "このコンテンツはすでにNCMECに提出されています。",
"content_and_safety.ncmec_submission_failed": "NCMECへの報告を送信できませんでした。後でもう一度お試しください。",
"content_and_safety.nsfw_age_restricted": "NSFWコンテンツには年齢制限があります。",
"discovery.already_applied": "このコミュニティはすでに「見つける」への掲載を申請しています。",
"discovery.application_already_reviewed": "「見つける」への掲載申請はすでに審査済みです。",
@@ -381,7 +379,6 @@ const ERROR_I18N_JA_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "DSA確認コードが無効です。",
"moderation_and_reports.invalid_report_flow_answers": "これらの回答は報告フォームと一致しません。最初から報告し直してください。",
"moderation_and_reports.ip_ban_declined": "このIPアドレスはブロックリストに追加できません。",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "NCMECに報告できるのは画像または動画の添付ファイルのみです。",
"moderation_and_reports.report_already_resolved": "報告はすでに解決済みです。",
"moderation_and_reports.report_banned": "報告の送信は禁止されています。",
"moderation_and_reports.report_flow_outdated": "入力中に報告フォームが変更されました。最初から報告し直してください。",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "연결 인증에 실패했어요. 인증 토큰이 올바른 위치에 있는지 확인해 주세요.",
"content_and_safety.content_blocked": "안전 시스템이 이 콘텐츠를 차단했어요.",
"content_and_safety.explicit_content_cannot_be_sent": "선정적인 콘텐츠는 보낼 수 없어요.",
"content_and_safety.ncmec_already_submitted": "이 콘텐츠는 이미 NCMEC에 제출된 상태예요.",
"content_and_safety.ncmec_submission_failed": "NCMEC에 신고를 제출하지 못했어요. 나중에 다시 시도해 주세요.",
"content_and_safety.nsfw_age_restricted": "NSFW 콘텐츠에는 연령 제한이 적용돼요.",
"discovery.already_applied": "이 커뮤니티는 이미 디스커버리에 신청했어요.",
"discovery.application_already_reviewed": "이 디스커버리 신청은 이미 검토되었어요.",
@@ -381,7 +379,6 @@ const ERROR_I18N_KO_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "DSA 인증 코드가 올바르지 않아요.",
"moderation_and_reports.invalid_report_flow_answers": "이 답변은 신고 양식과 맞지 않아요. 신고를 다시 시작해 주세요.",
"moderation_and_reports.ip_ban_declined": "이 IP 주소는 차단 목록에 추가할 수 없어요.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "이미지 또는 동영상 첨부 파일만 NCMEC에 신고할 수 있어요.",
"moderation_and_reports.report_already_resolved": "이미 해결된 신고예요.",
"moderation_and_reports.report_banned": "신고 제출이 금지되었어요.",
"moderation_and_reports.report_flow_outdated": "작성하는 동안 신고 양식이 변경되었어요. 신고를 다시 시작해 주세요.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Ryšio patvirtinimas nepavyko. Įsitikink, kad patvirtinimo žetonas įdėtas teisingai.",
"content_and_safety.content_blocked": "Šį turinį užblokavo saugumo sistemos.",
"content_and_safety.explicit_content_cannot_be_sent": "Nepadorus turinys negali būti siunčiamas.",
"content_and_safety.ncmec_already_submitted": "Šis turinys jau išsiųstas NCMEC.",
"content_and_safety.ncmec_submission_failed": "Nepavyko išsiųsti pranešimo NCMEC. Pabandyk dar kartą vėliau.",
"content_and_safety.nsfw_age_restricted": "NSFW turiniui taikomas amžiaus ribojimas.",
"discovery.already_applied": "Ši bendruomenė jau pateikė Atradimo paraišką.",
"discovery.application_already_reviewed": "Ši Atradimo paraiška jau peržiūrėta.",
@@ -381,7 +379,6 @@ const ERROR_I18N_LT_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Neteisingas DSA patvirtinimo kodas.",
"moderation_and_reports.invalid_report_flow_answers": "Šie atsakymai neatitinka pranešimo formos. Pradėk pranešimą iš naujo.",
"moderation_and_reports.ip_ban_declined": "Šio IP adreso negalima įtraukti į blokavimo sąrašą.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "NCMEC galima pranešti tik apie paveikslėlių ar vaizdo įrašų priedus.",
"moderation_and_reports.report_already_resolved": "Pranešimas jau išnagrinėtas.",
"moderation_and_reports.report_banned": "Tau uždrausta teikti pranešimus.",
"moderation_and_reports.report_flow_outdated": "Pranešimo forma pasikeitė, kol ją pildei. Pradėk pranešimą iš naujo.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Verificatie van de verbinding mislukt. Controleer of het verificatietoken juist is geplaatst.",
"content_and_safety.content_blocked": "Deze inhoud is geblokkeerd door veiligheidssystemen.",
"content_and_safety.explicit_content_cannot_be_sent": "Seksueel expliciete inhoud kan niet worden verzonden.",
"content_and_safety.ncmec_already_submitted": "Deze inhoud is al naar NCMEC verstuurd.",
"content_and_safety.ncmec_submission_failed": "We konden de melding niet indienen bij NCMEC. Probeer het later opnieuw.",
"content_and_safety.nsfw_age_restricted": "Voor NSFW-inhoud geldt een leeftijdsgrens.",
"discovery.already_applied": "Deze community heeft zich al aangemeld voor Ontdekken.",
"discovery.application_already_reviewed": "Deze aanvraag voor Ontdekken is al beoordeeld.",
@@ -381,7 +379,6 @@ const ERROR_I18N_NL_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Ongeldige DSA-verificatiecode.",
"moderation_and_reports.invalid_report_flow_answers": "Deze antwoorden passen niet bij het meldingsformulier. Begin opnieuw met je melding.",
"moderation_and_reports.ip_ban_declined": "Dit IP-adres kan niet aan de blokkeerlijst worden toegevoegd.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Alleen bijlagen met afbeeldingen of video’s kunnen bij NCMEC worden gemeld.",
"moderation_and_reports.report_already_resolved": "De melding is al afgehandeld.",
"moderation_and_reports.report_banned": "Je bent uitgesloten van het indienen van meldingen.",
"moderation_and_reports.report_flow_outdated": "Het meldingsformulier is gewijzigd terwijl je het invulde. Begin opnieuw met je melding.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Tilkoblingsverifisering mislyktes. Sjekk at verifiseringstokenet er riktig plassert.",
"content_and_safety.content_blocked": "Dette innholdet ble blokkert av sikkerhetssystemene.",
"content_and_safety.explicit_content_cannot_be_sent": "Eksplisitt innhold kan ikke sendes.",
"content_and_safety.ncmec_already_submitted": "Dette innholdet er allerede rapportert til NCMEC.",
"content_and_safety.ncmec_submission_failed": "Klarte ikke å sende rapporten til NCMEC. Prøv igjen senere.",
"content_and_safety.nsfw_age_restricted": "NSFW-innhold har aldersgrense.",
"discovery.already_applied": "Dette fellesskapet har allerede søkt om å bli oppført i Oppdag.",
"discovery.application_already_reviewed": "Denne søknaden om å bli oppført i Oppdag er allerede vurdert.",
@@ -381,7 +379,6 @@ const ERROR_I18N_NO_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Ugyldig DSA-bekreftelseskode.",
"moderation_and_reports.invalid_report_flow_answers": "Disse svarene stemmer ikke med rapportskjemaet. Start rapporten på nytt.",
"moderation_and_reports.ip_ban_declined": "Denne IP-adressen kan ikke legges til i blokkeringslisten.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Bare bilde- eller videovedlegg kan rapporteres til NCMEC.",
"moderation_and_reports.report_already_resolved": "Rapporten er allerede behandlet.",
"moderation_and_reports.report_banned": "Du er utestengt fra å sende rapporter.",
"moderation_and_reports.report_flow_outdated": "Rapportskjemaet ble endret mens du fylte det ut. Start rapporten på nytt.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Nie udało się zweryfikować połączenia. Upewnij się, że token weryfikacyjny jest poprawnie umieszczony.",
"content_and_safety.content_blocked": "Systemy bezpieczeństwa zablokowały tę treść.",
"content_and_safety.explicit_content_cannot_be_sent": "Nie można wysyłać treści o jednoznacznie seksualnym charakterze.",
"content_and_safety.ncmec_already_submitted": "Tę treść już zgłoszono do NCMEC.",
"content_and_safety.ncmec_submission_failed": "Nie mogliśmy przesłać zgłoszenia do NCMEC. Spróbuj ponownie później.",
"content_and_safety.nsfw_age_restricted": "Treści dla dorosłych są objęte ograniczeniem wiekowym.",
"discovery.already_applied": "Ta społeczność została już zgłoszona do sekcji Odkrywanie.",
"discovery.application_already_reviewed": "To zgłoszenie do sekcji Odkrywanie zostało już rozpatrzone.",
@@ -381,7 +379,6 @@ const ERROR_I18N_PL_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Nieprawidłowy kod weryfikacyjny DSA.",
"moderation_and_reports.invalid_report_flow_answers": "Te odpowiedzi nie pasują do formularza zgłoszenia. Rozpocznij zgłoszenie ponownie.",
"moderation_and_reports.ip_ban_declined": "Ten adres IP nie może zostać dodany do listy blokowanych.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Do NCMEC można zgłaszać tylko załączniki w postaci obrazów lub filmów.",
"moderation_and_reports.report_already_resolved": "Zgłoszenie zostało już rozpatrzone.",
"moderation_and_reports.report_banned": "Zablokowano Ci możliwość przesyłania zgłoszeń.",
"moderation_and_reports.report_flow_outdated": "Formularz zgłoszenia zmienił się podczas jego wypełniania. Rozpocznij zgłoszenie ponownie.",
@@ -215,8 +215,6 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "A verificação da conexão falhou. Certifique-se de que o token de verificação está corretamente posicionado.",
"content_and_safety.content_blocked": "Este conteúdo foi bloqueado por sistemas de segurança.",
"content_and_safety.explicit_content_cannot_be_sent": "Conteúdo explícito não pode ser enviado.",
"content_and_safety.ncmec_already_submitted": "Este conteúdo já foi enviado ao NCMEC.",
"content_and_safety.ncmec_submission_failed": "Não foi possível enviar a denúncia ao NCMEC. Tente novamente mais tarde.",
"content_and_safety.nsfw_age_restricted": "Conteúdo NSFW tem restrição de idade.",
"discovery.already_applied": "Esta comunidade já solicitou a inclusão em Descobrir.",
"discovery.application_already_reviewed": "Esta solicitação de inclusão em Descobrir já foi analisada.",
@@ -381,7 +379,6 @@ const ERROR_I18N_PT_BR_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Código de verificação do DSA inválido.",
"moderation_and_reports.invalid_report_flow_answers": "Estas respostas não correspondem ao formulário de denúncia. Inicie a denúncia novamente.",
"moderation_and_reports.ip_ban_declined": "Este endereço IP não pode ser adicionado à lista de bloqueio.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Apenas anexos de imagem ou vídeo podem ser denunciados ao NCMEC.",
"moderation_and_reports.report_already_resolved": "A denúncia já foi resolvida.",
"moderation_and_reports.report_banned": "Você foi impedido de enviar denúncias.",
"moderation_and_reports.report_flow_outdated": "O formulário de denúncia mudou enquanto você o preenchia. Comece a denúncia novamente.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Verificarea conexiunii a eșuat. Asigură-te că tokenul de verificare este plasat corect.",
"content_and_safety.content_blocked": "Acest conținut a fost blocat de sistemele de siguranță.",
"content_and_safety.explicit_content_cannot_be_sent": "Conținutul explicit nu poate fi trimis.",
"content_and_safety.ncmec_already_submitted": "Acest conținut a fost deja trimis la NCMEC.",
"content_and_safety.ncmec_submission_failed": "Nu am putut trimite raportul către NCMEC. Încearcă din nou mai târziu.",
"content_and_safety.nsfw_age_restricted": "Conținutul NSFW este restricționat pe bază de vârstă.",
"discovery.already_applied": "Această comunitate a trimis deja o cerere pentru Discovery.",
"discovery.application_already_reviewed": "Această cerere pentru Discovery a fost deja analizată.",
@@ -381,7 +379,6 @@ const ERROR_I18N_RO_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Cod de verificare DSA invalid.",
"moderation_and_reports.invalid_report_flow_answers": "Aceste răspunsuri nu corespund formularului de raportare. Repornește raportarea.",
"moderation_and_reports.ip_ban_declined": "Această adresă IP nu poate fi adăugată pe lista de blocare.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Doar atașamentele de tip imagine sau video pot fi raportate la NCMEC.",
"moderation_and_reports.report_already_resolved": "Raportul a fost deja rezolvat.",
"moderation_and_reports.report_banned": "Ți s-a interzis să trimiți rapoarte.",
"moderation_and_reports.report_flow_outdated": "Formularul de raportare s-a modificat în timp ce îl completai. Repornește raportarea.",
-3
View File
@@ -215,8 +215,6 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Не удалось проверить подключение. Убедись, что токен подтверждения размещён правильно.",
"content_and_safety.content_blocked": "Этот контент был заблокирован системами безопасности.",
"content_and_safety.explicit_content_cannot_be_sent": "Неприемлемый контент нельзя отправить.",
"content_and_safety.ncmec_already_submitted": "Этот контент уже был отправлен в NCMEC.",
"content_and_safety.ncmec_submission_failed": "Не удалось отправить жалобу в NCMEC. Попробуй ещё раз позже.",
"content_and_safety.nsfw_age_restricted": "Контент NSFW имеет возрастное ограничение.",
"discovery.already_applied": "Это сообщество уже подало заявку на участие в каталоге.",
"discovery.application_already_reviewed": "Эта заявка на добавление в каталог уже рассмотрена.",
@@ -381,7 +379,6 @@ const ERROR_I18N_RU_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Неверный код подтверждения DSA.",
"moderation_and_reports.invalid_report_flow_answers": "Эти ответы не соответствуют форме жалобы. Начни жалобу заново.",
"moderation_and_reports.ip_ban_declined": "Этот IP-адрес нельзя добавить в чёрный список.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Пожаловаться в NCMEC можно только на вложения с изображением или видео.",
"moderation_and_reports.report_already_resolved": "Жалоба уже рассмотрена.",
"moderation_and_reports.report_banned": "Ты забанен и не можешь отправлять жалобы.",
"moderation_and_reports.report_flow_outdated": "Форма жалобы изменилась во время заполнения. Начни жалобу заново.",
@@ -215,8 +215,6 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({
"connections.verification_failed": "Anslutningsverifieringen misslyckades. Kontrollera att verifieringstoken är korrekt placerad.",
"content_and_safety.content_blocked": "Det här innehållet blockerades av säkerhetssystemen.",
"content_and_safety.explicit_content_cannot_be_sent": "Sexuellt explicit innehåll kan inte skickas.",
"content_and_safety.ncmec_already_submitted": "Det här innehållet har redan skickats till NCMEC.",
"content_and_safety.ncmec_submission_failed": "Vi kunde inte skicka anmälan till NCMEC. Försök igen senare.",
"content_and_safety.nsfw_age_restricted": "NSFW-innehåll är åldersbegränsat.",
"discovery.already_applied": "Den här communityn har redan ansökt om att visas i Upptäck.",
"discovery.application_already_reviewed": "Den här ansökan till Upptäck har redan granskats.",
@@ -381,7 +379,6 @@ const ERROR_I18N_SV_SE_MESSAGES = defineErrorI18nLocaleMessages({
"moderation_and_reports.invalid_dsa_verification_code": "Ogiltig DSA-verifieringskod.",
"moderation_and_reports.invalid_report_flow_answers": "Svaren stämmer inte med anmälningsformuläret. Börja om med anmälan.",
"moderation_and_reports.ip_ban_declined": "Den här IP-adressen kan inte läggas till i blocklistan.",
"moderation_and_reports.ncmec_attachment_must_be_image_or_video": "Endast bild- eller videobilagor kan anmälas till NCMEC.",
"moderation_and_reports.report_already_resolved": "Anmälan har redan behandlats.",
"moderation_and_reports.report_banned": "Du har förbjudits att skicka in anmälningar.",
"moderation_and_reports.report_flow_outdated": "Anmälningsformuläret ändrades medan du fyllde i det. Börja om med anmälan.",
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more