feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

+39
View File
@@ -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]);
+34 -18
View File
@@ -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,
+174 -5
View File
@@ -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,
+7 -2
View File
@@ -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()
}
+2
View File
@@ -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(),
+244 -21
View File
@@ -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(&params),
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"
);
}
}
+32 -1
View File
@@ -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)) }
}
}
}
+19 -2
View File
@@ -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
+169 -80
View File
@@ -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}");
}
}