mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
1 parent
bc17922b02
commit
ece622e800
471 files changed
+56262
-35932
No files matched your search
@@ -215,6 +215,13 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
VOICE_SERVER_UPDATE,
|
||||
];
|
||||
|
||||
pub const NCMEC_ATTACHMENT_REPORT: &[&str] = &[
|
||||
CSAM_SUBMIT_NCMEC,
|
||||
MESSAGE_DELETE,
|
||||
USER_DELETE,
|
||||
ARCHIVE_TRIGGER_USER,
|
||||
];
|
||||
|
||||
pub fn has_permission(admin_acls: &[String], required: &str) -> bool {
|
||||
let required_alias = acl_key_alias(required);
|
||||
admin_acls.iter().any(|acl| {
|
||||
@@ -229,6 +236,10 @@ pub fn has_any_permission(admin_acls: &[String], required: &[&str]) -> bool {
|
||||
required.iter().any(|acl| has_permission(admin_acls, acl))
|
||||
}
|
||||
|
||||
pub fn has_all_permissions(admin_acls: &[String], required: &[&str]) -> bool {
|
||||
required.iter().all(|acl| has_permission(admin_acls, acl))
|
||||
}
|
||||
|
||||
fn acl_key_alias(acl: &str) -> String {
|
||||
acl.replace(':', "_").to_ascii_uppercase()
|
||||
}
|
||||
@@ -282,6 +293,34 @@ mod tests {
|
||||
assert!(has_any_permission(&acls(&[]), &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_all_permissions_needs_every_acl() {
|
||||
assert!(has_all_permissions(&acls(&[USER_LOOKUP]), &[]));
|
||||
assert!(has_all_permissions(
|
||||
&acls(&[USER_LOOKUP, GUILD_LOOKUP]),
|
||||
&[GUILD_LOOKUP, USER_LOOKUP]
|
||||
));
|
||||
assert!(!has_all_permissions(
|
||||
&acls(&[USER_LOOKUP]),
|
||||
&[GUILD_LOOKUP, USER_LOOKUP]
|
||||
));
|
||||
assert!(has_all_permissions(
|
||||
&acls(&[WILDCARD]),
|
||||
NCMEC_ATTACHMENT_REPORT
|
||||
));
|
||||
for missing in NCMEC_ATTACHMENT_REPORT {
|
||||
let held = NCMEC_ATTACHMENT_REPORT
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|acl| acl != missing)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
!has_all_permissions(&acls(&held), NCMEC_ATTACHMENT_REPORT),
|
||||
"{missing}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_any_permission_none_match() {
|
||||
let user_acls = acls(&[USER_LOOKUP]);
|
||||
|
||||
@@ -4,7 +4,8 @@ use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
ReportEntry, ReportLegalHoldResponse, ReportReasonListResponse, ResolveReportResponse,
|
||||
SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -13,8 +14,10 @@ pub struct SearchReportsParams<'a> {
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
@@ -26,23 +29,6 @@ pub struct SearchReportsParams<'a> {
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
status: Option<i32>,
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -72,6 +58,27 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_report_legal_hold(
|
||||
&self,
|
||||
report_id: &str,
|
||||
legal_hold_until: Option<&str>,
|
||||
legal_hold_reason: Option<&str>,
|
||||
) -> ApiResult<ReportLegalHoldResponse> {
|
||||
let body = serde_json::json!({
|
||||
"legal_hold_until": legal_hold_until,
|
||||
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
|
||||
});
|
||||
self.post_with_reason(
|
||||
&format!(
|
||||
"/admin/reports/{}/legal-hold",
|
||||
urlencoding::encode(report_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
@@ -98,11 +105,16 @@ impl AdminApiClient {
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reason", params.reason.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_webhook_id",
|
||||
params.reported_webhook_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
@@ -127,6 +139,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn list_report_reasons(&self) -> ApiResult<ReportReasonListResponse> {
|
||||
self.get("/admin/report-reasons", None).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
&self,
|
||||
reporter_id: &str,
|
||||
|
||||
@@ -98,6 +98,38 @@ pub struct ReportEntry {
|
||||
pub reported_user_global_name: Option<String>,
|
||||
pub reported_user_discriminator: Option<String>,
|
||||
pub reported_user_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_default_avatar_hash: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_type: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_application_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_channel_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reported_webhook_creator_avatar_hash: Option<String>,
|
||||
pub reported_guild_id: Option<String>,
|
||||
pub reported_guild_name: Option<String>,
|
||||
pub reported_guild_icon_hash: Option<String>,
|
||||
@@ -121,6 +153,148 @@ pub struct ReportEntry {
|
||||
pub public_comment: Option<String>,
|
||||
pub mutual_dm_channel_id: Option<String>,
|
||||
pub message_context: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reason_highest_priority: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub flow: Option<ReportFlowAnswersEntry>,
|
||||
#[serde(default)]
|
||||
pub reporter_good_faith_confirmed: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_user_bot: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub reported_profile_snapshot: Option<ReportProfileSnapshot>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshot {
|
||||
#[serde(default)]
|
||||
pub captured_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub user: Option<ReportProfileSnapshotUser>,
|
||||
#[serde(default)]
|
||||
pub member: Option<ReportProfileSnapshotMember>,
|
||||
#[serde(default)]
|
||||
pub guild: Option<ReportProfileSnapshotGuild>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotUser {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discriminator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub global_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotMember {
|
||||
pub guild_id: String,
|
||||
#[serde(default)]
|
||||
pub nick: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bio: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pronouns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub joined_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotGuild {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vanity_url_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub banner: Option<ReportProfileSnapshotAsset>,
|
||||
#[serde(default)]
|
||||
pub splash: Option<ReportProfileSnapshotAsset>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct ReportProfileSnapshotAsset {
|
||||
pub hash: String,
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportLegalHoldResponse {
|
||||
pub report_id: String,
|
||||
pub legal_hold_until: Option<String>,
|
||||
pub legal_hold_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswersEntry {
|
||||
pub revision_hash: String,
|
||||
pub surface: String,
|
||||
#[serde(default)]
|
||||
pub locale: Option<String>,
|
||||
#[serde(default)]
|
||||
pub steps: Vec<ReportFlowAnswerStepEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerStepEntry {
|
||||
pub screen_id: String,
|
||||
pub screen_title: String,
|
||||
#[serde(default)]
|
||||
pub option_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub option_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ReportFlowAnswerItemEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportFlowAnswerItemEntry {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonEntry {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub highest_priority: bool,
|
||||
#[serde(default)]
|
||||
pub legacy_category_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub legacy_category_guild: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ReportReasonListResponse {
|
||||
pub reasons: Vec<ReportReasonEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -131,11 +305,6 @@ pub struct SearchReportsResponse {
|
||||
pub limit: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ListReportsResponse {
|
||||
pub reports: Vec<ReportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ResolveReportResponse {
|
||||
pub report_id: String,
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::components::message_list::MessageActions,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
@@ -321,7 +322,11 @@ pub(crate) async fn messages_browse_fragment(
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let can_delete = acl::has_permission(admin_acls, acl::MESSAGE_DELETE);
|
||||
let actions = MessageActions {
|
||||
delete: acl::has_permission(admin_acls, acl::MESSAGE_DELETE),
|
||||
ncmec: acl::has_all_permissions(admin_acls, acl::NCMEC_ATTACHMENT_REPORT),
|
||||
source_report_id: None,
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client
|
||||
.browse_channel(
|
||||
@@ -334,7 +339,7 @@ pub(crate) async fn messages_browse_fragment(
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let markup = crate::templates::pages::messages_page::browse_messages_fragment(
|
||||
config, &resp.data, can_delete, None,
|
||||
config, &resp.data, actions, None,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn messages_page(
|
||||
.unwrap_or(&[]);
|
||||
let can_lookup = acl::has_permission(admin_acls, acl::MESSAGE_LOOKUP);
|
||||
let can_delete = acl::has_permission(admin_acls, acl::MESSAGE_DELETE);
|
||||
let can_report_ncmec = acl::has_all_permissions(admin_acls, acl::NCMEC_ATTACHMENT_REPORT);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let channel_id = query.channel_id.as_deref().filter(|s| !s.is_empty());
|
||||
let message_id = query.message_id.as_deref().filter(|s| !s.is_empty());
|
||||
@@ -137,6 +138,7 @@ async fn messages_page(
|
||||
prefill_channel_id: channel_id,
|
||||
can_lookup,
|
||||
can_delete,
|
||||
can_report_ncmec,
|
||||
lookup_result: lookup_result.as_ref(),
|
||||
browse_result: browse_result.as_ref(),
|
||||
search_result: search_result.as_ref(),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
client::{AdminApiClient, ApiError, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
types::ReportEntry,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
@@ -14,7 +16,7 @@ use crate::{
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
utils::forms::clean_string,
|
||||
utils::{forms::clean_string, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use axum::{
|
||||
Form, Router,
|
||||
@@ -35,8 +37,10 @@ struct ReportsQuery {
|
||||
#[serde(rename = "type")]
|
||||
report_type: Option<String>,
|
||||
category: Option<String>,
|
||||
reason: Option<String>,
|
||||
reporter_id: Option<String>,
|
||||
reported_user_id: Option<String>,
|
||||
reported_webhook_id: Option<String>,
|
||||
reported_guild_id: Option<String>,
|
||||
reported_channel_id: Option<String>,
|
||||
guild_context_id: Option<String>,
|
||||
@@ -46,6 +50,18 @@ struct ReportsQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LegalHoldForm {
|
||||
#[serde(default)]
|
||||
_csrf: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_until: Option<String>,
|
||||
#[serde(default)]
|
||||
legal_hold_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
clear: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveForm {
|
||||
#[serde(default)]
|
||||
@@ -67,6 +83,10 @@ pub fn router() -> Router<AppState> {
|
||||
"/reports/{report_id}/resolve",
|
||||
axum::routing::post(report_resolve),
|
||||
)
|
||||
.route(
|
||||
"/reports/{report_id}/legal-hold",
|
||||
axum::routing::post(report_legal_hold),
|
||||
)
|
||||
}
|
||||
|
||||
async fn reports_list(
|
||||
@@ -95,25 +115,30 @@ async fn reports_list(
|
||||
.report_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
category: query.category.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
.await
|
||||
.log_error("search reports");
|
||||
let reason = query.reason.as_deref().and_then(clean_string);
|
||||
let params = SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (reports, reasons) = tokio::join!(
|
||||
client.search_reports(¶ms),
|
||||
state.report_reasons(&client)
|
||||
);
|
||||
let reports = reports.log_error("search reports");
|
||||
|
||||
let markup = templates::pages::reports_list::reports_list_page(
|
||||
config,
|
||||
@@ -124,14 +149,17 @@ async fn reports_list(
|
||||
status: query.status.as_deref(),
|
||||
report_type: query.report_type.as_deref(),
|
||||
category: query.category.as_deref(),
|
||||
reason: reason.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_webhook_id: query.reported_webhook_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort: query.sort.as_deref().unwrap_or("reportedAt_desc"),
|
||||
},
|
||||
reasons.as_deref(),
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
@@ -167,10 +195,12 @@ async fn report_detail(
|
||||
.log_error("load report detail");
|
||||
match report {
|
||||
Some(report) => {
|
||||
let live = load_live_profile(&client, &auth.0, &report).await;
|
||||
let markup = templates::pages::report_detail::report_detail_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&report,
|
||||
&live,
|
||||
&csrf_token,
|
||||
is_detail_fragment,
|
||||
);
|
||||
@@ -183,6 +213,49 @@ async fn report_detail(
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_live_profile(
|
||||
client: &AdminApiClient,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
) -> templates::pages::report_detail::LiveProfile {
|
||||
let can = |permission: &str| {
|
||||
auth.admin_user
|
||||
.as_ref()
|
||||
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
|
||||
};
|
||||
let snapshot = report.reported_profile_snapshot.as_ref();
|
||||
let user_id = snapshot
|
||||
.and_then(|snapshot| snapshot.user.as_ref())
|
||||
.map(|user| user.id.as_str())
|
||||
.filter(|_| can(acl::USER_LOOKUP));
|
||||
let guild_id = snapshot
|
||||
.and_then(|snapshot| snapshot.guild.as_ref())
|
||||
.map(|guild| guild.id.as_str())
|
||||
.filter(|_| can(acl::GUILD_LOOKUP));
|
||||
let (user, guild) = tokio::join!(
|
||||
async {
|
||||
match user_id {
|
||||
Some(id) => client
|
||||
.get_user_by_id(id)
|
||||
.await
|
||||
.log_error("load live reported user"),
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
async {
|
||||
match guild_id {
|
||||
Some(id) => client
|
||||
.lookup_guild(id)
|
||||
.await
|
||||
.log_error("load live reported guild")
|
||||
.flatten(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
);
|
||||
templates::pages::report_detail::LiveProfile { user, guild }
|
||||
}
|
||||
|
||||
async fn report_fragment(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -261,6 +334,108 @@ async fn report_resolve(
|
||||
}
|
||||
}
|
||||
|
||||
async fn report_legal_hold(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(report_id): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let back = format!("{base}/reports/{report_id}");
|
||||
let form: LegalHoldForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "failed to parse report legal hold form");
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let clearing = form.clear.is_some();
|
||||
let until = if clearing {
|
||||
None
|
||||
} else {
|
||||
match form.legal_hold_until.as_deref().and_then(legal_hold_date) {
|
||||
Some(date) if date < time::OffsetDateTime::now_utc().date() => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_IN_PAST),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
Some(date) => Some(legal_hold_end_of_day(date)),
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error("Choose the date the hold ends"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
let reason = clean_string(form.legal_hold_reason.as_deref().unwrap_or(""));
|
||||
if until.is_some() && reason.is_none() {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(LEGAL_HOLD_NEEDS_REASON),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client
|
||||
.set_report_legal_hold(&report_id, until.as_deref(), reason.as_deref())
|
||||
.await;
|
||||
let flash = match result {
|
||||
Ok(response) => match response.legal_hold_until {
|
||||
Some(until) => FlashData::success(format!(
|
||||
"Legal hold placed until {}",
|
||||
format_admin_timestamp(&until)
|
||||
)),
|
||||
None => FlashData::success("Legal hold cleared"),
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, report_id, "admin API request failed: set report legal hold");
|
||||
match error {
|
||||
ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
} => FlashData::error(legal_hold_refusal(&message)),
|
||||
_ => FlashData::error("Failed to update the legal hold"),
|
||||
}
|
||||
}
|
||||
};
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future";
|
||||
const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold";
|
||||
|
||||
fn legal_hold_date(date: &str) -> Option<time::Date> {
|
||||
let format = time::format_description::parse_borrowed::<2>("[year]-[month]-[day]").ok()?;
|
||||
time::Date::parse(date.trim(), &format).ok()
|
||||
}
|
||||
|
||||
fn legal_hold_end_of_day(date: time::Date) -> String {
|
||||
format!("{date}T23:59:59.999Z")
|
||||
}
|
||||
|
||||
fn legal_hold_refusal(body: &str) -> &'static str {
|
||||
let body: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
let reason_refused = body["errors"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|error| error["path"] == "legal_hold_reason");
|
||||
if reason_refused {
|
||||
LEGAL_HOLD_NEEDS_REASON
|
||||
} else {
|
||||
LEGAL_HOLD_IN_PAST
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
|
||||
match sort.unwrap_or("reportedAt_desc") {
|
||||
"reportedAt_asc" => ("reportedAt", "asc"),
|
||||
@@ -271,3 +446,51 @@ fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
|
||||
_ => ("reportedAt", "desc"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legal_hold_dates_end_at_the_end_of_the_day_in_utc() {
|
||||
assert_eq!(
|
||||
legal_hold_date("2027-01-31")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-01-31T23:59:59.999Z")
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_date(" 2027-02-01 ")
|
||||
.map(legal_hold_end_of_day)
|
||||
.as_deref(),
|
||||
Some("2027-02-01T23:59:59.999Z")
|
||||
);
|
||||
for invalid in ["", "2027-02-30", "31/01/2027", "2027-1-31", "tomorrow"] {
|
||||
assert_eq!(legal_hold_date(invalid), None, "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_hold_names_the_field_the_api_refused() {
|
||||
let refusal = |path: &str| {
|
||||
serde_json::json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input Validation Error",
|
||||
"errors": [{"path": path, "message": "refused"}]
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_until")),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal(&refusal("legal_hold_reason")),
|
||||
"Give a reason for the hold"
|
||||
);
|
||||
assert_eq!(
|
||||
legal_hold_refusal("not json"),
|
||||
"The hold must end in the future"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding, ReportReasonEntry},
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ struct AppStateInner {
|
||||
pub http_client: reqwest::Client,
|
||||
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
|
||||
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
|
||||
report_reasons: Mutex<Option<Arc<[ReportReasonEntry]>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -40,6 +41,7 @@ impl AppState {
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
account_identity: Mutex::new(None),
|
||||
report_reasons: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -126,6 +128,35 @@ impl AppState {
|
||||
Some((Instant::now() + ttl, settings));
|
||||
settings
|
||||
}
|
||||
|
||||
pub async fn report_reasons(
|
||||
&self,
|
||||
client: &AdminApiClient,
|
||||
) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
if let Some(reasons) = self.cached_report_reasons() {
|
||||
return Some(reasons);
|
||||
}
|
||||
let reasons: Arc<[ReportReasonEntry]> = client
|
||||
.list_report_reasons()
|
||||
.await
|
||||
.log_error("load report reasons")?
|
||||
.reasons
|
||||
.into();
|
||||
*self
|
||||
.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reasons.clone());
|
||||
Some(reasons)
|
||||
}
|
||||
|
||||
fn cached_report_reasons(&self) -> Option<Arc<[ReportReasonEntry]>> {
|
||||
self.inner
|
||||
.report_reasons
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl axum::extract::FromRef<AppState> for AdminConfig {
|
||||
|
||||
@@ -17,6 +17,14 @@ pub struct Attachment {
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct MissingAttachment {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub content_type: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
@@ -27,12 +35,15 @@ pub struct Message {
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub webhook_id: Option<String>,
|
||||
pub author_bot: Option<bool>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub missing_attachments: Vec<MissingAttachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
@@ -48,6 +59,12 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
let missing_attachments = value["missing_attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(missing_attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -61,6 +78,8 @@ fn message_from_value(value: &Value) -> Message {
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
webhook_id: value_id(&value["webhook_id"]),
|
||||
author_bot: value["author_bot"].as_bool(),
|
||||
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||
channel_content_warning_level: value["channel_content_warning_level"]
|
||||
@@ -71,6 +90,16 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_attachment_from_value(value: &Value) -> MissingAttachment {
|
||||
MissingAttachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
size: value["size"].as_u64(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +186,8 @@ mod tests {
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
@@ -175,10 +206,69 @@ mod tests {
|
||||
ncmec_report_id: Some("report-id".into()),
|
||||
ncmec_failure_reason: Some("previous failure".into()),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
@@ -187,6 +277,7 @@ mod tests {
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.webhook_id, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::icons::paperclip_icon;
|
||||
use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct MessageActions<'a> {
|
||||
pub delete: bool,
|
||||
pub ncmec: bool,
|
||||
pub source_report_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn is_image(att: &Attachment) -> bool {
|
||||
att.content_type
|
||||
.as_deref()
|
||||
@@ -44,7 +53,7 @@ fn ncmec_badge(att: &Attachment) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
fn render_image_attachments(msg: &Message, actions: MessageActions<'_>) -> Markup {
|
||||
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||
if images.peek().is_none() {
|
||||
return html! {};
|
||||
@@ -72,38 +81,43 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
@if include_delete {
|
||||
@if actions.delete || actions.ncmec {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
button type="button"
|
||||
class="delete-message-btn rounded bg-white px-2.5 py-1 \
|
||||
text-red-600 text-xs shadow-sm ring-1 ring-neutral-200 \
|
||||
transition-colors hover:bg-red-50 hover:text-red-700"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id) { "Delete" }
|
||||
button type="button"
|
||||
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
|
||||
shadow-sm ring-1 ring-neutral-200 transition-colors \
|
||||
hover:bg-neutral-100 disabled:cursor-not-allowed \
|
||||
disabled:opacity-70"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id)
|
||||
data-attachment-id=(att.id)
|
||||
data-filename=(att.filename)
|
||||
data-content-type=(att.content_type.as_deref().unwrap_or(""))
|
||||
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
|
||||
data-author-id=(msg.author_id)
|
||||
data-ncmec-status=(att.ncmec_status)
|
||||
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
|
||||
disabled[att.ncmec_status == "submitted"]
|
||||
title=(if att.ncmec_status == "submitted" {
|
||||
"Already reported to NCMEC"
|
||||
} else {
|
||||
"Report this image to NCMEC"
|
||||
}) {
|
||||
@if att.ncmec_status == "submitted" {
|
||||
"Reported to NCMEC"
|
||||
} @else {
|
||||
"Report to NCMEC"
|
||||
@if actions.delete {
|
||||
button type="button"
|
||||
class="delete-message-btn rounded bg-white px-2.5 py-1 \
|
||||
text-red-600 text-xs shadow-sm ring-1 ring-neutral-200 \
|
||||
transition-colors hover:bg-red-50 hover:text-red-700"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id) { "Delete" }
|
||||
}
|
||||
@if actions.ncmec {
|
||||
button type="button"
|
||||
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
|
||||
shadow-sm ring-1 ring-neutral-200 transition-colors \
|
||||
hover:bg-neutral-100 disabled:cursor-not-allowed \
|
||||
disabled:opacity-70"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id)
|
||||
data-attachment-id=(att.id)
|
||||
data-filename=(att.filename)
|
||||
data-content-type=(att.content_type.as_deref().unwrap_or(""))
|
||||
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
|
||||
data-author-id=(msg.author_id)
|
||||
data-ncmec-status=(att.ncmec_status)
|
||||
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
|
||||
data-source-report-id=[actions.source_report_id]
|
||||
disabled[att.ncmec_status == "submitted"]
|
||||
title=(if att.ncmec_status == "submitted" {
|
||||
"Already reported to NCMEC"
|
||||
} else {
|
||||
"Report this image to NCMEC"
|
||||
}) {
|
||||
@if att.ncmec_status == "submitted" {
|
||||
"Reported to NCMEC"
|
||||
} @else {
|
||||
"Report to NCMEC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +154,34 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
||||
}
|
||||
}
|
||||
|
||||
fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
if msg.missing_attachments.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
div class="mt-1.5 space-y-1" {
|
||||
@for att in &msg.missing_attachments {
|
||||
div class="flex flex-wrap items-center gap-2 text-amber-800 text-xs"
|
||||
data-missing-attachment=(att.id) {
|
||||
(paperclip_icon("text-amber-500"))
|
||||
span class="break-words" {
|
||||
@if att.filename.is_empty() {
|
||||
"An attachment was not preserved in the report snapshot"
|
||||
} @else {
|
||||
(att.filename) " was not preserved in the report snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_row(
|
||||
base_path: &str,
|
||||
avatar_url: &str,
|
||||
msg: &Message,
|
||||
include_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
is_highlighted: bool,
|
||||
is_grouped: bool,
|
||||
) -> Markup {
|
||||
@@ -176,10 +213,14 @@ fn message_row(
|
||||
text-sm leading-snug" { (msg.content) }
|
||||
}
|
||||
@if !msg.attachments.is_empty() {
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_image_attachments(msg, actions))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@if actions.delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
class="delete-message-btn rounded bg-white px-2 py-0.5 \
|
||||
@@ -208,20 +249,39 @@ fn message_row(
|
||||
style="display:grid;grid-template-columns:16px 40px 16px minmax(0,1fr);"
|
||||
data-message-id=(msg.id) data-message-row="" {
|
||||
div style="grid-row:1;grid-column:1;" {}
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span title=(webhook_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
}
|
||||
div style="grid-row:1;grid-column:3;" {}
|
||||
div class="min-w-0" style="grid-column:4;" {
|
||||
div class="flex items-baseline gap-2" {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span class="font-medium text-neutral-900 text-sm"
|
||||
title=(webhook_id) data-message-webhook=(webhook_id) { (msg.author_username) }
|
||||
span class="self-center" { (badge("Webhook", BadgeVariant::Info)) }
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if msg.author_bot == Some(true) {
|
||||
span class="self-center" data-message-author-bot=(msg.author_id) {
|
||||
(badge("Bot", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
span class="text-neutral-400 text-xs" {
|
||||
" \u{2014} " (msg.timestamp)
|
||||
" \u{2014} " (format_admin_timestamp(&msg.timestamp))
|
||||
}
|
||||
(channel_nsfw_state_badge(
|
||||
msg.channel_nsfw.unwrap_or(false),
|
||||
@@ -236,14 +296,15 @@ fn message_row(
|
||||
text-sm leading-snug" { (msg.content) }
|
||||
}
|
||||
@if !msg.attachments.is_empty() {
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_image_attachments(msg, actions))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
@if actions.delete && !has_images {
|
||||
div class="absolute top-1 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
class="delete-message-btn rounded bg-white px-2 py-0.5 \
|
||||
@@ -261,20 +322,22 @@ pub fn message_list(
|
||||
config: &AdminConfig,
|
||||
base_path: &str,
|
||||
messages: &[Message],
|
||||
include_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@for (i, msg) in messages.iter().enumerate() {
|
||||
@let is_grouped = i > 0 && messages[i - 1].author_id == msg.author_id;
|
||||
@let is_grouped = i > 0
|
||||
&& messages[i - 1].author_id == msg.author_id
|
||||
&& messages[i - 1].author_username == msg.author_username;
|
||||
@let is_highlighted = highlight_message_id == Some(msg.id.as_str());
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
);
|
||||
(message_row(
|
||||
base_path, &avatar_url, msg,
|
||||
include_delete, is_highlighted, is_grouped,
|
||||
actions, is_highlighted, is_grouped,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -330,6 +393,7 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
fields.append('message_id', b.dataset.messageId || '');
|
||||
fields.append('attachment_id', b.dataset.attachmentId || '');
|
||||
fields.append('filename', b.dataset.filename || '');
|
||||
if (b.dataset.sourceReportId) fields.append('source_report_id', b.dataset.sourceReportId);
|
||||
fields.append('reporter_full_name', name.trim());
|
||||
fields.append('confirmed_viewed', 'true');
|
||||
b.disabled = true;
|
||||
|
||||
@@ -17,6 +17,8 @@ pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod stack;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
("harassment", "Harassment or bullying"),
|
||||
("hate_speech", "Hate speech"),
|
||||
("spam", "Spam"),
|
||||
("illegal_activity", "Illegal activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child safety concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or graphic content"),
|
||||
("nsfw_violation", "NSFW policy violation"),
|
||||
("doxxing", "Sharing personal information"),
|
||||
("self_harm", "Self-harm or suicide"),
|
||||
("malicious_links", "Malicious links"),
|
||||
("spam_account", "Spam account"),
|
||||
("underage_user", "Underage user"),
|
||||
("inappropriate_profile", "Inappropriate profile"),
|
||||
("raid_coordination", "Raid coordination"),
|
||||
("malware_distribution", "Malware distribution"),
|
||||
("extremist_community", "Extremist community"),
|
||||
];
|
||||
|
||||
pub fn report_category_label(category: &str) -> &str {
|
||||
REPORT_CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(key, label)| (*key == category).then_some(*label))
|
||||
.unwrap_or(category)
|
||||
}
|
||||
|
||||
pub fn reason_repeats_category(category: &str, reason_label: &str) -> bool {
|
||||
report_category_label(category).trim().to_lowercase() == reason_label.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn report_category_options(selected: &str) -> Vec<(&str, &str)> {
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(REPORT_CATEGORIES.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
pub fn report_category(category: &str) -> Markup {
|
||||
html! {
|
||||
span data-report-category=(category) title=(category) {
|
||||
(report_category_label(category))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::media::user_avatar_url;
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
|
||||
pub struct ReportedWebhook<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: String,
|
||||
pub configured_name: Option<&'a str>,
|
||||
pub configured_avatar_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub reports_href: String,
|
||||
pub kind: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub channel_id: Option<&'a str>,
|
||||
pub guild_id: Option<&'a str>,
|
||||
pub application: Option<WebhookApplication<'a>>,
|
||||
pub record_deleted: bool,
|
||||
pub creator: Option<WebhookCreator<'a>>,
|
||||
}
|
||||
|
||||
pub struct WebhookApplication<'a> {
|
||||
pub id: &'a str,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
pub struct WebhookCreator<'a> {
|
||||
pub id: &'a str,
|
||||
pub label: String,
|
||||
pub avatar_url: String,
|
||||
pub href: String,
|
||||
pub account_deleted: bool,
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
pub fn webhook_type_label(webhook_type: i32) -> String {
|
||||
match webhook_type {
|
||||
1 => "Incoming".to_owned(),
|
||||
2 => "Channel Follower".to_owned(),
|
||||
other => format!("Type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn reported_webhook<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<ReportedWebhook<'a>> {
|
||||
let id = report.reported_webhook_id.as_deref()?;
|
||||
let name = non_blank(report.reported_webhook_name.as_deref())
|
||||
.map_or_else(|| format!("Webhook {id}"), str::to_owned);
|
||||
let configured_name = non_blank(report.reported_webhook_default_name.as_deref())
|
||||
.filter(|configured| *configured != name);
|
||||
let channel_id = non_blank(report.reported_webhook_channel_id.as_deref());
|
||||
let guild_id = non_blank(report.reported_webhook_guild_id.as_deref());
|
||||
let record_present = report.reported_webhook_type.is_some()
|
||||
|| channel_id.is_some()
|
||||
|| guild_id.is_some()
|
||||
|| non_blank(report.reported_webhook_default_name.as_deref()).is_some();
|
||||
let configured_avatar_hash = non_blank(report.reported_webhook_default_avatar_hash.as_deref());
|
||||
let configured_avatar_url = (record_present
|
||||
&& configured_avatar_hash != non_blank(report.reported_webhook_avatar_hash.as_deref()))
|
||||
.then(|| user_avatar_url(config, id, configured_avatar_hash, 80, true));
|
||||
let created_at = non_blank(report.reported_webhook_created_at.as_deref());
|
||||
Some(ReportedWebhook {
|
||||
id,
|
||||
name,
|
||||
configured_name,
|
||||
configured_avatar_url,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
reports_href: format!(
|
||||
"{}/reports?reported_webhook_id={}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
kind: report.reported_webhook_type.map(webhook_type_label),
|
||||
created_at: created_at.map(format_admin_timestamp),
|
||||
channel_id,
|
||||
guild_id,
|
||||
application: non_blank(report.reported_webhook_application_id.as_deref()).map(|id| {
|
||||
WebhookApplication {
|
||||
id,
|
||||
href: format!(
|
||||
"{}/applications/{}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
}
|
||||
}),
|
||||
record_deleted: created_at.is_some() && !record_present,
|
||||
creator: webhook_creator(config, report),
|
||||
})
|
||||
}
|
||||
|
||||
fn webhook_creator<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<WebhookCreator<'a>> {
|
||||
let id = report.reported_webhook_creator_id.as_deref()?;
|
||||
let username = non_blank(report.reported_webhook_creator_username.as_deref());
|
||||
let bot = report.reported_webhook_application_id.as_deref() == Some(id);
|
||||
let tag = match username {
|
||||
Some(username) => Some(user_tag(
|
||||
username,
|
||||
report
|
||||
.reported_webhook_creator_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000"),
|
||||
bot,
|
||||
)),
|
||||
None => non_blank(report.reported_webhook_creator_tag.as_deref()).map(str::to_owned),
|
||||
};
|
||||
let label = match (
|
||||
non_blank(report.reported_webhook_creator_global_name.as_deref()),
|
||||
tag,
|
||||
) {
|
||||
(Some(display), Some(tag)) => format!("{display} ({tag})"),
|
||||
(_, Some(tag)) => tag,
|
||||
(Some(display), None) => display.to_owned(),
|
||||
(None, None) => format!("User {id}"),
|
||||
};
|
||||
Some(WebhookCreator {
|
||||
id,
|
||||
label,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_creator_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
href: format!("{}/users/{}", config.base_path, urlencoding::encode(id)),
|
||||
account_deleted: username.is_none()
|
||||
&& non_blank(report.reported_webhook_creator_tag.as_deref()).is_none()
|
||||
&& non_blank(report.reported_webhook_creator_global_name.as_deref()).is_none(),
|
||||
bot,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn webhook_identity(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
span class="inline-flex min-w-0 items-center gap-2" data-report-webhook=(webhook.id) {
|
||||
img src=(webhook.avatar_url) alt=(format!("{}'s avatar", webhook.name))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" {
|
||||
span class="font-medium text-neutral-900 text-sm break-words" { (webhook.name) }
|
||||
(badge("Webhook", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_link(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="inline-flex min-w-0 items-center gap-2 text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
img src=(creator.avatar_url) alt=(format!("{}'s avatar", creator.label))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="break-words" { (creator.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_badges(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
@if creator.bot {
|
||||
span class="inline-flex" data-report-webhook-creator-bot { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
@if creator.account_deleted {
|
||||
span class="inline-flex" data-report-webhook-creator-deleted { (badge("Account Deleted", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -489,10 +489,11 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
element = element.firstElementChild;
|
||||
depth += 1;
|
||||
}
|
||||
var level = status >= 400 ? 'error' : 'success';
|
||||
var level = 'success';
|
||||
if (className.indexOf('red-') >= 0) level = 'error';
|
||||
if (className.indexOf('green-') >= 0) level = 'success';
|
||||
if (className.indexOf('blue-') >= 0) level = 'info';
|
||||
if (status >= 400) level = 'error';
|
||||
return {
|
||||
level: level,
|
||||
message: text || (level === 'error' ? 'Action failed.' : 'Done.')
|
||||
@@ -514,6 +515,20 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAfterSuccess(detail, level) {
|
||||
if (level !== 'success') return;
|
||||
var elt = (detail && detail.requestConfig && detail.requestConfig.elt) || mutationElement(detail);
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
var selector = form ? form.getAttribute('data-admin-refresh-on-success') : '';
|
||||
if (!selector || !document.querySelector(selector)) return;
|
||||
if (!window.htmx || typeof window.htmx.ajax !== 'function') return;
|
||||
window.htmx.ajax('GET', window.location.pathname + window.location.search, {
|
||||
target: selector,
|
||||
swap: 'outerHTML',
|
||||
select: selector
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('showFlash', function (evt) {
|
||||
var d = evt.detail || {};
|
||||
showToast(d.level || 'info', d.message || '');
|
||||
@@ -533,11 +548,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
var xhr = event.detail.xhr;
|
||||
var parsed = isLoginResponse(xhr)
|
||||
? {level: 'error', message: 'Session expired. Sign in again.'}
|
||||
: parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
: parseAdminToastHeader(xhr) || parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
event.detail.shouldSwap = false;
|
||||
event.detail.isError = false;
|
||||
markToastHandled(event.detail);
|
||||
showToast(parsed.level, parsed.message);
|
||||
refreshAfterSuccess(event.detail, parsed.level);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function (event) {
|
||||
@@ -548,6 +564,7 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
if (serverToast) {
|
||||
showToast(serverToast.level, serverToast.message);
|
||||
markToastHandled(event.detail);
|
||||
refreshAfterSuccess(event.detail, serverToast.level);
|
||||
return;
|
||||
}
|
||||
var failed = !event.detail.successful || (xhr && xhr.status >= 400) || isLoginResponse(xhr);
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::user_tag::user_tag,
|
||||
templates::{
|
||||
components::{page_container::card_with_header, table::data_table},
|
||||
pages::user_detail_tabs::reports::{
|
||||
format_status, reported_at_cell, reporter_label, type_and_category_cell,
|
||||
},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -40,8 +44,7 @@ pub fn reports_tab(
|
||||
}))
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"],
|
||||
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
|
||||
html! {
|
||||
@for report in reports {
|
||||
(report_row(base, report))
|
||||
@@ -74,75 +77,19 @@ pub fn reports_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
let reporter_display = format_reporter(report);
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
|
||||
@if let Some(ref rid) = report.reporter_id {
|
||||
a href={(base) "/users/" (rid)}
|
||||
class="hover:underline" {
|
||||
(reporter_display)
|
||||
(reporter_label(report))
|
||||
}
|
||||
} @else {
|
||||
span { (reporter_display) }
|
||||
span { (reporter_label(report)) }
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
@@ -159,3 +106,46 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert_eq!(
|
||||
markup.matches("whitespace-nowrap px-4").count(),
|
||||
2,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 1, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub fn guilds_list_page(
|
||||
} @else if let Some(count) = result_state.total {
|
||||
(page_header_with_actions("Guilds", None, html! {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
"Found " (count) " results (showing "
|
||||
"Found " (count) (if count == 1 { " result" } else { " results" }) " (showing "
|
||||
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
|
||||
")"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
submit_button,
|
||||
},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::{MessageActions, message_deletion_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -193,6 +193,7 @@ pub struct MessagesPageParams<'a> {
|
||||
pub prefill_channel_id: Option<&'a str>,
|
||||
pub can_lookup: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_report_ncmec: bool,
|
||||
pub lookup_result: Option<&'a Value>,
|
||||
pub browse_result: Option<&'a Value>,
|
||||
pub search_result: Option<&'a Value>,
|
||||
@@ -207,6 +208,11 @@ pub fn messages_page(
|
||||
auth: &AuthContext,
|
||||
params: &MessagesPageParams<'_>,
|
||||
) -> Markup {
|
||||
let actions = MessageActions {
|
||||
delete: params.can_delete,
|
||||
ncmec: params.can_report_ncmec,
|
||||
source_report_id: None,
|
||||
};
|
||||
let content = html! {
|
||||
(page_header("Message Tools", None))
|
||||
|
||||
@@ -217,19 +223,19 @@ pub fn messages_page(
|
||||
@if let (Some(result), Some(channel_id), Some(query)) = (
|
||||
params.search_result, params.browse_channel_id, params.search_query_text,
|
||||
) {
|
||||
(search_result_card(config, result, channel_id, query, params.can_delete))
|
||||
(search_result_card(config, result, channel_id, query, actions))
|
||||
}
|
||||
@if params.search_result.is_none()
|
||||
&& let (Some(result), Some(channel_id)) = (
|
||||
params.browse_result, params.browse_channel_id,
|
||||
) {
|
||||
(browse_result_card(
|
||||
config, result, channel_id, params.can_delete,
|
||||
config, result, channel_id, actions,
|
||||
params.csrf_token, params.context_limit,
|
||||
))
|
||||
}
|
||||
@if let Some(result) = params.lookup_result {
|
||||
(lookup_result_card(config, result, params.can_delete, params.context_limit))
|
||||
(lookup_result_card(config, result, actions, params.context_limit))
|
||||
}
|
||||
@if params.can_lookup {
|
||||
(browse_channel_form(config, params.csrf_token, params.prefill_channel_id))
|
||||
@@ -260,7 +266,7 @@ pub fn messages_page(
|
||||
pub fn browse_messages_fragment(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -280,7 +286,7 @@ pub fn browse_messages_fragment(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions,
|
||||
highlight_message_id,
|
||||
))
|
||||
}
|
||||
@@ -291,7 +297,7 @@ fn browse_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
@@ -328,7 +334,7 @@ fn browse_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more,
|
||||
has_newer: false,
|
||||
focus_message_id: None,
|
||||
@@ -343,7 +349,7 @@ fn search_result_card(
|
||||
result: &Value,
|
||||
channel_id: &str,
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
@@ -369,7 +375,7 @@ fn search_result_card(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions,
|
||||
None,
|
||||
))
|
||||
}
|
||||
@@ -379,7 +385,7 @@ fn search_result_card(
|
||||
fn lookup_result_card(
|
||||
config: &AdminConfig,
|
||||
result: &Value,
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'_>,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = response_messages(result);
|
||||
@@ -401,7 +407,7 @@ fn lookup_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more: true,
|
||||
has_newer: true,
|
||||
focus_message_id: message_id,
|
||||
@@ -415,7 +421,7 @@ struct MessageScrollPane<'a> {
|
||||
config: &'a AdminConfig,
|
||||
channel_id: &'a str,
|
||||
messages: &'a [Message],
|
||||
show_delete: bool,
|
||||
actions: MessageActions<'a>,
|
||||
has_more: bool,
|
||||
has_newer: bool,
|
||||
focus_message_id: Option<&'a str>,
|
||||
@@ -454,7 +460,7 @@ fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
|
||||
pane.config,
|
||||
&pane.config.base_path,
|
||||
pane.messages,
|
||||
pane.show_delete,
|
||||
pane.actions,
|
||||
pane.focus_message_id,
|
||||
))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{ReportEntry, SearchReportsResponse},
|
||||
api::types::{ReportEntry, ReportReasonEntry, SearchReportsResponse},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
@@ -11,11 +11,16 @@ use crate::{
|
||||
form::{secondary_button_link, select_input, submit_button, text_input},
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::page_header_with_actions,
|
||||
table::{data_table, empty_state},
|
||||
report_category::{reason_repeats_category, report_category, report_category_options},
|
||||
report_webhook::{
|
||||
ReportedWebhook, reported_webhook, webhook_creator_badges, webhook_identity,
|
||||
},
|
||||
table::{empty_state, table, table_body, table_container, table_head},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::user_detail_tabs::reports::tagged_user,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
@@ -24,8 +29,10 @@ pub struct ReportFilters<'a> {
|
||||
pub status: Option<&'a str>,
|
||||
pub report_type: Option<&'a str>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
@@ -38,6 +45,7 @@ pub fn reports_list_page(
|
||||
auth: &AuthContext,
|
||||
result: Option<&SearchReportsResponse>,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
page: u32,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
@@ -48,7 +56,7 @@ pub fn reports_list_page(
|
||||
None,
|
||||
report_count_summary(result),
|
||||
))
|
||||
(filters_card(config, filters, limit))
|
||||
(filters_card(config, filters, reasons, limit))
|
||||
@if let Some(result) = result {
|
||||
@if result.reports.is_empty() {
|
||||
(empty_state("No reports found."))
|
||||
@@ -72,14 +80,25 @@ fn report_count_summary(result: Option<&SearchReportsResponse>) -> Markup {
|
||||
html! {
|
||||
@if let Some(result) = result {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Found " (result.total) " results (showing " (result.reports.len()) ")"
|
||||
"Found " (result.total) (if result.total == 1 { " result" } else { " results" })
|
||||
" (showing " (result.reports.len()) ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -> Markup {
|
||||
fn filters_card(
|
||||
config: &AdminConfig,
|
||||
filters: &ReportFilters<'_>,
|
||||
reasons: Option<&[ReportReasonEntry]>,
|
||||
limit: u32,
|
||||
) -> Markup {
|
||||
let limit_value = limit.to_string();
|
||||
let select_grid_class = if reasons.is_some() {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
} else {
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"
|
||||
};
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-6 transition-all" {
|
||||
form method="get" {
|
||||
@@ -90,7 +109,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
filters.query.unwrap_or(""),
|
||||
"Search by ID, reporter, category, or description...",
|
||||
))
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5" {
|
||||
div class=(select_grid_class) {
|
||||
(select_input("status", "Status", &[
|
||||
("", "All"),
|
||||
("0", "Pending"),
|
||||
@@ -102,7 +121,12 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
("1", "User"),
|
||||
("2", "Guild"),
|
||||
], filters.report_type.unwrap_or("")))
|
||||
(select_input("category", "Category", report_category_options(), filters.category.unwrap_or("")))
|
||||
(select_input("category", "Category", &report_category_options(filters.category.unwrap_or("")), filters.category.unwrap_or("")))
|
||||
@if let Some(reasons) = reasons {
|
||||
div class="sm:col-span-2 lg:col-span-2" {
|
||||
(reason_select(reasons, filters.reason.unwrap_or("")))
|
||||
}
|
||||
}
|
||||
(select_input("sort", "Sort", &[
|
||||
("reportedAt_desc", "Reported (newest first)"),
|
||||
("reportedAt_asc", "Reported (oldest first)"),
|
||||
@@ -121,6 +145,7 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
|
||||
(text_input("reporter_id", "Reporter user ID", filters.reporter_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_user_id", "Reported user ID", filters.reported_user_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_webhook_id", "Reported webhook ID", filters.reported_webhook_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_guild_id", "Reported guild ID", filters.reported_guild_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("reported_channel_id", "Reported channel ID", filters.reported_channel_id.unwrap_or(""), "Snowflake"))
|
||||
(text_input("guild_context_id", "Guild context ID", filters.guild_context_id.unwrap_or(""), "Snowflake"))
|
||||
@@ -136,28 +161,29 @@ fn filters_card(config: &AdminConfig, filters: &ReportFilters<'_>, limit: u32) -
|
||||
}
|
||||
}
|
||||
|
||||
fn report_category_options() -> &'static [(&'static str, &'static str)] {
|
||||
&[
|
||||
("", "All"),
|
||||
("harassment", "Harassment or Bullying"),
|
||||
("hate_speech", "Hate Speech"),
|
||||
("spam", "Spam or Scam"),
|
||||
("illegal_activity", "Illegal Activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child Safety Concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or Graphic Content"),
|
||||
("nsfw_violation", "NSFW Policy Violation"),
|
||||
("doxxing", "Sharing Personal Information"),
|
||||
("self_harm", "Self-Harm or Suicide"),
|
||||
("malicious_links", "Malicious Links"),
|
||||
("spam_account", "Spam Account"),
|
||||
("underage_user", "Underage User"),
|
||||
("inappropriate_profile", "Inappropriate Profile"),
|
||||
("raid_coordination", "Raid Coordination"),
|
||||
("malware_distribution", "Malware Distribution"),
|
||||
("extremist_community", "Extremist Community"),
|
||||
]
|
||||
fn reason_select(reasons: &[ReportReasonEntry], selected: &str) -> Markup {
|
||||
let labels = reasons
|
||||
.iter()
|
||||
.map(|reason| {
|
||||
if reason.highest_priority {
|
||||
format!("Priority: {}", reason.label)
|
||||
} else {
|
||||
reason.label.clone()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(
|
||||
reasons
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|(reason, label)| (reason.key.as_str(), label.as_str())),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
select_input("reason", "Reason", &options, selected)
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> (&'static str, BadgeVariant) {
|
||||
@@ -177,36 +203,21 @@ fn format_report_type(report_type: i32) -> (&'static str, BadgeVariant) {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_category(category: Option<&str>) -> String {
|
||||
let Some(category) = category else {
|
||||
return "\u{2014}".to_owned();
|
||||
};
|
||||
report_category_options()
|
||||
.iter()
|
||||
.find_map(|(value, label)| (*value == category).then_some((*label).to_owned()))
|
||||
.unwrap_or_else(|| category.to_owned())
|
||||
}
|
||||
|
||||
fn reporter_label(report: &ReportEntry) -> String {
|
||||
fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(username) = &report.reporter_username {
|
||||
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, discriminator, false);
|
||||
if let Some(display) = report
|
||||
.reporter_global_name
|
||||
.as_ref()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
{
|
||||
return format!("{display} ({tag})");
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(tag) = &report.reporter_tag {
|
||||
return tag.to_owned();
|
||||
html! {
|
||||
(report
|
||||
.reporter_tag
|
||||
.as_deref()
|
||||
.or(report.reporter_email.as_deref())
|
||||
.unwrap_or("Anonymous"))
|
||||
}
|
||||
if let Some(email) = &report.reporter_email {
|
||||
return email.to_owned();
|
||||
}
|
||||
"Anonymous".to_owned()
|
||||
}
|
||||
|
||||
fn reported_user_label(report: &ReportEntry) -> String {
|
||||
@@ -277,14 +288,30 @@ fn reported_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_name(report: &ReportEntry) -> Markup {
|
||||
match &report.reported_user_username {
|
||||
Some(username) => tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
),
|
||||
None => html! { (reported_user_label(report)) },
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let primary = reported_user_label(report);
|
||||
html! {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (primary) }
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &primary))
|
||||
span class="font-medium text-sm" { (reported_user_name(report)) }
|
||||
}
|
||||
@if report.reported_user_bot == Some(true) {
|
||||
span class="inline-flex" data-report-user-bot=(id) { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (primary) }
|
||||
@@ -328,7 +355,7 @@ fn reported_guild_icon(
|
||||
true,
|
||||
) {
|
||||
Some(url) => html! {
|
||||
img src=(url) alt="" class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
img src=(url) alt="" class="h-8 w-8 max-w-none flex-shrink-0 rounded-full object-cover";
|
||||
},
|
||||
None => html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
@@ -340,26 +367,28 @@ fn reported_guild_icon(
|
||||
|
||||
fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let user_label = reported_user_label(report);
|
||||
let channel_label = report
|
||||
.reported_channel_name
|
||||
.as_deref()
|
||||
.or(report.reported_channel_id.as_deref())
|
||||
.unwrap_or("Unknown channel");
|
||||
let channel_prefix =
|
||||
if report.reported_guild_id.is_none() && report.reported_channel_id.is_some() {
|
||||
"DM channel: "
|
||||
} else {
|
||||
"Channel: "
|
||||
};
|
||||
html! {
|
||||
div class="flex flex-col gap-1" {
|
||||
@if let Some(id) = &report.reported_user_id {
|
||||
a href={(base) "/users/" (id)} class="flex items-center gap-2 text-blue-600 hover:underline" {
|
||||
(reported_user_avatar(config, report, id, &user_label))
|
||||
span class="font-medium text-sm" { (user_label) }
|
||||
}
|
||||
@if let Some(webhook) = reported_webhook(config, report) {
|
||||
(reported_webhook_author(&webhook))
|
||||
} @else {
|
||||
span class="text-neutral-900 text-sm" { (user_label) }
|
||||
(reported_user_cell(config, report))
|
||||
}
|
||||
@if let Some(channel_id) = &report.reported_channel_id {
|
||||
a href=(message_lookup_href(base, channel_id, report.reported_message_id.as_deref()))
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Channel: " (channel_label)
|
||||
(channel_prefix) (channel_label)
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500 text-xs" { "Channel: " (channel_label) }
|
||||
@@ -371,6 +400,27 @@ fn reported_message_cell(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_webhook_author(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col items-start gap-0.5" {
|
||||
(webhook_identity(webhook))
|
||||
a href=(webhook.reports_href) title="Reports about this webhook"
|
||||
class="font-mono text-blue-600 text-xs hover:underline" {
|
||||
"Webhook ID: " (webhook.id)
|
||||
}
|
||||
@if let Some(ref creator) = webhook.creator {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="text-blue-600 text-xs hover:underline" {
|
||||
"Webhook creator: " (creator.label)
|
||||
}
|
||||
@if creator.bot || creator.account_deleted {
|
||||
span class="inline-flex flex-wrap items-center gap-1" { (webhook_creator_badges(creator)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reported_user_avatar(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
@@ -385,7 +435,7 @@ fn reported_user_avatar(
|
||||
true,
|
||||
);
|
||||
html! {
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 rounded-full object-cover";
|
||||
img src=(url) alt=(format!("{label}'s avatar")) class="h-8 w-8 max-w-none rounded-full object-cover";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,31 +452,53 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
let rows = html! {
|
||||
@for report in reports {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-neutral-600 text-sm" {
|
||||
(report.reported_at)
|
||||
td class="whitespace-nowrap px-3 py-3 text-neutral-600 text-sm" {
|
||||
(format_admin_timestamp(&report.reported_at))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
@let (type_label, type_variant) = format_report_type(report.report_type);
|
||||
(badge(type_label, type_variant))
|
||||
span class="text-neutral-600 text-xs" {
|
||||
(format_category(report.category.as_deref()))
|
||||
@let reason_label = report.reason.as_deref().map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
@let repeats = report.category.as_deref().zip(reason_label).is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
@let priority = report.reason_highest_priority == Some(true);
|
||||
@let category_class = if repeats && priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-600 text-xs"
|
||||
};
|
||||
span class=(category_class) data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
@if let Some(category) = &report.category {
|
||||
(report_category(category))
|
||||
} @else {
|
||||
"\u{2014}"
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
@let reason_class = if priority {
|
||||
"font-medium text-red-700 text-xs"
|
||||
} else {
|
||||
"text-neutral-500 text-xs"
|
||||
};
|
||||
span class=(reason_class) data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
(reporter_cell(config, report))
|
||||
}
|
||||
td class="px-4 py-3 text-sm" {
|
||||
td class="px-3 py-3 text-sm" {
|
||||
(reported_cell(config, report))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-3 py-3 text-sm" {
|
||||
@let (label, variant) = format_status(report.status);
|
||||
span data-status-pill=(report.report_id) {
|
||||
(badge(label, variant))
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-3 py-3 text-sm" {
|
||||
div class="flex flex-col items-start gap-1" {
|
||||
button type="button"
|
||||
data-drawer-open="report-peek"
|
||||
@@ -455,7 +527,18 @@ fn render_reports_table(config: &AdminConfig, reports: &[ReportEntry]) -> Markup
|
||||
};
|
||||
html! {
|
||||
div data-report-table="true" {
|
||||
(data_table(headers, rows))
|
||||
(table_container(table(html! {
|
||||
(table_head(html! {
|
||||
tr {
|
||||
@for header in headers {
|
||||
th class="whitespace-nowrap px-3 py-3 text-left text-neutral-600 text-xs uppercase tracking-wider" {
|
||||
(header)
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
(table_body(rows))
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,8 +591,14 @@ fn reports_url(config: &AdminConfig, filters: &ReportFilters<'_>, page: u32, lim
|
||||
push_param(&mut params, "status", filters.status);
|
||||
push_param(&mut params, "type", filters.report_type);
|
||||
push_param(&mut params, "category", filters.category);
|
||||
push_param(&mut params, "reason", filters.reason);
|
||||
push_param(&mut params, "reporter_id", filters.reporter_id);
|
||||
push_param(&mut params, "reported_user_id", filters.reported_user_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
"reported_webhook_id",
|
||||
filters.reported_webhook_id,
|
||||
);
|
||||
push_param(&mut params, "reported_guild_id", filters.reported_guild_id);
|
||||
push_param(
|
||||
&mut params,
|
||||
|
||||
@@ -6,9 +6,11 @@ use crate::{
|
||||
templates::components::{
|
||||
media::{guild_icon_url, initials, user_avatar_url},
|
||||
page_container::card_with_header,
|
||||
report_category::{reason_repeats_category, report_category},
|
||||
report_webhook::{reported_webhook, webhook_identity},
|
||||
table::data_table,
|
||||
},
|
||||
utils::user_tag::user_tag,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -102,11 +104,9 @@ fn report_section(
|
||||
p class="text-sm text-neutral-500" { (empty_msg) }
|
||||
} @else {
|
||||
@let headers = if kind == "sent" {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reported Entity", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reported Entity", "Status"]
|
||||
} else {
|
||||
vec!["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"]
|
||||
vec!["Reported At", "Type / Category", "Reporter", "Status"]
|
||||
};
|
||||
(data_table(
|
||||
&headers,
|
||||
@@ -152,7 +152,7 @@ fn format_report_type(report_type: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
pub(crate) fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
@@ -160,36 +160,86 @@ fn format_status(status: i32) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
pub(crate) fn tagged_user(
|
||||
global_name: Option<&str>,
|
||||
username: &str,
|
||||
discriminator: Option<&str>,
|
||||
) -> Markup {
|
||||
let tag = user_tag(username, discriminator.unwrap_or("0000"), false);
|
||||
html! {
|
||||
@if let Some(name) = global_name.map(str::trim).filter(|name| !name.is_empty()) {
|
||||
(name) " "
|
||||
span class="whitespace-nowrap text-neutral-500 text-xs" { "(" (tag) ")" }
|
||||
} @else {
|
||||
span class="whitespace-nowrap" { (tag) }
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reporter_label(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
return tagged_user(
|
||||
report.reporter_global_name.as_deref(),
|
||||
username,
|
||||
report.reporter_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
html! {
|
||||
(report
|
||||
.reporter_email
|
||||
.as_deref()
|
||||
.or(report.reporter_id.as_deref())
|
||||
.unwrap_or("Unknown reporter"))
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
}
|
||||
|
||||
pub(crate) fn reported_at_cell(base: &str, report: &ReportEntry) -> Markup {
|
||||
html! {
|
||||
td class="px-3 py-3 text-sm text-neutral-900" {
|
||||
div { (format_admin_timestamp(&report.reported_at)) }
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="whitespace-nowrap font-mono text-blue-600 text-xs tracking-tight hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn type_and_category_cell(report: &ReportEntry) -> Markup {
|
||||
let reason_label = report
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
.map(|reason| report.reason_label.as_deref().unwrap_or(reason));
|
||||
let repeats = report
|
||||
.category
|
||||
.as_deref()
|
||||
.zip(reason_label)
|
||||
.is_some_and(|(category, label)| reason_repeats_category(category, label));
|
||||
html! {
|
||||
td class="px-4 py-3 text-sm text-neutral-900" {
|
||||
div class="text-neutral-500 text-xs" { (format_report_type(report.report_type)) }
|
||||
@if let Some(category) = &report.category {
|
||||
div class="break-words" data-report-reason=[report.reason.as_deref().filter(|_| repeats)] {
|
||||
(report_category(category))
|
||||
}
|
||||
}
|
||||
@if !repeats && let (Some(reason), Some(label)) = (&report.reason, reason_label) {
|
||||
div class="break-words text-neutral-500 text-xs" data-report-reason=(reason) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry) -> Markup {
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
let webhook = if kind == "sent" {
|
||||
reported_webhook(config, report)
|
||||
} else {
|
||||
format_reporter(report)
|
||||
None
|
||||
};
|
||||
|
||||
let entity_href = if kind == "sent" {
|
||||
@@ -210,31 +260,28 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
.map(|id| format!("{base}/users/{id}"))
|
||||
};
|
||||
|
||||
let entity_display = if kind == "sent" {
|
||||
format_reported_entity(report)
|
||||
} else {
|
||||
reporter_label(report)
|
||||
};
|
||||
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
@if let Some(href) = entity_href {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-4 py-3 text-sm [overflow-wrap:anywhere]" {
|
||||
@if let Some(webhook) = &webhook {
|
||||
a href=(webhook.reports_href) class="hover:underline" {
|
||||
(webhook_identity(webhook))
|
||||
}
|
||||
} @else if let Some(href) = entity_href {
|
||||
a href=(href)
|
||||
class="inline-flex items-center gap-2 hover:underline" {
|
||||
@if kind == "sent" {
|
||||
(reported_entity_icon(config, report, &entity_display))
|
||||
(reported_entity_icon(config, report))
|
||||
}
|
||||
span { (entity_display) }
|
||||
span class="min-w-0" { (entity_display) }
|
||||
}
|
||||
} @else {
|
||||
span { (entity_display) }
|
||||
@@ -243,48 +290,32 @@ fn report_row(config: &AdminConfig, base: &str, kind: &str, report: &ReportEntry
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_status(report.status))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border \
|
||||
border-neutral-300 bg-white px-3 py-1.5 text-sm \
|
||||
font-medium text-neutral-700 hover:bg-neutral-50" {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reported_entity(report: &ReportEntry) -> String {
|
||||
fn format_reported_entity(report: &ReportEntry) -> Markup {
|
||||
if let Some(ref username) = report.reported_user_username {
|
||||
let disc = report
|
||||
.reported_user_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reported_user_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
return tagged_user(
|
||||
report.reported_user_global_name.as_deref(),
|
||||
username,
|
||||
report.reported_user_discriminator.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Some(ref tag) = report.reported_user_tag {
|
||||
return tag.clone();
|
||||
return html! { span class="whitespace-nowrap" { (tag) } };
|
||||
}
|
||||
if let Some(ref id) = report.reported_user_id {
|
||||
return id.clone();
|
||||
html! {
|
||||
(report
|
||||
.reported_user_id
|
||||
.as_ref()
|
||||
.or(report.reported_guild_name.as_ref())
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.map_or("Unknown", String::as_str))
|
||||
}
|
||||
report
|
||||
.reported_guild_name
|
||||
.as_ref()
|
||||
.or(report.reported_guild_id.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Unknown".to_owned())
|
||||
}
|
||||
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str) -> Markup {
|
||||
fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
if let Some(ref user_id) = report.reported_user_id {
|
||||
return html! {
|
||||
img
|
||||
@@ -307,9 +338,161 @@ fn reported_entity_icon(config: &AdminConfig, report: &ReportEntry, label: &str)
|
||||
}
|
||||
return html! {
|
||||
span class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-600 text-xs" {
|
||||
(initials(label))
|
||||
(initials(report.reported_guild_name.as_deref().unwrap_or(guild_id)))
|
||||
}
|
||||
};
|
||||
}
|
||||
html! {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ProxyConfig, RuntimeEnv};
|
||||
use serde_json::json;
|
||||
|
||||
fn test_config() -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: String::new(),
|
||||
port: 0,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: "/admin".to_owned(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_webhook_reports_name_the_webhook_instead_of_the_guild() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000004",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reported_user_id": null,
|
||||
"reported_webhook_id": "1700000000000000500",
|
||||
"reported_webhook_name": "Harbor Bulletin",
|
||||
"reported_guild_id": "1700000000000000800",
|
||||
"reported_guild_name": "Harbor"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let config = test_config();
|
||||
let sent = report_row(&config, "/admin", "sent", &report).into_string();
|
||||
assert!(
|
||||
sent.contains(r#"href="/admin/reports?reported_webhook_id=1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(
|
||||
sent.contains(r#"data-report-webhook="1700000000000000500""#),
|
||||
"{sent}"
|
||||
);
|
||||
assert!(sent.contains("Harbor Bulletin"), "{sent}");
|
||||
assert!(!sent.contains("/admin/guilds/"), "{sent}");
|
||||
let received = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert!(!received.contains("data-report-webhook"), "{received}");
|
||||
assert!(
|
||||
received.contains(r#"href="/admin/users/1500000000000000000""#),
|
||||
"{received}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_label_the_category_and_keep_the_key() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000005",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 1,
|
||||
"category": "inappropriate_profile",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
for kind in ["sent", "received"] {
|
||||
let markup = report_row(&config, "/admin", kind, &report).into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="inappropriate_profile""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Inappropriate profile<"), "{markup}");
|
||||
assert!(!markup.contains(">inappropriate_profile<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"data-report-reason="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Oct 4, 2026, 10:00 AM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-04T10:00:00.000Z"), "{markup}");
|
||||
assert_eq!(
|
||||
markup
|
||||
.matches(r#"href="/admin/reports/1800000000000000005""#)
|
||||
.count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(!markup.contains(">View<"), "{markup}");
|
||||
}
|
||||
let mut unknown = report.clone();
|
||||
unknown.category = Some("future_value".to_owned());
|
||||
let markup = report_row(&config, "/admin", "sent", &unknown).into_string();
|
||||
assert!(markup.contains(">future_value<"), "{markup}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rows_skip_a_reason_that_repeats_the_category() {
|
||||
let config = test_config();
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000007",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-04T10:00:00.000Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "harassment",
|
||||
"reported_user_id": "1500000000000000001",
|
||||
"reason": "harassment",
|
||||
"reason_label": "Harassment or bullying"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row(&config, "/admin", "received", &report).into_string();
|
||||
assert_eq!(
|
||||
markup.matches("Harassment or bullying").count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup.matches(r#"data-report-reason="harassment""#).count(),
|
||||
1,
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="harassment""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Message<"), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"Avery Reporter <span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user