mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
1 parent
bc17922b02
commit
ece622e800
471 files changed
+56262
-35932
No files matched your search
@@ -175,7 +175,13 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
|
||||
const OPEN_INTEGER_ENUMS: &[&str] = &[
|
||||
"ChannelType",
|
||||
"MessageType",
|
||||
"ReportStatus",
|
||||
"ReportType",
|
||||
"WebhookType",
|
||||
];
|
||||
|
||||
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
|
||||
let components = spec.components.as_mut().expect("missing API components");
|
||||
|
||||
+1112
-18
File diff suppressed because it is too large.
Load diff
@@ -215,6 +215,13 @@ 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| {
|
||||
@@ -229,6 +236,10 @@ 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()
|
||||
}
|
||||
@@ -282,6 +293,34 @@ 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]);
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
ReportEntry, ReportLegalHoldResponse, ReportReasonListResponse, ResolveReportResponse,
|
||||
SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -13,8 +14,10 @@ pub struct SearchReportsParams<'a> {
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
@@ -26,23 +29,6 @@ pub struct SearchReportsParams<'a> {
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
status: Option<i32>,
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -72,6 +58,27 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_report_legal_hold(
|
||||
&self,
|
||||
report_id: &str,
|
||||
legal_hold_until: Option<&str>,
|
||||
legal_hold_reason: Option<&str>,
|
||||
) -> ApiResult<ReportLegalHoldResponse> {
|
||||
let body = serde_json::json!({
|
||||
"legal_hold_until": legal_hold_until,
|
||||
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
|
||||
});
|
||||
self.post_with_reason(
|
||||
&format!(
|
||||
"/admin/reports/{}/legal-hold",
|
||||
urlencoding::encode(report_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
@@ -98,11 +105,16 @@ impl AdminApiClient {
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reason", params.reason.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_webhook_id",
|
||||
params.reported_webhook_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
@@ -127,6 +139,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn list_report_reasons(&self) -> ApiResult<ReportReasonListResponse> {
|
||||
self.get("/admin/report-reasons", None).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
&self,
|
||||
reporter_id: &str,
|
||||
|
||||
@@ -98,6 +98,38 @@ pub struct ReportEntry {
|
||||
pub reported_user_global_name: Option<String>,
|
||||
pub reported_user_discriminator: Option<String>,
|
||||
pub reported_user_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_type: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_application_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_channel_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_avatar_hash: Option<String>,
|
||||
pub reported_guild_id: Option<String>,
|
||||
pub reported_guild_name: Option<String>,
|
||||
pub reported_guild_icon_hash: Option<String>,
|
||||
@@ -121,6 +153,148 @@ pub struct ReportEntry {
|
||||
pub public_comment: Option<String>,
|
||||
pub mutual_dm_channel_id: Option<String>,
|
||||
pub message_context: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_highest_priority: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub flow: Option<ReportFlowAnswersEntry>,
|
||||
#[serde(default)]
|
||||
pub reporter_good_faith_confirmed: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_user_bot: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_profile_snapshot: Option<ReportProfileSnapshot>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshot {
|
||||
#[serde(default)]
|
||||
pub captured_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user: Option<ReportProfileSnapshotUser>,
|
||||
#[serde(default)]
|
||||
pub member: Option<ReportProfileSnapshotMember>,
|
||||
#[serde(default)]
|
||||
pub guild: Option<ReportProfileSnapshotGuild>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotUser {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotMember {
|
||||
pub guild_id: String,
|
||||
#[serde(default)]
|
||||
pub nick: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub joined_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotGuild {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vanity_url_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub splash: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotAsset {
|
||||
pub hash: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportLegalHoldResponse {
|
||||
pub report_id: String,
|
||||
pub legal_hold_until: Option<String>,
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswersEntry {
|
||||
pub revision_hash: String,
|
||||
pub surface: String,
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub steps: Vec<ReportFlowAnswerStepEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerStepEntry {
|
||||
pub screen_id: String,
|
||||
pub screen_title: String,
|
||||
#[serde(default)]
|
||||
pub option_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub option_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ReportFlowAnswerItemEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerItemEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonEntry {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub highest_priority: bool,
|
||||
#[serde(default)]
|
||||
pub legacy_category_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_guild: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonListResponse {
|
||||
pub reasons: Vec<ReportReasonEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -131,11 +305,6 @@ pub struct SearchReportsResponse {
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ListReportsResponse {
|
||||
pub reports: Vec<ReportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResolveReportResponse {
|
||||
pub report_id: String,
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::components::message_list::MessageActions,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
@@ -321,7 +322,11 @@ pub(crate) async fn messages_browse_fragment(
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let can_delete = acl::has_permission(admin_acls, acl::MESSAGE_DELETE);
|
||||
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 client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client
|
||||
.browse_channel(
|
||||
@@ -334,7 +339,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, can_delete, None,
|
||||
config, &resp.data, actions, None,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ 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());
|
||||
@@ -137,6 +138,7 @@ 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(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
client::{AdminApiClient, ApiError, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
types::ReportEntry,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
@@ -14,7 +16,7 @@ use crate::{
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
utils::forms::clean_string,
|
||||
utils::{forms::clean_string, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
@@ -35,8 +37,10 @@ struct ReportsQuery {
|
||||
#[serde(rename = "type")]
|
||||
report_type: Option<String>,
|
||||
category: Option<String>,
|
||||
reason: Option<String>,
|
||||
reporter_id: Option<String>,
|
||||
reported_user_id: Option<String>,
|
||||
reported_webhook_id: Option<String>,
|
||||
reported_guild_id: Option<String>,
|
||||
reported_channel_id: Option<String>,
|
||||
guild_context_id: Option<String>,
|
||||
@@ -46,6 +50,18 @@ struct ReportsQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegalHoldForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveForm {
|
||||
#[serde(default)]
|
||||
@@ -67,6 +83,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/resolve",
|
||||
axum::routing::post(report_resolve),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
)
|
||||
}
|
||||
|
||||
async fn reports_list(
|
||||
@@ -95,25 +115,30 @@ async fn reports_list(
|
||||
.report_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
category: query.category.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
.await
|
||||
.log_error("search reports");
|
||||
let reason = query.reason.as_deref().and_then(clean_string);
|
||||
let params = SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (reports, reasons) = tokio::join!(
|
||||
client.search_reports(¶ms),
|
||||
state.report_reasons(&client)
|
||||
);
|
||||
let reports = reports.log_error("search reports");
|
||||
|
||||
let markup = templates::pages::reports_list::reports_list_page(
|
||||
config,
|
||||
@@ -124,14 +149,17 @@ async fn reports_list(
|
||||
status: query.status.as_deref(),
|
||||
report_type: query.report_type.as_deref(),
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort: query.sort.as_deref().unwrap_or("reportedAt_desc"),
|
||||
},
|
||||
reasons.as_deref(),
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
@@ -167,10 +195,12 @@ async fn report_detail(
|
||||
.log_error("load report detail");
|
||||
match report {
|
||||
Some(report) => {
|
||||
let live = load_live_profile(&client, &auth.0, &report).await;
|
||||
let markup = templates::pages::report_detail::report_detail_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&report,
|
||||
&live,
|
||||
&csrf_token,
|
||||
is_detail_fragment,
|
||||
);
|
||||
@@ -183,6 +213,49 @@ async fn report_detail(
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_live_profile(
|
||||
client: &AdminApiClient,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
) -> templates::pages::report_detail::LiveProfile {
|
||||
let can = |permission: &str| {
|
||||
auth.admin_user
|
||||
.as_ref()
|
||||
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
|
||||
};
|
||||
let snapshot = report.reported_profile_snapshot.as_ref();
|
||||
let user_id = snapshot
|
||||
.and_then(|snapshot| snapshot.user.as_ref())
|
||||
.map(|user| user.id.as_str())
|
||||
.filter(|_| can(acl::USER_LOOKUP));
|
||||
let guild_id = snapshot
|
||||
.and_then(|snapshot| snapshot.guild.as_ref())
|
||||
.map(|guild| guild.id.as_str())
|
||||
.filter(|_| can(acl::GUILD_LOOKUP));
|
||||
let (user, guild) = tokio::join!(
|
||||
async {
|
||||
match user_id {
|
||||
Some(id) => client
|
||||
.get_user_by_id(id)
|
||||
.await
|
||||
.log_error("load live reported user"),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
async {
|
||||
match guild_id {
|
||||
Some(id) => client
|
||||
.lookup_guild(id)
|
||||
.await
|
||||
.log_error("load live reported guild")
|
||||
.flatten(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
);
|
||||
templates::pages::report_detail::LiveProfile { user, guild }
|
||||
}
|
||||
|
||||
async fn report_fragment(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -261,6 +334,108 @@ async fn report_resolve(
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_legal_hold(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let form: LegalHoldForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report legal hold form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let clearing = form.clear.is_some();
|
||||
let until = if clearing {
|
||||
None
|
||||
} else {
|
||||
match form.legal_hold_until.as_deref().and_then(legal_hold_date) {
|
||||
Some(date) if date < time::OffsetDateTime::now_utc().date() => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_IN_PAST),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
Some(date) => Some(legal_hold_end_of_day(date)),
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Choose the date the hold ends"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let reason = clean_string(form.legal_hold_reason.as_deref().unwrap_or(""));
|
||||
if until.is_some() && reason.is_none() {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_NEEDS_REASON),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client
|
||||
.set_report_legal_hold(&report_id, until.as_deref(), reason.as_deref())
|
||||
.await;
|
||||
let flash = match result {
|
||||
Ok(response) => match response.legal_hold_until {
|
||||
Some(until) => FlashData::success(format!(
|
||||
"Legal hold placed until {}",
|
||||
format_admin_timestamp(&until)
|
||||
)),
|
||||
None => FlashData::success("Legal hold cleared"),
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: set report legal hold");
|
||||
match error {
|
||||
ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
} => FlashData::error(legal_hold_refusal(&message)),
|
||||
_ => FlashData::error("Failed to update the legal hold"),
|
||||
}
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future";
|
||||
const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold";
|
||||
|
||||
fn legal_hold_date(date: &str) -> Option<time::Date> {
|
||||
let format = time::format_description::parse_borrowed::<2>("[year]-[month]-[day]").ok()?;
|
||||
time::Date::parse(date.trim(), &format).ok()
|
||||
}
|
||||
|
||||
fn legal_hold_end_of_day(date: time::Date) -> String {
|
||||
format!("{date}T23:59:59.999Z")
|
||||
}
|
||||
|
||||
fn legal_hold_refusal(body: &str) -> &'static str {
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
let reason_refused = body["errors"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|error| error["path"] == "legal_hold_reason");
|
||||
if reason_refused {
|
||||
LEGAL_HOLD_NEEDS_REASON
|
||||
} else {
|
||||
LEGAL_HOLD_IN_PAST
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
|
||||
match sort.unwrap_or("reportedAt_desc") {
|
||||
"reportedAt_asc" => ("reportedAt", "asc"),
|
||||
@@ -271,3 +446,51 @@ fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
|
||||
_ => ("reportedAt", "desc"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legal_hold_dates_end_at_the_end_of_the_day_in_utc() {
|
||||
assert_eq!(
|
||||
legal_hold_date("2027-01-31")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-01-31T23:59:59.999Z")
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_date(" 2027-02-01 ")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-02-01T23:59:59.999Z")
|
||||
);
|
||||
for invalid in ["", "2027-02-30", "31/01/2027", "2027-1-31", "tomorrow"] {
|
||||
assert_eq!(legal_hold_date(invalid), None, "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_hold_names_the_field_the_api_refused() {
|
||||
let refusal = |path: &str| {
|
||||
serde_json::json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input Validation Error",
|
||||
"errors": [{"path": path, "message": "refused"}]
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_until")),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_reason")),
|
||||
"Give a reason for the hold"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal("not json"),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding, ReportReasonEntry},
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ struct AppStateInner {
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
|
||||
report_reasons: Mutex<Option<Arc<[ReportReasonEntry]>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -40,6 +41,7 @@ impl AppState {
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
account_identity: Mutex::new(None),
|
||||
report_reasons: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -126,6 +128,35 @@ impl AppState {
|
||||
Some((Instant::now() + ttl, settings));
|
||||
settings
|
||||
}
|
||||
|
||||
pub async fn report_reasons(
|
||||
&self,
|
||||
client: &AdminApiClient,
|
||||
) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
if let Some(reasons) = self.cached_report_reasons() {
|
||||
return Some(reasons);
|
||||
}
|
||||
let reasons: Arc<[ReportReasonEntry]> = client
|
||||
.list_report_reasons()
|
||||
.await
|
||||
.log_error("load report reasons")?
|
||||
.reasons
|
||||
.into();
|
||||
*self
|
||||
.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reasons.clone());
|
||||
Some(reasons)
|
||||
}
|
||||
|
||||
fn cached_report_reasons(&self) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
self.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -17,6 +17,14 @@ pub struct Attachment {
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct MissingAttachment {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub content_type: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
@@ -27,12 +35,15 @@ pub struct Message {
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub webhook_id: Option<String>,
|
||||
pub author_bot: Option<bool>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub missing_attachments: Vec<MissingAttachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
@@ -48,6 +59,12 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
let missing_attachments = value["missing_attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(missing_attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -61,6 +78,8 @@ fn message_from_value(value: &Value) -> Message {
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
webhook_id: value_id(&value["webhook_id"]),
|
||||
author_bot: value["author_bot"].as_bool(),
|
||||
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||
channel_content_warning_level: value["channel_content_warning_level"]
|
||||
@@ -71,6 +90,16 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_attachment_from_value(value: &Value) -> MissingAttachment {
|
||||
MissingAttachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
size: value["size"].as_u64(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +186,8 @@ mod tests {
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
@@ -175,10 +206,69 @@ mod tests {
|
||||
ncmec_report_id: Some("report-id".into()),
|
||||
ncmec_failure_reason: Some("previous failure".into()),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
@@ -187,6 +277,7 @@ mod tests {
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.webhook_id, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::icons::paperclip_icon;
|
||||
use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
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()
|
||||
@@ -44,7 +53,7 @@ fn ncmec_badge(att: &Attachment) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
fn render_image_attachments(msg: &Message, actions: MessageActions<'_>) -> Markup {
|
||||
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||
if images.peek().is_none() {
|
||||
return html! {};
|
||||
@@ -72,38 +81,43 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
@if include_delete {
|
||||
@if actions.delete || actions.ncmec {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
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" }
|
||||
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(""))
|
||||
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"
|
||||
@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +154,34 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
||||
}
|
||||
}
|
||||
|
||||
fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
if msg.missing_attachments.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
div class="mt-1.5 space-y-1" {
|
||||
@for att in &msg.missing_attachments {
|
||||
div class="flex flex-wrap items-center gap-2 text-amber-800 text-xs"
|
||||
data-missing-attachment=(att.id) {
|
||||
(paperclip_icon("text-amber-500"))
|
||||
span class="break-words" {
|
||||
@if att.filename.is_empty() {
|
||||
"An attachment was not preserved in the report snapshot"
|
||||
} @else {
|
||||
(att.filename) " was not preserved in the report snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_row(
|
||||
base_path: &str,
|
||||
avatar_url: &str,
|
||||
msg: &Message,
|
||||
include_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
is_highlighted: bool,
|
||||
is_grouped: bool,
|
||||
) -> Markup {
|
||||
@@ -176,10 +213,14 @@ fn message_row(
|
||||
text-sm leading-snug" { (msg.content) }
|
||||
}
|
||||
@if !msg.attachments.is_empty() {
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_image_attachments(msg, actions))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@if actions.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 \
|
||||
@@ -208,20 +249,39 @@ fn message_row(
|
||||
style="display:grid;grid-template-columns:16px 40px 16px minmax(0,1fr);"
|
||||
data-message-id=(msg.id) data-message-row="" {
|
||||
div style="grid-row:1;grid-column:1;" {}
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span title=(webhook_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
}
|
||||
div style="grid-row:1;grid-column:3;" {}
|
||||
div class="min-w-0" style="grid-column:4;" {
|
||||
div class="flex items-baseline gap-2" {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span class="font-medium text-neutral-900 text-sm"
|
||||
title=(webhook_id) data-message-webhook=(webhook_id) { (msg.author_username) }
|
||||
span class="self-center" { (badge("Webhook", BadgeVariant::Info)) }
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if msg.author_bot == Some(true) {
|
||||
span class="self-center" data-message-author-bot=(msg.author_id) {
|
||||
(badge("Bot", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
span class="text-neutral-400 text-xs" {
|
||||
" \u{2014} " (msg.timestamp)
|
||||
" \u{2014} " (format_admin_timestamp(&msg.timestamp))
|
||||
}
|
||||
(channel_nsfw_state_badge(
|
||||
msg.channel_nsfw.unwrap_or(false),
|
||||
@@ -236,14 +296,15 @@ fn message_row(
|
||||
text-sm leading-snug" { (msg.content) }
|
||||
}
|
||||
@if !msg.attachments.is_empty() {
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_image_attachments(msg, actions))
|
||||
(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 include_delete && !has_images {
|
||||
@if actions.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 \
|
||||
@@ -261,20 +322,22 @@ pub fn message_list(
|
||||
config: &AdminConfig,
|
||||
base_path: &str,
|
||||
messages: &[Message],
|
||||
include_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@for (i, msg) in messages.iter().enumerate() {
|
||||
@let is_grouped = i > 0 && messages[i - 1].author_id == msg.author_id;
|
||||
@let is_grouped = i > 0
|
||||
&& messages[i - 1].author_id == msg.author_id
|
||||
&& messages[i - 1].author_username == msg.author_username;
|
||||
@let is_highlighted = highlight_message_id == Some(msg.id.as_str());
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
);
|
||||
(message_row(
|
||||
base_path, &avatar_url, msg,
|
||||
include_delete, is_highlighted, is_grouped,
|
||||
actions, is_highlighted, is_grouped,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -330,6 +393,7 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
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;
|
||||
|
||||
@@ -17,6 +17,8 @@ pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod stack;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
("harassment", "Harassment or bullying"),
|
||||
("hate_speech", "Hate speech"),
|
||||
("spam", "Spam"),
|
||||
("illegal_activity", "Illegal activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child safety concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or graphic content"),
|
||||
("nsfw_violation", "NSFW policy violation"),
|
||||
("doxxing", "Sharing personal information"),
|
||||
("self_harm", "Self-harm or suicide"),
|
||||
("malicious_links", "Malicious links"),
|
||||
("spam_account", "Spam account"),
|
||||
("underage_user", "Underage user"),
|
||||
("inappropriate_profile", "Inappropriate profile"),
|
||||
("raid_coordination", "Raid coordination"),
|
||||
("malware_distribution", "Malware distribution"),
|
||||
("extremist_community", "Extremist community"),
|
||||
];
|
||||
|
||||
pub fn report_category_label(category: &str) -> &str {
|
||||
REPORT_CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(key, label)| (*key == category).then_some(*label))
|
||||
.unwrap_or(category)
|
||||
}
|
||||
|
||||
pub fn reason_repeats_category(category: &str, reason_label: &str) -> bool {
|
||||
report_category_label(category).trim().to_lowercase() == reason_label.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn report_category_options(selected: &str) -> Vec<(&str, &str)> {
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(REPORT_CATEGORIES.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
pub fn report_category(category: &str) -> Markup {
|
||||
html! {
|
||||
span data-report-category=(category) title=(category) {
|
||||
(report_category_label(category))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::media::user_avatar_url;
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
|
||||
pub struct ReportedWebhook<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: String,
|
||||
pub configured_name: Option<&'a str>,
|
||||
pub configured_avatar_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub reports_href: String,
|
||||
pub kind: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub channel_id: Option<&'a str>,
|
||||
pub guild_id: Option<&'a str>,
|
||||
pub application: Option<WebhookApplication<'a>>,
|
||||
pub record_deleted: bool,
|
||||
pub creator: Option<WebhookCreator<'a>>,
|
||||
}
|
||||
|
||||
pub struct WebhookApplication<'a> {
|
||||
pub id: &'a str,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
pub struct WebhookCreator<'a> {
|
||||
pub id: &'a str,
|
||||
pub label: String,
|
||||
pub avatar_url: String,
|
||||
pub href: String,
|
||||
pub account_deleted: bool,
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
pub fn webhook_type_label(webhook_type: i32) -> String {
|
||||
match webhook_type {
|
||||
1 => "Incoming".to_owned(),
|
||||
2 => "Channel Follower".to_owned(),
|
||||
other => format!("Type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn reported_webhook<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<ReportedWebhook<'a>> {
|
||||
let id = report.reported_webhook_id.as_deref()?;
|
||||
let name = non_blank(report.reported_webhook_name.as_deref())
|
||||
.map_or_else(|| format!("Webhook {id}"), str::to_owned);
|
||||
let configured_name = non_blank(report.reported_webhook_default_name.as_deref())
|
||||
.filter(|configured| *configured != name);
|
||||
let channel_id = non_blank(report.reported_webhook_channel_id.as_deref());
|
||||
let guild_id = non_blank(report.reported_webhook_guild_id.as_deref());
|
||||
let record_present = report.reported_webhook_type.is_some()
|
||||
|| channel_id.is_some()
|
||||
|| guild_id.is_some()
|
||||
|| non_blank(report.reported_webhook_default_name.as_deref()).is_some();
|
||||
let configured_avatar_hash = non_blank(report.reported_webhook_default_avatar_hash.as_deref());
|
||||
let configured_avatar_url = (record_present
|
||||
&& configured_avatar_hash != non_blank(report.reported_webhook_avatar_hash.as_deref()))
|
||||
.then(|| user_avatar_url(config, id, configured_avatar_hash, 80, true));
|
||||
let created_at = non_blank(report.reported_webhook_created_at.as_deref());
|
||||
Some(ReportedWebhook {
|
||||
id,
|
||||
name,
|
||||
configured_name,
|
||||
configured_avatar_url,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
reports_href: format!(
|
||||
"{}/reports?reported_webhook_id={}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
kind: report.reported_webhook_type.map(webhook_type_label),
|
||||
created_at: created_at.map(format_admin_timestamp),
|
||||
channel_id,
|
||||
guild_id,
|
||||
application: non_blank(report.reported_webhook_application_id.as_deref()).map(|id| {
|
||||
WebhookApplication {
|
||||
id,
|
||||
href: format!(
|
||||
"{}/applications/{}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
}
|
||||
}),
|
||||
record_deleted: created_at.is_some() && !record_present,
|
||||
creator: webhook_creator(config, report),
|
||||
})
|
||||
}
|
||||
|
||||
fn webhook_creator<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<WebhookCreator<'a>> {
|
||||
let id = report.reported_webhook_creator_id.as_deref()?;
|
||||
let username = non_blank(report.reported_webhook_creator_username.as_deref());
|
||||
let bot = report.reported_webhook_application_id.as_deref() == Some(id);
|
||||
let tag = match username {
|
||||
Some(username) => Some(user_tag(
|
||||
username,
|
||||
report
|
||||
.reported_webhook_creator_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000"),
|
||||
bot,
|
||||
)),
|
||||
None => non_blank(report.reported_webhook_creator_tag.as_deref()).map(str::to_owned),
|
||||
};
|
||||
let label = match (
|
||||
non_blank(report.reported_webhook_creator_global_name.as_deref()),
|
||||
tag,
|
||||
) {
|
||||
(Some(display), Some(tag)) => format!("{display} ({tag})"),
|
||||
(_, Some(tag)) => tag,
|
||||
(Some(display), None) => display.to_owned(),
|
||||
(None, None) => format!("User {id}"),
|
||||
};
|
||||
Some(WebhookCreator {
|
||||
id,
|
||||
label,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_creator_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
href: format!("{}/users/{}", config.base_path, urlencoding::encode(id)),
|
||||
account_deleted: username.is_none()
|
||||
&& non_blank(report.reported_webhook_creator_tag.as_deref()).is_none()
|
||||
&& non_blank(report.reported_webhook_creator_global_name.as_deref()).is_none(),
|
||||
bot,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn webhook_identity(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
span class="inline-flex min-w-0 items-center gap-2" data-report-webhook=(webhook.id) {
|
||||
img src=(webhook.avatar_url) alt=(format!("{}'s avatar", webhook.name))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" {
|
||||
span class="font-medium text-neutral-900 text-sm break-words" { (webhook.name) }
|
||||
(badge("Webhook", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_link(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="inline-flex min-w-0 items-center gap-2 text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
img src=(creator.avatar_url) alt=(format!("{}'s avatar", creator.label))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="break-words" { (creator.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_badges(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
@if creator.bot {
|
||||
span class="inline-flex" data-report-webhook-creator-bot { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
@if creator.account_deleted {
|
||||
span class="inline-flex" data-report-webhook-creator-deleted { (badge("Account Deleted", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,10 +489,11 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
element = element.firstElementChild;
|
||||
depth += 1;
|
||||
}
|
||||
var level = status >= 400 ? 'error' : 'success';
|
||||
var level = 'success';
|
||||
if (className.indexOf('red-') >= 0) level = 'error';
|
||||
if (className.indexOf('green-') >= 0) level = 'success';
|
||||
if (className.indexOf('blue-') >= 0) level = 'info';
|
||||
if (status >= 400) level = 'error';
|
||||
return {
|
||||
level: level,
|
||||
message: text || (level === 'error' ? 'Action failed.' : 'Done.')
|
||||
@@ -514,6 +515,20 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAfterSuccess(detail, level) {
|
||||
if (level !== 'success') return;
|
||||
var elt = (detail && detail.requestConfig && detail.requestConfig.elt) || mutationElement(detail);
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
var selector = form ? form.getAttribute('data-admin-refresh-on-success') : '';
|
||||
if (!selector || !document.querySelector(selector)) return;
|
||||
if (!window.htmx || typeof window.htmx.ajax !== 'function') return;
|
||||
window.htmx.ajax('GET', window.location.pathname + window.location.search, {
|
||||
target: selector,
|
||||
swap: 'outerHTML',
|
||||
select: selector
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('showFlash', function (evt) {
|
||||
var d = evt.detail || {};
|
||||
showToast(d.level || 'info', d.message || '');
|
||||
@@ -533,11 +548,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
var xhr = event.detail.xhr;
|
||||
var parsed = isLoginResponse(xhr)
|
||||
? {level: 'error', message: 'Session expired. Sign in again.'}
|
||||
: parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
: parseAdminToastHeader(xhr) || parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
event.detail.shouldSwap = false;
|
||||
event.detail.isError = false;
|
||||
markToastHandled(event.detail);
|
||||
showToast(parsed.level, parsed.message);
|
||||
refreshAfterSuccess(event.detail, parsed.level);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function (event) {
|
||||
@@ -548,6 +564,7 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
if (serverToast) {
|
||||
showToast(serverToast.level, serverToast.message);
|
||||
markToastHandled(event.detail);
|
||||
refreshAfterSuccess(event.detail, serverToast.level);
|
||||
return;
|
||||
}
|
||||
var failed = !event.detail.successful || (xhr && xhr.status >= 400) || isLoginResponse(xhr);
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::user_tag::user_tag,
|
||||
templates::{
|
||||
components::{page_container::card_with_header, table::data_table},
|
||||
pages::user_detail_tabs::reports::{
|
||||
format_status, reported_at_cell, reporter_label, type_and_category_cell,
|
||||
},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -40,8 +44,7 @@ pub fn reports_tab(
|
||||
}))
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"],
|
||||
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
|
||||
html! {
|
||||
@for report in reports {
|
||||
(report_row(base, report))
|
||||
@@ -74,75 +77,19 @@ pub fn reports_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
let reporter_display = format_reporter(report);
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
|
||||
@if let Some(ref rid) = report.reporter_id {
|
||||
a href={(base) "/users/" (rid)}
|
||||
class="hover:underline" {
|
||||
(reporter_display)
|
||||
(reporter_label(report))
|
||||
}
|
||||
} @else {
|
||||
span { (reporter_display) }
|
||||
span { (reporter_label(report)) }
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
@@ -159,3 +106,46 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert_eq!(
|
||||
markup.matches("whitespace-nowrap px-4").count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 1, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub fn guilds_list_page(
|
||||
} @else if let Some(count) = result_state.total {
|
||||
(page_header_with_actions("Guilds", None, html! {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
"Found " (count) " results (showing "
|
||||
"Found " (count) (if count == 1 { " result" } else { " results" }) " (showing "
|
||||
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
|
||||
")"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
submit_button,
|
||||
},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::{MessageActions, message_deletion_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -193,6 +193,7 @@ 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>,
|
||||
@@ -207,6 +208,11 @@ 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))
|
||||
|
||||
@@ -217,19 +223,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, params.can_delete))
|
||||
(search_result_card(config, result, channel_id, query, actions))
|
||||
}
|
||||
@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, params.can_delete,
|
||||
config, result, channel_id, actions,
|
||||
params.csrf_token, params.context_limit,
|
||||
))
|
||||
}
|
||||
@if let Some(result) = params.lookup_result {
|
||||
(lookup_result_card(config, result, params.can_delete, params.context_limit))
|
||||
(lookup_result_card(config, result, actions, params.context_limit))
|
||||
}
|
||||
@if params.can_lookup {
|
||||
(browse_channel_form(config, params.csrf_token, params.prefill_channel_id))
|
||||
@@ -260,7 +266,7 @@ pub fn messages_page(
|
||||
pub fn browse_messages_fragment(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -280,7 +286,7 @@ pub fn browse_messages_fragment(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions,
|
||||
highlight_message_id,
|
||||
))
|
||||
}
|
||||
@@ -291,7 +297,7 @@ fn browse_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
@@ -328,7 +334,7 @@ fn browse_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more,
|
||||
has_newer: false,
|
||||
focus_message_id: None,
|
||||
@@ -343,7 +349,7 @@ fn search_result_card(
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
@@ -369,7 +375,7 @@ fn search_result_card(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions,
|
||||
None,
|
||||
))
|
||||
}
|
||||
@@ -379,7 +385,7 @@ fn search_result_card(
|
||||
fn lookup_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -401,7 +407,7 @@ fn lookup_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more: true,
|
||||
has_newer: true,
|
||||
focus_message_id: message_id,
|
||||
@@ -415,7 +421,7 @@ struct MessageScrollPane<'a> {
|
||||
config: &'a AdminConfig,
|
||||
channel_id: &'a str,
|
||||
messages: &'a [Message],
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'a>,
|
||||
has_more: bool,
|
||||
has_newer: bool,
|
||||
focus_message_id: Option<&'a str>,
|
||||
@@ -454,7 +460,7 @@ fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
|
||||
pane.config,
|
||||
&pane.config.base_path,
|
||||
pane.messages,
|
||||
pane.show_delete,
|
||||
pane.actions,
|
||||
pane.focus_message_id,
|
||||
))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, SearchReportsResponse},
|
||||
api::types::{ReportEntry, ReportReasonEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
@@ -11,11 +11,16 @@ use crate::{
|
||||
form::{secondary_button_link, select_input, submit_button, text_input},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::page_header_with_actions,
|
||||
table::{data_table, empty_state},
|
||||
report_category::{reason_repeats_category, report_category, report_category_options},
|
||||
report_webhook::{
|
||||
ReportedWebhook, reported_webhook, webhook_creator_badges, webhook_identity,
|
||||
},
|
||||
table::{empty_state, table, table_body, table_container, table_head},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::user_detail_tabs::reports::tagged_user,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
@@ -24,8 +29,10 @@ pub struct ReportFilters<'a> {
|
||||
pub status: Option<&'a str>,
|
||||
pub report_type: Option<&'a str>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
@@ -38,6 +45,7 @@ pub fn reports_list_page(
|
||||
auth: &AuthContext,
|
||||
result: Option<&SearchReportsResponse>,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
page: u32,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
@@ -48,7 +56,7 @@ pub fn reports_list_page(
|
||||
None,
|
||||
report_count_summary(result),
|
||||
))
|
||||
(filters_card(config, filters, limit))
|
||||
(filters_card(config, filters, reasons, limit))
|
||||
@if let Some(result) = result {
|
||||
@if result.reports.is_empty() {
|
||||
(empty_state("No reports found."))
|
||||
@@ -72,14 +80,25 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
|
||||
html! {
|
||||
@if let Some(result) = result {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Found " (result.total) " results (showing " (result.reports.len()) ")"
|
||||
"Found " (result.total) (if result.total == 1 { " result" } else { " results" })
|
||||
" (showing " (result.reports.len()) ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -> Markup {
|
||||
fn filters_card(
|
||||
config: &AdminConfig,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
let limit_value = limit.to_string();
|
||||
let select_grid_class = if reasons.is_some() {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
} else {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
|
||||
};
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-6 transition-all" {
|
||||
form method="get" {
|
||||
@@ -90,7 +109,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
filters.query.unwrap_or(""),
|
||||
"Search by ID, reporter, category, or description...",
|
||||
))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5" {
|
||||
div class=(select_grid_class) {
|
||||
(select_input("status", "Status", &[
|
||||
("", "All"),
|
||||
("0", "Pending"),
|
||||
@@ -102,7 +121,12 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
("1", "User"),
|
||||
("2", "Guild"),
|
||||
], filters.report_type.unwrap_or("")))
|
||||
(select_input("category", "Category", report_category_options(), filters.category.unwrap_or("")))
|
||||
(select_input("category", "Category", &report_category_options(filters.category.unwrap_or("")), filters.category.unwrap_or("")))
|
||||
@if let Some(reasons) = reasons {
|
||||
div class="sm:col-span-2 lg:col-span-2" {
|
||||
(reason_select(reasons, filters.reason.unwrap_or("")))
|
||||
}
|
||||
}
|
||||
(select_input("sort", "Sort", &[
|
||||
("reportedAt_desc", "Reported (newest first)"),
|
||||
("reportedAt_asc", "Reported (oldest first)"),
|
||||
@@ -121,6 +145,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
|
||||
(text_input("reporter_id", "Reporter user ID", filters.reporter_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_user_id", "Reported user ID", filters.reported_user_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_webhook_id", "Reported webhook ID", filters.reported_webhook_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_guild_id", "Reported guild ID", filters.reported_guild_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_channel_id", "Reported channel ID", filters.reported_channel_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("guild_context_id", "Guild context ID", filters.guild_context_id.unwrap_or(""), "Snowflake"))
|
||||
@@ -136,28 +161,29 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
}
|
||||
}
|
||||
|
||||
fn report_category_options() -> &'static [(&'static str, &'static str)] {
|
||||
&[
|
||||
("", "All"),
|
||||
("harassment", "Harassment or Bullying"),
|
||||
("hate_speech", "Hate Speech"),
|
||||
("spam", "Spam or Scam"),
|
||||
("illegal_activity", "Illegal Activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child Safety Concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or Graphic Content"),
|
||||
("nsfw_violation", "NSFW Policy Violation"),
|
||||
("doxxing", "Sharing Personal Information"),
|
||||
("self_harm", "Self-Harm or Suicide"),
|
||||
("malicious_links", "Malicious Links"),
|
||||
("spam_account", "Spam Account"),
|
||||
("underage_user", "Underage User"),
|
||||
("inappropriate_profile", "Inappropriate Profile"),
|
||||
("raid_coordination", "Raid Coordination"),
|
||||
("malware_distribution", "Malware Distribution"),
|
||||
("extremist_community", "Extremist Community"),
|
||||
]
|
||||
fn reason_select(reasons: &[ReportReasonEntry], selected: &str) -> Markup {
|
||||
let labels = reasons
|
||||
.iter()
|
||||
.map(|reason| {
|
||||
if reason.highest_priority {
|
||||
format!("Priority: {}", reason.label)
|
||||
} else {
|
||||
reason.label.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(
|
||||
reasons
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|(reason, label)| (reason.key.as_str(), label.as_str())),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
select_input("reason", "Reason", &options, selected)
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
@@ -177,36 +203,21 @@ fn format_report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_category(category: Option<&str>) -> String {
|
||||
let Some(category) = category else {
|
||||
return "\u{2014}".to_owned();
|
||||
};
|
||||
report_category_options()
|
||||
.iter()
|
||||
.find_map(|(value, label)| (*value == category).then_some((*label).to_owned()))
|
||||
.unwrap_or_else(|| category.to_owned())
|
||||
}
|
||||
|
||||
fn reporter_label(report: &ReportEntry) -> String {
|
||||
fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(username) = &report.reporter_username {
|
||||
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(display) = report
|
||||
.reporter_global_name
|
||||
.as_ref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
{
|
||||
return format!("{display} ({tag})");
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(tag) = &report.reporter_tag {
|
||||
return tag.to_owned();
|
||||
html! {
|
||||
(report
|
||||
.reporter_tag
|
||||
.as_deref()
|
||||
.or(report.reporter_email.as_deref())
|
||||
.unwrap_or("Anonymous"))
|
||||
}
|
||||
if let Some(email) = &report.reporter_email {
|
||||
return email.to_owned();
|
||||
}
|
||||
"Anonymous".to_owned()
|
||||
}
|
||||
|
||||
fn reported_user_label(report: &ReportEntry) -> String {
|
||||
@@ -277,14 +288,30 @@ fn reported_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_name(report: &ReportEntry) -> Markup {
|
||||
match &report.reported_user_username {
|
||||
Some(username) => tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
),
|
||||
None => html! { (reported_user_label(report)) },
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let primary = reported_user_label(report);
|
||||
html! {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (primary) }
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (reported_user_name(report)) }
|
||||
}
|
||||
@if report.reported_user_bot == Some(true) {
|
||||
span class="inline-flex" data-report-user-bot=(id) { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (primary) }
|
||||
@@ -328,7 +355,7 @@ fn reported_guild_icon(
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
@@ -340,26 +367,28 @@ fn reported_guild_icon(
|
||||
|
||||
fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let user_label = reported_user_label(report);
|
||||
let channel_label = report
|
||||
.reported_channel_name
|
||||
.as_deref()
|
||||
.or(report.reported_channel_id.as_deref())
|
||||
.unwrap_or("Unknown channel");
|
||||
let channel_prefix =
|
||||
if report.reported_guild_id.is_none() && report.reported_channel_id.is_some() {
|
||||
"DM channel: "
|
||||
} else {
|
||||
"Channel: "
|
||||
};
|
||||
html! {
|
||||
div class="flex flex-col gap-1" {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &user_label))
|
||||
span class="font-medium text-sm" { (user_label) }
|
||||
}
|
||||
@if let Some(webhook) = reported_webhook(config, report) {
|
||||
(reported_webhook_author(&webhook))
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (user_label) }
|
||||
(reported_user_cell(config, report))
|
||||
}
|
||||
@if let Some(channel_id) = &report.reported_channel_id {
|
||||
a href=(message_lookup_href(base, channel_id, report.reported_message_id.as_deref()))
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Channel: " (channel_label)
|
||||
(channel_prefix) (channel_label)
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500 text-xs" { "Channel: " (channel_label) }
|
||||
@@ -371,6 +400,27 @@ fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col items-start gap-0.5" {
|
||||
(webhook_identity(webhook))
|
||||
a href=(webhook.reports_href) title="Reports about this webhook"
|
||||
class="font-mono text-blue-600 text-xs hover:underline" {
|
||||
"Webhook ID: " (webhook.id)
|
||||
}
|
||||
@if let Some(ref creator) = webhook.creator {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Webhook creator: " (creator.label)
|
||||
}
|
||||
@if creator.bot || creator.account_deleted {
|
||||
span class="inline-flex flex-wrap items-center gap-1" { (webhook_creator_badges(creator)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_avatar(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
@@ -385,7 +435,7 @@ fn reported_user_avatar(
|
||||
true,
|
||||
);
|
||||
html! {
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 rounded-full object-cover";
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 max-w-none rounded-full object-cover";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,31 +452,53 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
let rows = html! {
|
||||
@for report in reports {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-neutral-600 text-sm" {
|
||||
(report.reported_at)
|
||||
td class="whitespace-nowrap px-3 py-3 text-neutral-600 text-sm" {
|
||||
(format_admin_timestamp(&report.reported_at))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
@let (type_label, type_variant) = format_report_type(report.report_type);
|
||||
(badge(type_label, type_variant))
|
||||
span class="text-neutral-600 text-xs" {
|
||||
(format_category(report.category.as_deref()))
|
||||
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
@let repeats = report.category.as_deref().zip(reason_label).is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
@let priority = report.reason_highest_priority == Some(true);
|
||||
@let category_class = if repeats && priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-600 text-xs"
|
||||
};
|
||||
span class=(category_class) data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
@if let Some(category) = &report.category {
|
||||
(report_category(category))
|
||||
} @else {
|
||||
"\u{2014}"
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
@let reason_class = if priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-500 text-xs"
|
||||
};
|
||||
span class=(reason_class) data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
(reporter_cell(config, report))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
(reported_cell(config, report))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-3 py-3 text-sm" {
|
||||
@let (label, variant) = format_status(report.status);
|
||||
span data-status-pill=(report.report_id) {
|
||||
(badge(label, variant))
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
button type="button"
|
||||
data-drawer-open="report-peek"
|
||||
@@ -455,7 +527,18 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
};
|
||||
html! {
|
||||
div data-report-table="true" {
|
||||
(data_table(headers, rows))
|
||||
(table_container(table(html! {
|
||||
(table_head(html! {
|
||||
tr {
|
||||
@for header in headers {
|
||||
th class="whitespace-nowrap px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" {
|
||||
(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
(table_body(rows))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,8 +591,14 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
push_param(&mut params, "status", filters.status);
|
||||
push_param(&mut params, "type", filters.report_type);
|
||||
push_param(&mut params, "category", filters.category);
|
||||
push_param(&mut params, "reason", filters.reason);
|
||||
push_param(&mut params, "reporter_id", filters.reporter_id);
|
||||
push_param(&mut params, "reported_user_id", filters.reported_user_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
"reported_webhook_id",
|
||||
filters.reported_webhook_id,
|
||||
);
|
||||
push_param(&mut params, "reported_guild_id", filters.reported_guild_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
|
||||
@@ -6,9 +6,11 @@ use crate::{
|
||||
templates::components::{
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::card_with_header,
|
||||
report_category::{reason_repeats_category, report_category},
|
||||
report_webhook::{reported_webhook, webhook_identity},
|
||||
table::data_table,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -102,11 +104,9 @@ fn report_section(
|
||||
p class="text-sm text-neutral-500" { (empty_msg) }
|
||||
} @else {
|
||||
@let headers = if kind == "sent" {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reported Entity", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reported Entity", "Status"]
|
||||
} else {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reporter", "Status"]
|
||||
};
|
||||
(data_table(
|
||||
&headers,
|
||||
@@ -152,7 +152,7 @@ fn format_report_type(report_type: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
pub(crate) fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
@@ -160,36 +160,86 @@ fn format_status(status: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
pub(crate) fn tagged_user(
|
||||
global_name: Option<&str>,
|
||||
username: &str,
|
||||
discriminator: Option<&str>,
|
||||
) -> Markup {
|
||||
let tag = user_tag(username, discriminator.unwrap_or("0000"), false);
|
||||
html! {
|
||||
@if let Some(name) = global_name.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
(name) " "
|
||||
span class="whitespace-nowrap text-neutral-500 text-xs" { "(" (tag) ")" }
|
||||
} @else {
|
||||
span class="whitespace-nowrap" { (tag) }
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
html! {
|
||||
(report
|
||||
.reporter_email
|
||||
.as_deref()
|
||||
.or(report.reporter_id.as_deref())
|
||||
.unwrap_or("Unknown reporter"))
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
}
|
||||
|
||||
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
|
||||
html! {
|
||||
td class="px-3 py-3 text-sm text-neutral-900" {
|
||||
div { (format_admin_timestamp(&report.reported_at)) }
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
|
||||
let reason_label = report
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
.map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
let repeats = report
|
||||
.category
|
||||
.as_deref()
|
||||
.zip(reason_label)
|
||||
.is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
html! {
|
||||
td class="px-4 py-3 text-sm text-neutral-900" {
|
||||
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
|
||||
@if let Some(category) = &report.category {
|
||||
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
(report_category(category))
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry) -> Markup {
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
let webhook = if kind == "sent" {
|
||||
reported_webhook(config, report)
|
||||
} else {
|
||||
format_reporter(report)
|
||||
None
|
||||
};
|
||||
|
||||
let entity_href = if kind == "sent" {
|
||||
@@ -210,31 +260,28 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
.map(|id| format!("{base}/users/{id}"))
|
||||
};
|
||||
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
} else {
|
||||
reporter_label(report)
|
||||
};
|
||||
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
@if let Some(href) = entity_href {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
|
||||
@if let Some(webhook) = &webhook {
|
||||
a href=(webhook.reports_href) class="hover:underline" {
|
||||
(webhook_identity(webhook))
|
||||
}
|
||||
} @else if let Some(href) = entity_href {
|
||||
a href=(href)
|
||||
class="inline-flex items-center gap-2 hover:underline" {
|
||||
@if kind == "sent" {
|
||||
(reported_entity_icon(config, report, &entity_display))
|
||||
(reported_entity_icon(config, report))
|
||||
}
|
||||
span { (entity_display) }
|
||||
span class="min-w-0" { (entity_display) }
|
||||
}
|
||||
} @else {
|
||||
span { (entity_display) }
|
||||
@@ -243,48 +290,32 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_status(report.status))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-1.5 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reported_entity(report: &ReportEntry) -> String {
|
||||
fn format_reported_entity(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reported_user_username {
|
||||
let disc = report
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reported_user_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reported_user_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref id) = report.reported_user_id {
|
||||
return id.clone();
|
||||
html! {
|
||||
(report
|
||||
.reported_user_id
|
||||
.as_ref()
|
||||
.or(report.reported_guild_name.as_ref())
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.map_or("Unknown", String::as_str))
|
||||
}
|
||||
report
|
||||
.reported_guild_name
|
||||
.as_ref()
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_owned())
|
||||
}
|
||||
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str) -> Markup {
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
if let Some(ref user_id) = report.reported_user_id {
|
||||
return html! {
|
||||
img
|
||||
@@ -307,9 +338,161 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str)
|
||||
}
|
||||
return html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
(initials(report.reported_guild_name.as_deref().unwrap_or(guild_id)))
|
||||
}
|
||||
};
|
||||
}
|
||||
html! {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1700000000000000500",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_guild_id": "1700000000000000800",
|
||||
"reported_guild_name": "Harbor"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let config = test_config();
|
||||
let sent = report_row(&config, "/admin", "sent", &report).into_string();
|
||||
assert!(
|
||||
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains(r#"data-report-webhook="1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(sent.contains("Harbor Bulletin"), "{sent}");
|
||||
assert!(!sent.contains("/admin/guilds/"), "{sent}");
|
||||
let received = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert!(!received.contains("data-report-webhook"), "{received}");
|
||||
assert!(
|
||||
received.contains(r#"href="/admin/users/1500000000000000000""#),
|
||||
"{received}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_label_the_category_and_keep_the_key() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000005",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "inappropriate_profile",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
for kind in ["sent", "received"] {
|
||||
let markup = report_row(&config, "/admin", kind, &report).into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="inappropriate_profile""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
|
||||
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-reason="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(r#"href="/admin/reports/1800000000000000005""#)
|
||||
.count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(!markup.contains(">View<"), "{markup}");
|
||||
}
|
||||
let mut unknown = report.clone();
|
||||
unknown.category = Some("future_value".to_owned());
|
||||
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
|
||||
assert!(markup.contains(">future_value<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_skip_a_reason_that_repeats_the_category() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000007",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert_eq!(
|
||||
markup.matches("Harassment or bullying").count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup.matches(r#"data-report-reason="harassment""#).count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Message<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
}
|
||||
}
|
||||
@@ -752,6 +752,631 @@ fn deserialize_search_reports_response() {
|
||||
assert!(resp.reports[0].reported_guild_icon_hash.is_none());
|
||||
}
|
||||
|
||||
fn report_json(extra: serde_json::Value) -> serde_json::Value {
|
||||
let mut report = serde_json::json!({
|
||||
"report_id": "1556008115701286089",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter#0896",
|
||||
"reporter_username": "reporter",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": null,
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": "FR",
|
||||
"reported_at": "2026-10-03T18:30:33.848Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "child_safety",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target#6741",
|
||||
"reported_user_username": "target",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115697091784",
|
||||
"reported_channel_id": "1556008115692897479",
|
||||
"reported_channel_name": null,
|
||||
"reported_guild_invite_code": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null
|
||||
});
|
||||
for (key, value) in extra.as_object().expect("object").clone() {
|
||||
report[key] = value;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn v2_dsa_report_json() -> serde_json::Value {
|
||||
report_json(serde_json::json!({
|
||||
"reason": "csam",
|
||||
"reason_label": "Child sexual abuse material",
|
||||
"reason_highest_priority": true,
|
||||
"reporter_good_faith_confirmed": true,
|
||||
"flow": {
|
||||
"revision_hash": "b7667e8b32c98c40",
|
||||
"surface": "dsa",
|
||||
"locale": "fr",
|
||||
"steps": [
|
||||
{"screen_id": "root_message", "screen_title": "Report message", "option_id": "abuse", "option_label": "Abusive or harmful content", "items": []},
|
||||
{"screen_id": "abuse", "screen_title": "What does it involve?", "option_id": "sexual", "option_label": "Sexual content", "items": []},
|
||||
{"screen_id": "private_info", "screen_title": "What private information is shared?", "option_id": null, "option_label": null, "items": [{"id": "email", "label": "Email address"}, {"id": "phone", "label": "Phone number"}]},
|
||||
{"screen_id": "profile_intro", "screen_title": "Report profile", "option_id": null, "option_label": null, "items": []}
|
||||
]
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode_report_both_ways(json: serde_json::Value) -> types::ReportEntry {
|
||||
let generated: generated_types::ReportAdminResponseSchema =
|
||||
serde_json::from_value(json.clone()).expect("generated report type");
|
||||
let via_generated: types::ReportEntry =
|
||||
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
|
||||
.expect("hand-written report type from generated");
|
||||
let direct: types::ReportEntry =
|
||||
serde_json::from_value(json).expect("hand-written report type");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&via_generated).unwrap(),
|
||||
serde_json::to_value(&direct).unwrap()
|
||||
);
|
||||
direct
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_v2_report_with_reason_and_answers() {
|
||||
let report = decode_report_both_ways(v2_dsa_report_json());
|
||||
assert_eq!(report.reason.as_deref(), Some("csam"));
|
||||
assert_eq!(
|
||||
report.reason_label.as_deref(),
|
||||
Some("Child sexual abuse material")
|
||||
);
|
||||
assert_eq!(report.reason_highest_priority, Some(true));
|
||||
assert_eq!(report.reporter_good_faith_confirmed, Some(true));
|
||||
let flow = report.flow.expect("flow answers");
|
||||
assert_eq!(flow.revision_hash, "b7667e8b32c98c40");
|
||||
assert_eq!(flow.surface, "dsa");
|
||||
assert_eq!(flow.locale.as_deref(), Some("fr"));
|
||||
assert_eq!(flow.steps.len(), 4);
|
||||
assert_eq!(flow.steps[0].option_id.as_deref(), Some("abuse"));
|
||||
assert_eq!(flow.steps[2].option_id, None);
|
||||
assert_eq!(flow.steps[2].items[1].label, "Phone number");
|
||||
assert_eq!(flow.steps[3].option_label, None);
|
||||
assert!(flow.steps[3].items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_report_without_reason_fields() {
|
||||
let nulls = report_json(serde_json::json!({
|
||||
"reason": null,
|
||||
"reason_label": null,
|
||||
"reason_highest_priority": null,
|
||||
"flow": null,
|
||||
"reporter_good_faith_confirmed": null
|
||||
}));
|
||||
for json in [report_json(serde_json::json!({})), nulls] {
|
||||
let report = decode_report_both_ways(json);
|
||||
assert!(report.reason.is_none());
|
||||
assert!(report.reason_label.is_none());
|
||||
assert!(report.reason_highest_priority.is_none());
|
||||
assert!(report.flow.is_none());
|
||||
assert!(report.reporter_good_faith_confirmed.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_with_unknown_reason_and_ids() {
|
||||
let report = decode_report_both_ways(report_json(serde_json::json!({
|
||||
"reason": "future_reason",
|
||||
"reason_label": "future_reason",
|
||||
"reason_highest_priority": null,
|
||||
"flow": {
|
||||
"revision_hash": "0000000000000000",
|
||||
"surface": "future_surface",
|
||||
"locale": null,
|
||||
"steps": [
|
||||
{"screen_id": "future_screen", "screen_title": "future_screen", "option_id": "future_option", "option_label": "future_option", "items": [{"id": "future_item", "label": "future_item"}]}
|
||||
]
|
||||
}
|
||||
})));
|
||||
assert_eq!(report.reason.as_deref(), Some("future_reason"));
|
||||
assert_eq!(report.reason_label.as_deref(), Some("future_reason"));
|
||||
assert_eq!(report.reason_highest_priority, None);
|
||||
let flow = report.flow.expect("flow answers");
|
||||
assert_eq!(flow.surface, "future_surface");
|
||||
assert_eq!(flow.locale, None);
|
||||
assert_eq!(flow.steps[0].items[0].id, "future_item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_with_unknown_status_and_type() {
|
||||
let report = decode_report_both_ways(report_json(serde_json::json!({
|
||||
"status": 7,
|
||||
"report_type": 9
|
||||
})));
|
||||
assert_eq!(report.status, 7);
|
||||
assert_eq!(report.report_type, 9);
|
||||
let generated: generated_types::ReportAdminResponseSchema = serde_json::from_value(
|
||||
report_json(serde_json::json!({"status": 7, "report_type": 9})),
|
||||
)
|
||||
.expect("generated report type");
|
||||
assert_eq!(*generated.status, 7);
|
||||
assert_eq!(*generated.report_type, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_resolve_report_response_with_unknown_status() {
|
||||
let json = serde_json::json!({
|
||||
"report_id": "1556008115701286089",
|
||||
"status": 7,
|
||||
"resolved_at": "2026-10-03T18:40:00.000Z",
|
||||
"public_comment": null
|
||||
});
|
||||
let generated: generated_types::ResolveReportResponse =
|
||||
serde_json::from_value(json.clone()).expect("generated resolve response");
|
||||
assert_eq!(*generated.status, 7);
|
||||
let via_generated: types::ResolveReportResponse =
|
||||
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
|
||||
.expect("hand-written resolve response from generated");
|
||||
let direct: types::ResolveReportResponse =
|
||||
serde_json::from_value(json).expect("hand-written resolve response");
|
||||
assert_eq!(via_generated.status, 7);
|
||||
assert_eq!(direct.status, 7);
|
||||
assert_eq!(direct.report_id, "1556008115701286089");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_webhook_message_report() {
|
||||
let json = report_json(serde_json::json!({
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_webhook_id": "1556008115710000001",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": "a_1f2e3d",
|
||||
"reported_guild_id": "1556008115710000002",
|
||||
"reported_guild_name": "Harbor",
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556008115697091784",
|
||||
"channel_id": "1556008115692897479",
|
||||
"channel_nsfw": false,
|
||||
"guild_id": "1556008115710000002",
|
||||
"guild_nsfw_level": 0,
|
||||
"content": "Free stuff here",
|
||||
"timestamp": "2026-10-04T10:00:00.000Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556008115710000001",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": "a_1f2e3d",
|
||||
"webhook_id": "1556008115710000001",
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556008115697091780",
|
||||
"channel_id": "1556008115692897479",
|
||||
"channel_nsfw": false,
|
||||
"guild_id": "1556008115710000002",
|
||||
"guild_nsfw_level": 0,
|
||||
"content": "hello",
|
||||
"timestamp": "2026-10-04T09:59:00.000Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556008115625788599",
|
||||
"author_username": "target",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "6741",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
}
|
||||
]
|
||||
}));
|
||||
let mut json = json;
|
||||
let webhook_details = serde_json::json!({
|
||||
"reported_webhook_default_name": "Harbor Hook",
|
||||
"reported_webhook_default_avatar_hash": "b_4c5d6e",
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556008115692897479",
|
||||
"reported_webhook_guild_id": "1556008115710000002",
|
||||
"reported_webhook_created_at": "2026-10-04T09:00:00.000Z",
|
||||
"reported_webhook_creator_id": "1556008115625788600",
|
||||
"reported_webhook_creator_tag": "keeper#0042",
|
||||
"reported_webhook_creator_username": "keeper",
|
||||
"reported_webhook_creator_global_name": "Harbor Keeper",
|
||||
"reported_webhook_creator_discriminator": "0042",
|
||||
"reported_webhook_creator_avatar_hash": "c_7f8a9b",
|
||||
});
|
||||
for (key, value) in webhook_details.as_object().expect("object") {
|
||||
json[key] = value.clone();
|
||||
}
|
||||
let generated: generated_types::ReportAdminResponseSchema =
|
||||
serde_json::from_value(json.clone()).expect("generated report type");
|
||||
let via_generated: types::ReportEntry =
|
||||
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
|
||||
.expect("hand-written report type from generated");
|
||||
let direct: types::ReportEntry =
|
||||
serde_json::from_value(json).expect("hand-written report type");
|
||||
for report in [&via_generated, &direct] {
|
||||
assert!(report.reported_user_id.is_none());
|
||||
assert_eq!(
|
||||
report.reported_webhook_id.as_deref(),
|
||||
Some("1556008115710000001")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_name.as_deref(),
|
||||
Some("Harbor Bulletin")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_avatar_hash.as_deref(),
|
||||
Some("a_1f2e3d")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_default_name.as_deref(),
|
||||
Some("Harbor Hook")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_id.as_deref(),
|
||||
Some("1556008115625788600")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_tag.as_deref(),
|
||||
Some("keeper#0042")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_username.as_deref(),
|
||||
Some("keeper")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_global_name.as_deref(),
|
||||
Some("Harbor Keeper")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_discriminator.as_deref(),
|
||||
Some("0042")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_creator_avatar_hash.as_deref(),
|
||||
Some("c_7f8a9b")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_default_avatar_hash.as_deref(),
|
||||
Some("b_4c5d6e")
|
||||
);
|
||||
assert_eq!(report.reported_webhook_type, Some(1));
|
||||
assert!(report.reported_webhook_application_id.is_none());
|
||||
assert_eq!(
|
||||
report.reported_webhook_channel_id.as_deref(),
|
||||
Some("1556008115692897479")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_guild_id.as_deref(),
|
||||
Some("1556008115710000002")
|
||||
);
|
||||
assert_eq!(
|
||||
report.reported_webhook_created_at.as_deref(),
|
||||
Some("2026-10-04T09:00:00.000Z")
|
||||
);
|
||||
let context = report.message_context.as_ref().expect("message context");
|
||||
assert_eq!(context[0]["webhook_id"], "1556008115710000001");
|
||||
assert_eq!(context[0]["author_id"], "1556008115710000001");
|
||||
assert!(context[1]["webhook_id"].is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_without_webhook_fields() {
|
||||
let nulls = report_json(serde_json::json!({
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null
|
||||
}));
|
||||
for json in [report_json(serde_json::json!({})), nulls] {
|
||||
let report = decode_report_both_ways(json);
|
||||
assert_eq!(
|
||||
report.reported_user_id.as_deref(),
|
||||
Some("1556008115625788599")
|
||||
);
|
||||
assert!(report.reported_webhook_id.is_none());
|
||||
assert!(report.reported_webhook_name.is_none());
|
||||
assert!(report.reported_webhook_avatar_hash.is_none());
|
||||
assert!(report.reported_webhook_default_name.is_none());
|
||||
assert!(report.reported_webhook_creator_id.is_none());
|
||||
assert!(report.reported_webhook_creator_tag.is_none());
|
||||
assert!(report.reported_webhook_default_avatar_hash.is_none());
|
||||
assert!(report.reported_webhook_type.is_none());
|
||||
assert!(report.reported_webhook_application_id.is_none());
|
||||
assert!(report.reported_webhook_channel_id.is_none());
|
||||
assert!(report.reported_webhook_guild_id.is_none());
|
||||
assert!(report.reported_webhook_created_at.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_asset(hash: &str, url: Option<&str>) -> serde_json::Value {
|
||||
serde_json::json!({"hash": hash, "url": url})
|
||||
}
|
||||
|
||||
fn evidence_report_json() -> serde_json::Value {
|
||||
report_json(serde_json::json!({
|
||||
"reported_user_bot": true,
|
||||
"reported_profile_snapshot": {
|
||||
"captured_at": "2026-10-04T10:00:00.000Z",
|
||||
"user": {
|
||||
"id": "1556008115625788599",
|
||||
"username": "target",
|
||||
"discriminator": "6741",
|
||||
"global_name": "Jordan Target",
|
||||
"bio": "Original bio",
|
||||
"pronouns": null,
|
||||
"avatar": snapshot_asset("avatar1", Some("https://reports.example.test/a?sig=1")),
|
||||
"banner": snapshot_asset("banner1", None)
|
||||
},
|
||||
"member": {
|
||||
"guild_id": "1556008115710000002",
|
||||
"nick": "Harbor Nick",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"joined_at": "2026-10-01T08:00:00.000Z",
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"guild": null
|
||||
},
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556008115697091784",
|
||||
"channel_id": "1556008115692897479",
|
||||
"channel_nsfw": false,
|
||||
"guild_id": null,
|
||||
"guild_nsfw_level": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T10:00:00.000Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556008115625788599",
|
||||
"author_username": "target",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "6741",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": [{
|
||||
"id": "1556008115697091790",
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556008115697091780",
|
||||
"channel_id": "1556008115692897479",
|
||||
"channel_nsfw": false,
|
||||
"guild_id": null,
|
||||
"guild_nsfw_level": null,
|
||||
"content": "hello",
|
||||
"timestamp": "2026-10-04T09:59:00.000Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556008115617399989",
|
||||
"author_username": "reporter",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0896",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_with_bot_flags_profile_snapshot_and_missing_attachments() {
|
||||
let json = evidence_report_json();
|
||||
let generated: generated_types::ReportAdminResponseSchema =
|
||||
serde_json::from_value(json.clone()).expect("generated report type");
|
||||
let via_generated: types::ReportEntry =
|
||||
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
|
||||
.expect("hand-written report type from generated");
|
||||
let direct: types::ReportEntry =
|
||||
serde_json::from_value(json).expect("hand-written report type");
|
||||
for report in [&via_generated, &direct] {
|
||||
assert_eq!(report.reported_user_bot, Some(true));
|
||||
let snapshot = report
|
||||
.reported_profile_snapshot
|
||||
.as_ref()
|
||||
.expect("profile snapshot");
|
||||
assert_eq!(
|
||||
snapshot.captured_at.as_deref(),
|
||||
Some("2026-10-04T10:00:00.000Z")
|
||||
);
|
||||
let user = snapshot.user.as_ref().expect("user snapshot");
|
||||
assert_eq!(user.id, "1556008115625788599");
|
||||
assert_eq!(user.bio.as_deref(), Some("Original bio"));
|
||||
let avatar = user.avatar.as_ref().expect("avatar");
|
||||
assert_eq!(avatar.hash, "avatar1");
|
||||
assert_eq!(
|
||||
avatar.url.as_deref(),
|
||||
Some("https://reports.example.test/a?sig=1")
|
||||
);
|
||||
let banner = user.banner.as_ref().expect("banner");
|
||||
assert_eq!(banner.hash, "banner1");
|
||||
assert!(banner.url.is_none());
|
||||
let member = snapshot.member.as_ref().expect("member snapshot");
|
||||
assert_eq!(member.nick.as_deref(), Some("Harbor Nick"));
|
||||
assert!(snapshot.guild.is_none());
|
||||
let context = report.message_context.as_ref().expect("message context");
|
||||
assert_eq!(context[0]["author_bot"], true);
|
||||
assert_eq!(context[1]["author_bot"], false);
|
||||
let missing = context[0]["missing_attachments"]
|
||||
.as_array()
|
||||
.expect("missing attachments");
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0]["filename"], "evidence.png");
|
||||
assert_eq!(missing[0]["size"], 4096);
|
||||
assert!(
|
||||
context[1]["missing_attachments"]
|
||||
.as_array()
|
||||
.is_none_or(Vec::is_empty)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_without_bot_flags_snapshot_or_hold() {
|
||||
let nulls = report_json(serde_json::json!({
|
||||
"reported_user_bot": null,
|
||||
"reported_profile_snapshot": null,
|
||||
"legal_hold_until": null,
|
||||
"legal_hold_reason": null
|
||||
}));
|
||||
for json in [report_json(serde_json::json!({})), nulls] {
|
||||
let report = decode_report_both_ways(json);
|
||||
assert!(report.reported_user_bot.is_none());
|
||||
assert!(report.reported_profile_snapshot.is_none());
|
||||
assert!(report.legal_hold_until.is_none());
|
||||
assert!(report.legal_hold_reason.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_legal_hold_fields_and_hold_response() {
|
||||
let report = decode_report_both_ways(report_json(serde_json::json!({
|
||||
"legal_hold_until": "2027-01-31T23:59:59.999Z",
|
||||
"legal_hold_reason": "Court order 42"
|
||||
})));
|
||||
assert_eq!(
|
||||
report.legal_hold_until.as_deref(),
|
||||
Some("2027-01-31T23:59:59.999Z")
|
||||
);
|
||||
assert_eq!(report.legal_hold_reason.as_deref(), Some("Court order 42"));
|
||||
|
||||
for json in [
|
||||
serde_json::json!({
|
||||
"report_id": "1556008115701286089",
|
||||
"legal_hold_until": "2027-01-31T23:59:59.999Z",
|
||||
"legal_hold_reason": "Court order 42"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"report_id": "1556008115701286089",
|
||||
"legal_hold_until": null,
|
||||
"legal_hold_reason": null
|
||||
}),
|
||||
] {
|
||||
let generated: generated_types::ReportLegalHoldResponse =
|
||||
serde_json::from_value(json.clone()).expect("generated hold response");
|
||||
let via_generated: types::ReportLegalHoldResponse =
|
||||
serde_json::from_value(serde_json::to_value(generated).expect("serialize generated"))
|
||||
.expect("hand-written hold response from generated");
|
||||
let direct: types::ReportLegalHoldResponse =
|
||||
serde_json::from_value(json.clone()).expect("hand-written hold response");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&via_generated).unwrap(),
|
||||
serde_json::to_value(&direct).unwrap()
|
||||
);
|
||||
assert_eq!(direct.report_id, "1556008115701286089");
|
||||
assert_eq!(
|
||||
direct.legal_hold_until.as_deref(),
|
||||
json["legal_hold_until"].as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_webhook_report_with_a_bot_creator_and_a_deleted_creator() {
|
||||
let bot = decode_report_both_ways(report_json(serde_json::json!({
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1556008115710000001",
|
||||
"reported_webhook_name": "Relay",
|
||||
"reported_webhook_type": 2,
|
||||
"reported_webhook_application_id": "1556008115625788601",
|
||||
"reported_webhook_created_at": "2026-10-04T09:00:00.000Z",
|
||||
"reported_webhook_creator_id": "1556008115625788601",
|
||||
"reported_webhook_creator_tag": "relaybot#0000",
|
||||
"reported_webhook_creator_username": "relaybot",
|
||||
"reported_webhook_creator_discriminator": "0000"
|
||||
})));
|
||||
assert_eq!(bot.reported_webhook_type, Some(2));
|
||||
assert_eq!(
|
||||
bot.reported_webhook_application_id.as_deref(),
|
||||
Some("1556008115625788601")
|
||||
);
|
||||
assert_eq!(
|
||||
bot.reported_webhook_application_id,
|
||||
bot.reported_webhook_creator_id
|
||||
);
|
||||
|
||||
let gone = decode_report_both_ways(report_json(serde_json::json!({
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1556008115710000001",
|
||||
"reported_webhook_name": "Night Relay",
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_created_at": "2026-10-04T09:00:00.000Z",
|
||||
"reported_webhook_creator_id": "1556008115625788602",
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null
|
||||
})));
|
||||
assert_eq!(
|
||||
gone.reported_webhook_creator_id.as_deref(),
|
||||
Some("1556008115625788602")
|
||||
);
|
||||
assert!(gone.reported_webhook_creator_tag.is_none());
|
||||
assert!(gone.reported_webhook_creator_username.is_none());
|
||||
assert!(gone.reported_webhook_application_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_report_reason_list() {
|
||||
let json = r#"{
|
||||
"reasons": [
|
||||
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
|
||||
{"key": "raid", "label": "Raid or brigading", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "raid_coordination"}
|
||||
]
|
||||
}"#;
|
||||
let generated: generated_types::AdminReportReasonsResponse =
|
||||
serde_json::from_str(json).expect("generated reason list type");
|
||||
assert_eq!(generated.reasons.len(), 2);
|
||||
let response: types::ReportReasonListResponse =
|
||||
serde_json::from_str(json).expect("hand-written reason list type");
|
||||
assert_eq!(response.reasons[0].key, "csam");
|
||||
assert!(response.reasons[0].highest_priority);
|
||||
assert_eq!(
|
||||
response.reasons[1].legacy_category_guild.as_deref(),
|
||||
Some("raid_coordination")
|
||||
);
|
||||
|
||||
let lenient: types::ReportReasonListResponse = serde_json::from_str(
|
||||
r#"{"reasons": [{"key": "future_reason", "label": "Future reason", "future_field": 1}], "future_field": []}"#,
|
||||
)
|
||||
.expect("the reason list tolerates missing and unknown fields");
|
||||
assert_eq!(lenient.reasons[0].key, "future_reason");
|
||||
assert!(!lenient.reasons[0].highest_priority);
|
||||
assert!(lenient.reasons[0].legacy_category_message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_lookup_guild_response() {
|
||||
let json = r#"{
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,12 +3,43 @@
|
||||
#[path = "parity/mod.rs"]
|
||||
mod parity_support;
|
||||
|
||||
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, RawQuery},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use fluxer_admin::api::{
|
||||
generated::types::{
|
||||
AdminReportListResponse, LookupGuildResponse, ReportAdminResponseSchema,
|
||||
SearchGuildsResponse,
|
||||
},
|
||||
types::{ReportEntry, SearchReportsResponse},
|
||||
};
|
||||
use parity_support::{
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{error::Error, io};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const SEARCH_REPORTS_V2: &str = include_str!("parity/fixtures/api/search_reports_v2.json");
|
||||
const SEARCH_REPORTS_WEBHOOK: &str =
|
||||
include_str!("parity/fixtures/api/search_reports_webhook.json");
|
||||
const REPORT_WEBHOOK_DETAIL: &str = include_str!("parity/fixtures/api/report_webhook_detail.json");
|
||||
const REPORT_EVIDENCE_DETAIL: &str =
|
||||
include_str!("parity/fixtures/api/report_evidence_detail.json");
|
||||
const EVIDENCE_REPORT_ID: &str = "1556352159220498613";
|
||||
const EVIDENCE_BOT_ID: &str = "1556352159149195428";
|
||||
const EVIDENCE_WEBHOOK_ID: &str = "1556352159132418208";
|
||||
const EVIDENCE_DELETED_AUTHOR_ID: &str = "1556352159216304308";
|
||||
const WEBHOOK_ID: &str = "1556114449176200401";
|
||||
const WEBHOOK_REPORT_ID: &str = "1556114449264280806";
|
||||
const BOT_REPORT_ID: &str = "1556114449268475111";
|
||||
const BOT_USER_ID: &str = "1556114449192977621";
|
||||
const WEBHOOK_CREATOR_ID: &str = "1556114449125868741";
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
|
||||
@@ -130,6 +161,473 @@ async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_search_v2_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the generated response contract");
|
||||
let flow = generated.reports[0]
|
||||
.flow
|
||||
.as_ref()
|
||||
.expect("the v2 message report has answers");
|
||||
assert_eq!(flow.surface, "in_app");
|
||||
assert_eq!(flow.steps.len(), 4);
|
||||
assert_eq!(generated.reports[0].reason.as_deref(), Some("csam"));
|
||||
assert_eq!(generated.reports[2].reason, None);
|
||||
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_V2)
|
||||
.expect("v2 report search fixture must match the hand-written response type");
|
||||
assert_eq!(response.total, 3);
|
||||
let [message, user, legacy] = response.reports.as_slice() else {
|
||||
panic!("expected three reports");
|
||||
};
|
||||
assert_eq!(
|
||||
message.reason_label.as_deref(),
|
||||
Some("Child sexual abuse material")
|
||||
);
|
||||
assert_eq!(message.reason_highest_priority, Some(true));
|
||||
assert_eq!(message.category.as_deref(), Some("child_safety"));
|
||||
let user_flow = user.flow.as_ref().expect("the v2 user report has answers");
|
||||
assert_eq!(user.reason.as_deref(), Some("harassment"));
|
||||
assert_eq!(user_flow.locale.as_deref(), Some("de"));
|
||||
assert_eq!(user_flow.steps[0].screen_id, "profile_intro");
|
||||
assert!(user_flow.steps[0].option_id.is_none());
|
||||
assert!(user_flow.steps[0].items.is_empty());
|
||||
assert_eq!(
|
||||
user_flow.steps[1]
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["photo", "profile_text"]
|
||||
);
|
||||
assert!(legacy.reason.is_none());
|
||||
assert!(legacy.reason_label.is_none());
|
||||
assert!(legacy.flow.is_none());
|
||||
assert!(legacy.reporter_good_faith_confirmed.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_v2_report_fixture_with_reason_and_answers() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reason=csam",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(r#"name="reason""#), "{}", list.body);
|
||||
assert!(
|
||||
list.body.contains("Priority: Child sexual abuse material"),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
list.body.contains(r#"data-report-reason="csam""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
assert!(
|
||||
!list.body.contains(r#"data-report-reason="harassment""#),
|
||||
"{}",
|
||||
list.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115705480394",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
"Report Answers",
|
||||
"Report profile",
|
||||
"Which parts of their profile are a problem?: Pictures, Profile text",
|
||||
"Shown to the reporter in de",
|
||||
"Surface: In app",
|
||||
"Harassment or bullying",
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
|
||||
let legacy = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports/1556008115709674699",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(legacy.status, 200, "{legacy:#?}");
|
||||
assert!(!legacy.body.contains("Report Answers"), "{}", legacy.body);
|
||||
assert!(
|
||||
!legacy.body.contains("data-report-reason"),
|
||||
"{}",
|
||||
legacy.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_webhook_fixtures_match_the_generated_and_hand_written_contracts() {
|
||||
let generated: AdminReportListResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the generated response contract");
|
||||
assert_eq!(generated.reports.len(), 2);
|
||||
let response: SearchReportsResponse = serde_json::from_str(SEARCH_REPORTS_WEBHOOK)
|
||||
.expect("webhook report search fixture must match the hand-written response type");
|
||||
let [webhook, bot] = response.reports.as_slice() else {
|
||||
panic!("expected two reports");
|
||||
};
|
||||
assert_eq!(webhook.report_id, WEBHOOK_REPORT_ID);
|
||||
assert!(webhook.reported_user_id.is_none());
|
||||
assert_eq!(webhook.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
webhook.reported_webhook_name.as_deref(),
|
||||
Some("Harbor Bulletin")
|
||||
);
|
||||
assert_eq!(bot.reported_user_id.as_deref(), Some(BOT_USER_ID));
|
||||
assert!(bot.reported_webhook_id.is_none());
|
||||
|
||||
let generated_detail: ReportAdminResponseSchema = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated_detail).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_WEBHOOK_DETAIL)
|
||||
.expect("webhook report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert!(detail.reported_user_id.is_none());
|
||||
assert_eq!(detail.reported_webhook_id.as_deref(), Some(WEBHOOK_ID));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_id.as_deref(),
|
||||
Some(WEBHOOK_CREATOR_ID)
|
||||
);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_creator_global_name.as_deref(),
|
||||
Some("Morgan Owner")
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_type, Some(1));
|
||||
assert_eq!(
|
||||
detail.reported_webhook_channel_id,
|
||||
detail.reported_channel_id
|
||||
);
|
||||
assert_eq!(detail.reported_webhook_guild_id, detail.reported_guild_id);
|
||||
assert_eq!(
|
||||
detail.reported_webhook_created_at.as_deref(),
|
||||
Some("2026-10-04T01:23:15.892Z")
|
||||
);
|
||||
assert!(detail.reported_webhook_application_id.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let entry = context
|
||||
.iter()
|
||||
.find(|entry| entry["webhook_id"].as_str() == Some(WEBHOOK_ID))
|
||||
.expect("the webhook message is in the context");
|
||||
assert_eq!(entry["author_id"].as_str(), Some(WEBHOOK_ID));
|
||||
assert_eq!(entry["author_username"].as_str(), Some("Harbor Bulletin"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_webhook_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let webhook_marker = format!(r#"data-report-webhook="{WEBHOOK_ID}""#);
|
||||
let webhook_user_link = format!("/users/{WEBHOOK_ID}");
|
||||
|
||||
let list = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports?reported_webhook_id={WEBHOOK_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(list.status, 200, "{list:#?}");
|
||||
assert!(list.body.contains(WEBHOOK_REPORT_ID), "{}", list.body);
|
||||
assert!(!list.body.contains(BOT_REPORT_ID), "{}", list.body);
|
||||
assert!(list.body.contains(&webhook_marker), "{}", list.body);
|
||||
assert!(list.body.contains("Harbor Bulletin"), "{}", list.body);
|
||||
assert!(list.body.contains("Channel: general"), "{}", list.body);
|
||||
assert!(!list.body.contains(&webhook_user_link), "{}", list.body);
|
||||
|
||||
let both = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
"/reports?reporter_id=1556114449113285826",
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(both.status, 200, "{both:#?}");
|
||||
assert!(
|
||||
both.body
|
||||
.contains(&format!(r#"href="/users/{BOT_USER_ID}""#)),
|
||||
"{}",
|
||||
both.body
|
||||
);
|
||||
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{WEBHOOK_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
webhook_marker.clone(),
|
||||
">Webhook ID<".to_owned(),
|
||||
format!(r#"href="/reports?reported_webhook_id={WEBHOOK_ID}""#),
|
||||
"Harbor Commons".to_owned(),
|
||||
"Message Context".to_owned(),
|
||||
format!(r#"data-message-webhook="{WEBHOOK_ID}""#),
|
||||
format!(r#"href="/users/{BOT_USER_ID}""#),
|
||||
">Webhook Creator<".to_owned(),
|
||||
format!(r#"data-report-webhook-creator="{WEBHOOK_CREATOR_ID}""#),
|
||||
format!(r#"href="/users/{WEBHOOK_CREATOR_ID}""#),
|
||||
"Morgan Owner".to_owned(),
|
||||
">Webhook Type<".to_owned(),
|
||||
">Incoming<".to_owned(),
|
||||
">Webhook Created<".to_owned(),
|
||||
"Oct 4, 2026, 1:23 AM UTC".to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert!(!detail.body.contains(&webhook_user_link), "{}", detail.body);
|
||||
assert!(
|
||||
!detail.body.contains("View Reported User"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
for absent in [
|
||||
">Webhook Channel ID<",
|
||||
">Webhook Guild ID<",
|
||||
">Webhook Record<",
|
||||
"data-report-webhook-creator-deleted",
|
||||
] {
|
||||
assert!(!detail.body.contains(absent), "{absent}\n{}", detail.body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_evidence_fixture_matches_the_generated_and_hand_written_contracts() {
|
||||
let generated: ReportAdminResponseSchema = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the generated response contract");
|
||||
let via_generated: ReportEntry = serde_json::from_value(
|
||||
serde_json::to_value(generated).expect("serialize generated detail"),
|
||||
)
|
||||
.expect("hand-written report type from the generated detail");
|
||||
let direct: ReportEntry = serde_json::from_str(REPORT_EVIDENCE_DETAIL)
|
||||
.expect("evidence report detail fixture must match the hand-written report type");
|
||||
for detail in [&via_generated, &direct] {
|
||||
assert_eq!(detail.reported_user_id.as_deref(), Some(EVIDENCE_BOT_ID));
|
||||
assert_eq!(detail.reported_user_bot, Some(true));
|
||||
let snapshot = detail
|
||||
.reported_profile_snapshot
|
||||
.as_ref()
|
||||
.expect("the bot author has a profile snapshot");
|
||||
let user = snapshot.user.as_ref().expect("user snapshot");
|
||||
assert_eq!(user.id, EVIDENCE_BOT_ID);
|
||||
assert_eq!(user.username.as_deref(), Some("Harbor_Helper"));
|
||||
let member = snapshot.member.as_ref().expect("member snapshot");
|
||||
assert_eq!(member.guild_id, "1556352159090475158");
|
||||
assert!(member.joined_at.is_some());
|
||||
assert!(snapshot.guild.is_none());
|
||||
let context = detail.message_context.as_ref().expect("message context");
|
||||
let author_bot = |author_id: &str| {
|
||||
context
|
||||
.iter()
|
||||
.filter(|entry| entry["author_id"].as_str() == Some(author_id))
|
||||
.map(|entry| entry["author_bot"].clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(author_bot(EVIDENCE_BOT_ID), [json!(true), json!(true)]);
|
||||
assert_eq!(author_bot(EVIDENCE_WEBHOOK_ID), [Value::Null]);
|
||||
assert_eq!(author_bot(EVIDENCE_DELETED_AUTHOR_ID), [Value::Null]);
|
||||
assert_eq!(author_bot("1556352159069503633"), [json!(false)]);
|
||||
assert!(context.iter().all(|entry| {
|
||||
entry["missing_attachments"]
|
||||
.as_array()
|
||||
.is_none_or(Vec::is_empty)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_admin_renders_the_evidence_report_fixture() -> Result<(), Box<dyn Error>> {
|
||||
let api_endpoint = spawn_v2_report_api().await.map_err(test_error)?;
|
||||
let rust_admin = rust_server::start(&api_endpoint)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let detail = capture::fetch_route(
|
||||
&client,
|
||||
rust_admin.base_url(),
|
||||
&format!("/reports/{EVIDENCE_REPORT_ID}"),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
assert_eq!(detail.status, 200, "{detail:#?}");
|
||||
for expected in [
|
||||
format!(r#"data-report-user-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-author-bot="{EVIDENCE_BOT_ID}""#),
|
||||
format!(r#"data-message-webhook="{EVIDENCE_WEBHOOK_ID}""#),
|
||||
"At Report Time".to_owned(),
|
||||
"Harbor_Helper#3966".to_owned(),
|
||||
">Community Profile<".to_owned(),
|
||||
r#"data-report-legal-hold="none""#.to_owned(),
|
||||
] {
|
||||
assert!(
|
||||
detail.body.contains(&expected),
|
||||
"{expected}\n{}",
|
||||
detail.body
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
detail.body.matches("data-message-author-bot=").count(),
|
||||
1,
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
assert!(
|
||||
!detail.body.contains("data-missing-attachment"),
|
||||
"{}",
|
||||
detail.body
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn spawn_v2_report_api() -> Result<String, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind v2 report API: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read v2 report API address: {error}"))?
|
||||
.port();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
get(|| async {
|
||||
fixture_json(include_str!("parity/fixtures/api/admin_user_me.json"))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/report-reasons",
|
||||
get(|| async {
|
||||
Json(json!({"reasons": [
|
||||
{"key": "csam", "label": "Child sexual abuse material", "highest_priority": true, "legacy_category_message": "child_safety", "legacy_category_user": "child_safety", "legacy_category_guild": "child_safety"},
|
||||
{"key": "harassment", "label": "Harassment or bullying", "highest_priority": false, "legacy_category_message": "harassment", "legacy_category_user": "harassment", "legacy_category_guild": "harassment"}
|
||||
]}))
|
||||
.into_response()
|
||||
}),
|
||||
)
|
||||
.route("/admin/reports", get(v2_report_search))
|
||||
.route("/admin/reports/{report_id}", get(v2_report_detail));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
Ok(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
|
||||
fn fixture_json(body: &'static str) -> Response {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
Json(value).into_response()
|
||||
}
|
||||
|
||||
fn v2_reports() -> Vec<Value> {
|
||||
[SEARCH_REPORTS_V2, SEARCH_REPORTS_WEBHOOK]
|
||||
.into_iter()
|
||||
.flat_map(|body| {
|
||||
let value: Value = serde_json::from_str(body).expect("fixture is JSON");
|
||||
value["reports"].as_array().cloned().unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn v2_report_search(RawQuery(query): RawQuery) -> Response {
|
||||
let query = query.unwrap_or_default();
|
||||
let filters = url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(key, value)| {
|
||||
matches!(
|
||||
key.as_ref(),
|
||||
"reason" | "reporter_id" | "reported_webhook_id"
|
||||
) && !value.is_empty()
|
||||
})
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let reports = v2_reports()
|
||||
.into_iter()
|
||||
.filter(|report| {
|
||||
filters
|
||||
.iter()
|
||||
.all(|(key, value)| report[key.as_str()].as_str() == Some(value.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(json!({"reports": reports, "total": reports.len(), "offset": 0, "limit": 25}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn v2_report_detail(Path(report_id): Path<String>) -> Response {
|
||||
if report_id == WEBHOOK_REPORT_ID {
|
||||
return fixture_json(REPORT_WEBHOOK_DETAIL);
|
||||
}
|
||||
if report_id == EVIDENCE_REPORT_ID {
|
||||
return fixture_json(REPORT_EVIDENCE_DETAIL);
|
||||
}
|
||||
match v2_reports()
|
||||
.into_iter()
|
||||
.find(|report| report["report_id"].as_str() == Some(report_id.as_str()))
|
||||
{
|
||||
Some(report) => Json(report).into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_error(message: String) -> Box<dyn Error> {
|
||||
Box::new(io::Error::other(message))
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"report_id": "1556352159220498613",
|
||||
"reporter_id": "1556352159077892243",
|
||||
"reporter_tag": "target_a91da2c28aae#2089",
|
||||
"reporter_username": "target_a91da2c28aae",
|
||||
"reporter_global_name": "Jordan Target",
|
||||
"reporter_discriminator": "2089",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T17:12:47.653Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556352159149195428",
|
||||
"reported_user_tag": "Harbor_Helper#3966",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "3966",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_user_bot": true,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556352159090475158",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556352159161778343",
|
||||
"reported_channel_id": "1556352159103058073",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159069503633",
|
||||
"author_username": "reporter_c03a2ee3db86",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2642",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159073697938",
|
||||
"author_username": "unverified_7d9456caf467",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "4822",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159077892243",
|
||||
"author_username": "target_a91da2c28aae",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "2089",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"author_bot": false,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159132418208",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159132418208",
|
||||
"author_bot": null,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159149195428",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "3966",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": true,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159203721393",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556352159203721393",
|
||||
"author_bot": null,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556352159090475158",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556352159216304308",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"author_bot": null,
|
||||
"missing_attachments": [],
|
||||
"user_prior_ncmec_report_ids": []
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556352159115640988",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159069503633",
|
||||
"username": "reporter_c03a2ee3db86",
|
||||
"discriminator": "2642",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.358Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159119835293",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159073697938",
|
||||
"username": "unverified_7d9456caf467",
|
||||
"discriminator": "4822",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.359Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159124029598",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.360Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159128223903",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159077892243",
|
||||
"username": "target_a91da2c28aae",
|
||||
"discriminator": "2089",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T17:07:50.361Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159140806818",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159132418208",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159132418208",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T17:07:50.364Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159153389733",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.367Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159161778343",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T17:07:50.369Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159212110003",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159203721393",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556352159203721393",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T17:07:50.381Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556352159186944173",
|
||||
"channel_id": "1556352159103058073",
|
||||
"author": {
|
||||
"id": "1556352159216304308",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T17:07:50.375Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reported_profile_snapshot": {
|
||||
"captured_at": "2026-10-04T17:12:47.653Z",
|
||||
"user": {
|
||||
"id": "1556352159149195428",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "3966",
|
||||
"global_name": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"member": {
|
||||
"guild_id": "1556352159090475158",
|
||||
"nick": null,
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"joined_at": "2026-10-04T17:12:47.614Z",
|
||||
"avatar": null,
|
||||
"banner": null
|
||||
},
|
||||
"guild": null
|
||||
},
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"mutual_dm_channel_id": null,
|
||||
"message_context": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449113285826",
|
||||
"author_username": "reporter_010908032cb3",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "7915",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449117480131",
|
||||
"author_username": "unverified_b8bab8c7a8c5",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0943",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449121674436",
|
||||
"author_username": "target_8b1ee6a349df",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "9014",
|
||||
"author_avatar": "f829b914",
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449176200401",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449176200401",
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449247503586",
|
||||
"author_username": "Night Relay",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": "1556114449247503586",
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449260086501",
|
||||
"author_username": "DeletedUser",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "0000",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"channel_nsfw": false,
|
||||
"channel_content_warning_level": null,
|
||||
"channel_content_warning_text": null,
|
||||
"guild_id": "1556114449134257351",
|
||||
"guild_nsfw_level": 0,
|
||||
"guild_nsfw": null,
|
||||
"guild_content_warning_level": null,
|
||||
"guild_content_warning_text": null,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"attachments": [],
|
||||
"author_id": "1556114449192977621",
|
||||
"author_username": "Harbor_Helper",
|
||||
"author_global_name": null,
|
||||
"author_discriminator": "1238",
|
||||
"author_avatar": null,
|
||||
"webhook_id": null,
|
||||
"user_prior_ncmec_report_ids": []
|
||||
}
|
||||
],
|
||||
"message_responses": [
|
||||
{
|
||||
"id": "1556114449159423181",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449113285826",
|
||||
"username": "reporter_010908032cb3",
|
||||
"discriminator": "7915",
|
||||
"global_name": "Avery Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.888Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449163617486",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449117480131",
|
||||
"username": "unverified_b8bab8c7a8c5",
|
||||
"discriminator": "0943",
|
||||
"global_name": "Unverified Reporter",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.889Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449167811791",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.890Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449172006096",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449121674436",
|
||||
"username": "target_8b1ee6a349df",
|
||||
"discriminator": "9014",
|
||||
"global_name": "Jordan Target",
|
||||
"avatar": "f829b914",
|
||||
"avatar_color": 4604377,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Anyone up for a trade this weekend?",
|
||||
"timestamp": "2026-10-04T01:23:15.891Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449184589011",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449176200401",
|
||||
"username": "Harbor Bulletin",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Harbor Bulletin",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449176200401",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Weekly bulletin: the garden swap starts Saturday.",
|
||||
"timestamp": "2026-10-04T01:23:15.894Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449255892196",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449247503586",
|
||||
"username": "Night Relay",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Night Relay",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": "1556114449247503586",
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Night relay: the swap tables are packed away.",
|
||||
"timestamp": "2026-10-04T01:23:15.911Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449230726366",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449260086501",
|
||||
"username": "DeletedUser",
|
||||
"discriminator": "0000",
|
||||
"global_name": "Deleted User",
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.905Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449205560536",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"content": "Reminder: the garden swap moved to 10 AM.",
|
||||
"timestamp": "2026-10-04T01:23:15.899Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
},
|
||||
{
|
||||
"id": "1556114449197171926",
|
||||
"channel_id": "1556114449146840266",
|
||||
"author": {
|
||||
"id": "1556114449192977621",
|
||||
"username": "Harbor_Helper",
|
||||
"discriminator": "1238",
|
||||
"global_name": null,
|
||||
"avatar": null,
|
||||
"avatar_color": null,
|
||||
"bot": true,
|
||||
"flags": 0
|
||||
},
|
||||
"webhook_id": null,
|
||||
"type": 7,
|
||||
"flags": 0,
|
||||
"content": "",
|
||||
"timestamp": "2026-10-04T01:23:15.897Z",
|
||||
"edited_timestamp": null,
|
||||
"pinned": false,
|
||||
"mention_everyone": false,
|
||||
"tts": false,
|
||||
"mentions": [],
|
||||
"mention_roles": [],
|
||||
"mention_channels": null,
|
||||
"embeds": null,
|
||||
"attachments": null,
|
||||
"stickers": null,
|
||||
"reactions": null,
|
||||
"message_reference": null,
|
||||
"message_snapshots": null,
|
||||
"nonce": null,
|
||||
"call": null
|
||||
}
|
||||
],
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556008115701286089",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.848Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "child_safety",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115697091784",
|
||||
"reported_channel_id": "1556008115692897479",
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "csam",
|
||||
"reason_label": "Child sexual abuse material",
|
||||
"reason_highest_priority": true,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "abuse",
|
||||
"screen_title": "What does it involve?",
|
||||
"option_id": "sexual",
|
||||
"option_label": "Sexual content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "sexual",
|
||||
"screen_title": "Tell us about the sexual content",
|
||||
"option_id": "minor_sexual",
|
||||
"option_label": "Sexualizing a minor",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "minor_sexual",
|
||||
"screen_title": "What did you see involving a minor?",
|
||||
"option_id": "csam",
|
||||
"option_label": "Real or AI-generated images or videos of child sexual abuse",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115705480394",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.852Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "harassment",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": null,
|
||||
"reported_guild_name": null,
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": null,
|
||||
"reported_channel_id": null,
|
||||
"reported_channel_name": null,
|
||||
"reported_channel_nsfw": null,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": null,
|
||||
"reported_guild_nsfw": null,
|
||||
"reported_guild_content_warning_level": null,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": null,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": null,
|
||||
"reported_channel_effective_content_warning_level": null,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "22a1e877519c31ca",
|
||||
"surface": "in_app",
|
||||
"locale": "de",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "profile_intro",
|
||||
"screen_title": "Report profile",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_parts",
|
||||
"screen_title": "Which parts of their profile are a problem?",
|
||||
"option_id": null,
|
||||
"option_label": null,
|
||||
"items": [
|
||||
{
|
||||
"id": "photo",
|
||||
"label": "Pictures"
|
||||
},
|
||||
{
|
||||
"id": "profile_text",
|
||||
"label": "Profile text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"screen_id": "root_user",
|
||||
"screen_title": "What's wrong with their profile?",
|
||||
"option_id": "abuse",
|
||||
"option_label": "Abusive or harmful content",
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"screen_id": "profile_abuse",
|
||||
"screen_title": "What's harmful about their profile?",
|
||||
"option_id": "harassment",
|
||||
"option_label": "Their profile harasses or targets me or someone else",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556008115709674699",
|
||||
"reporter_id": "1556008115617399989",
|
||||
"reporter_tag": "reporter_f189c9468c17#0896",
|
||||
"reporter_username": "reporter_f189c9468c17",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "0896",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-03T18:30:33.856Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556008115625788599",
|
||||
"reported_user_tag": "target_4a5a5ce687ff#6741",
|
||||
"reported_user_username": "target_4a5a5ce687ff",
|
||||
"reported_user_global_name": "Jordan Target",
|
||||
"reported_user_discriminator": "6741",
|
||||
"reported_user_avatar_hash": "f829b914",
|
||||
"reported_guild_id": "1556008115638371514",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556008115676120259",
|
||||
"reported_channel_id": "1556008115650954429",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": null,
|
||||
"reason_label": null,
|
||||
"reason_highest_priority": null,
|
||||
"flow": null,
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"offset": 0,
|
||||
"limit": 25
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"report_id": "1556114449264280806",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.372Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": null,
|
||||
"reported_user_tag": null,
|
||||
"reported_user_username": null,
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": null,
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": "1556114449176200401",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": "Harbor Bulletin",
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": 1,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": "1556114449146840266",
|
||||
"reported_webhook_guild_id": "1556114449134257351",
|
||||
"reported_webhook_created_at": "2026-10-04T01:23:15.892Z",
|
||||
"reported_webhook_creator_id": "1556114449125868741",
|
||||
"reported_webhook_creator_tag": "owner_aa696aafce86#7021",
|
||||
"reported_webhook_creator_username": "owner_aa696aafce86",
|
||||
"reported_webhook_creator_global_name": "Morgan Owner",
|
||||
"reported_webhook_creator_discriminator": "7021",
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449184589011",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
},
|
||||
{
|
||||
"report_id": "1556114449268475111",
|
||||
"reporter_id": "1556114449113285826",
|
||||
"reporter_tag": "reporter_010908032cb3#7915",
|
||||
"reporter_username": "reporter_010908032cb3",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reporter_discriminator": "7915",
|
||||
"reporter_email": "[email protected]",
|
||||
"reporter_full_legal_name": null,
|
||||
"reporter_country_of_residence": null,
|
||||
"reported_at": "2026-10-04T01:30:41.375Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"additional_info": null,
|
||||
"reported_user_id": "1556114449192977621",
|
||||
"reported_user_tag": "Harbor_Helper#1238",
|
||||
"reported_user_username": "Harbor_Helper",
|
||||
"reported_user_global_name": null,
|
||||
"reported_user_discriminator": "1238",
|
||||
"reported_user_avatar_hash": null,
|
||||
"reported_webhook_id": null,
|
||||
"reported_webhook_name": null,
|
||||
"reported_webhook_avatar_hash": null,
|
||||
"reported_webhook_default_name": null,
|
||||
"reported_webhook_default_avatar_hash": null,
|
||||
"reported_webhook_type": null,
|
||||
"reported_webhook_application_id": null,
|
||||
"reported_webhook_channel_id": null,
|
||||
"reported_webhook_guild_id": null,
|
||||
"reported_webhook_created_at": null,
|
||||
"reported_webhook_creator_id": null,
|
||||
"reported_webhook_creator_tag": null,
|
||||
"reported_webhook_creator_username": null,
|
||||
"reported_webhook_creator_global_name": null,
|
||||
"reported_webhook_creator_discriminator": null,
|
||||
"reported_webhook_creator_avatar_hash": null,
|
||||
"reported_guild_id": "1556114449134257351",
|
||||
"reported_guild_name": "Harbor Commons",
|
||||
"reported_guild_icon_hash": null,
|
||||
"reported_message_id": "1556114449205560536",
|
||||
"reported_channel_id": "1556114449146840266",
|
||||
"reported_channel_name": "general",
|
||||
"reported_channel_nsfw": false,
|
||||
"reported_guild_invite_code": null,
|
||||
"reported_guild_nsfw_level": 0,
|
||||
"reported_guild_nsfw": false,
|
||||
"reported_guild_content_warning_level": 0,
|
||||
"reported_guild_content_warning_text": null,
|
||||
"reported_channel_nsfw_override": null,
|
||||
"reported_channel_content_warning_level": 0,
|
||||
"reported_channel_content_warning_text": null,
|
||||
"reported_channel_effective_nsfw": false,
|
||||
"reported_channel_effective_content_warning_level": 0,
|
||||
"reported_channel_effective_content_warning_text": null,
|
||||
"resolved_at": null,
|
||||
"resolved_by_admin_id": null,
|
||||
"public_comment": null,
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reason_highest_priority": false,
|
||||
"flow": {
|
||||
"revision_hash": "16781a19a797f23f",
|
||||
"surface": "in_app",
|
||||
"locale": "en-US",
|
||||
"steps": [
|
||||
{
|
||||
"screen_id": "root_message",
|
||||
"screen_title": "Report message",
|
||||
"option_id": "spam",
|
||||
"option_label": "Spam or unwanted ads",
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"reporter_good_faith_confirmed": null
|
||||
}
|
||||
],
|
||||
"total": 2,
|
||||
"offset": 0,
|
||||
"limit": 50
|
||||
}
|
||||
Reference in new issue
Block a user