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

@@ -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}");
}
}