mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(enforcement): record decisions and send notices (#3492)
This commit is contained in:
1 parent
da1842ecab
commit
f5c30fdf8f
383 files changed
+29692
-4139
No files matched your search
Generated
+1502
-209
File diff suppressed because it is too large.
Load diff
@@ -45,6 +45,8 @@ pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
|
||||
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
|
||||
pub const DISCOVERY_REVIEW: &str = "discovery:review";
|
||||
pub const ENFORCEMENT_ANNOTATE: &str = "enforcement:annotate";
|
||||
pub const ENFORCEMENT_VIEW: &str = "enforcement:view";
|
||||
pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats";
|
||||
pub const GATEWAY_RELOAD_ALL: &str = "gateway:reload_all";
|
||||
pub const GIFT_CODES_GENERATE: &str = "gift_codes:generate";
|
||||
@@ -153,6 +155,8 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
DISCOVERY_REMOVE,
|
||||
DISCOVERY_REVIEW,
|
||||
ENFORCEMENT_ANNOTATE,
|
||||
ENFORCEMENT_VIEW,
|
||||
GATEWAY_MEMORY_STATS,
|
||||
GATEWAY_RELOAD_ALL,
|
||||
GIFT_CODES_GENERATE,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types::EnforcementKindType;
|
||||
|
||||
pub struct U64Flag {
|
||||
pub name: &'static str,
|
||||
pub value: u64,
|
||||
@@ -121,6 +123,21 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
},
|
||||
];
|
||||
|
||||
const RESTRICTION_USER_FLAGS: u64 = user_flag_bits::DISABLED
|
||||
| user_flag_bits::DELETED
|
||||
| user_flag_bits::SPAMMER
|
||||
| user_flag_bits::REPORT_BANNED;
|
||||
const PURCHASE_DISABLED: i32 = 1 << 6;
|
||||
|
||||
pub fn user_flag_is_editable(flag: u64, current_flags: u64, suspended: bool) -> bool {
|
||||
let self_disabled = flag == user_flag_bits::DISABLED && current_flags & flag != 0 && !suspended;
|
||||
flag & RESTRICTION_USER_FLAGS == 0 || self_disabled
|
||||
}
|
||||
|
||||
pub fn premium_flag_is_editable(flag: i32) -> bool {
|
||||
flag & PURCHASE_DISABLED == 0
|
||||
}
|
||||
|
||||
pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
I32Flag {
|
||||
name: "DISCRIMINATOR",
|
||||
@@ -148,7 +165,7 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
},
|
||||
I32Flag {
|
||||
name: "PURCHASE_DISABLED",
|
||||
value: 1 << 6,
|
||||
value: PURCHASE_DISABLED,
|
||||
},
|
||||
I32Flag {
|
||||
name: "ENABLED_OVERRIDE",
|
||||
@@ -166,7 +183,7 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("6", "Disruptive automation"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
@@ -183,8 +200,157 @@ pub const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
("23", "Threats or violent speech"),
|
||||
("24", "Violent extremism"),
|
||||
("25", "Self-harm promotion"),
|
||||
("26", "Sexual content outside 18+ spaces"),
|
||||
("27", "Illegal goods or activity"),
|
||||
("28", "Harmful deception"),
|
||||
("29", "Reporting abuse"),
|
||||
("30", "Under the minimum age"),
|
||||
("31", "Copyright"),
|
||||
];
|
||||
|
||||
pub fn deletion_reason_label(code: i32) -> String {
|
||||
let value = code.to_string();
|
||||
DELETION_REASONS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == value)
|
||||
.map_or_else(
|
||||
|| format!("Reason {code}"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EnforcementHandling {
|
||||
Standard,
|
||||
Protected,
|
||||
Restricted,
|
||||
}
|
||||
|
||||
pub struct EnforcementReason {
|
||||
pub code: i32,
|
||||
pub handling: EnforcementHandling,
|
||||
pub kinds: Option<&'static [EnforcementKindType]>,
|
||||
}
|
||||
|
||||
impl EnforcementReason {
|
||||
pub fn allows(&self, kind: EnforcementKindType) -> bool {
|
||||
self.kinds.is_none_or(|kinds| kinds.contains(&kind))
|
||||
&& (kind != EnforcementKindType::FeatureLimit
|
||||
|| ENFORCEMENT_LIMITS
|
||||
.iter()
|
||||
.any(|limit| limit.codes.contains(&self.code)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EnforcementLimitOption {
|
||||
pub value: &'static str,
|
||||
pub label: &'static str,
|
||||
pub codes: &'static [i32],
|
||||
}
|
||||
|
||||
pub const ENFORCEMENT_LIMITS: &[EnforcementLimitOption] = &[
|
||||
EnforcementLimitOption {
|
||||
value: "direct_messages",
|
||||
label: "Direct messages",
|
||||
codes: &[3, 6, 8, 18, 21],
|
||||
},
|
||||
EnforcementLimitOption {
|
||||
value: "reports",
|
||||
label: "Reports",
|
||||
codes: &[29],
|
||||
},
|
||||
EnforcementLimitOption {
|
||||
value: "purchases",
|
||||
label: "Purchases",
|
||||
codes: &[12, 14],
|
||||
},
|
||||
];
|
||||
|
||||
pub const COPYRIGHT_REASON_CODE: i32 = 31;
|
||||
|
||||
const RESTRICTED_KINDS: &[EnforcementKindType] = &[
|
||||
EnforcementKindType::Suspension,
|
||||
EnforcementKindType::ContentRemoval,
|
||||
];
|
||||
const PAYMENT_KINDS: &[EnforcementKindType] = &[
|
||||
EnforcementKindType::Warning,
|
||||
EnforcementKindType::Suspension,
|
||||
EnforcementKindType::Closure,
|
||||
EnforcementKindType::FeatureLimit,
|
||||
];
|
||||
|
||||
const fn reason(
|
||||
code: i32,
|
||||
handling: EnforcementHandling,
|
||||
kinds: Option<&'static [EnforcementKindType]>,
|
||||
) -> EnforcementReason {
|
||||
EnforcementReason {
|
||||
code,
|
||||
handling,
|
||||
kinds,
|
||||
}
|
||||
}
|
||||
|
||||
pub const ENFORCEMENT_REASONS: &[EnforcementReason] = {
|
||||
use EnforcementHandling::{Protected, Restricted, Standard};
|
||||
use EnforcementKindType::{
|
||||
Closure, CommunityRemoval, ContentRemoval, FeatureLimit, ProfileReset, Suspension, Warning,
|
||||
};
|
||||
&[
|
||||
reason(3, Standard, None),
|
||||
reason(4, Standard, None),
|
||||
reason(5, Standard, None),
|
||||
reason(6, Standard, None),
|
||||
reason(7, Protected, None),
|
||||
reason(8, Standard, None),
|
||||
reason(9, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(10, Protected, None),
|
||||
reason(11, Protected, None),
|
||||
reason(12, Standard, Some(PAYMENT_KINDS)),
|
||||
reason(13, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(14, Standard, Some(PAYMENT_KINDS)),
|
||||
reason(15, Protected, None),
|
||||
reason(16, Standard, None),
|
||||
reason(17, Standard, Some(&[Warning, Suspension, Closure])),
|
||||
reason(18, Standard, None),
|
||||
reason(20, Standard, None),
|
||||
reason(21, Standard, None),
|
||||
reason(22, Standard, None),
|
||||
reason(23, Protected, None),
|
||||
reason(24, Restricted, Some(RESTRICTED_KINDS)),
|
||||
reason(
|
||||
25,
|
||||
Standard,
|
||||
Some(&[
|
||||
Warning,
|
||||
Suspension,
|
||||
ContentRemoval,
|
||||
ProfileReset,
|
||||
CommunityRemoval,
|
||||
]),
|
||||
),
|
||||
reason(26, Standard, None),
|
||||
reason(27, Standard, None),
|
||||
reason(28, Standard, None),
|
||||
reason(29, Standard, Some(&[Warning, FeatureLimit, Suspension])),
|
||||
reason(30, Standard, Some(&[Closure])),
|
||||
reason(
|
||||
31,
|
||||
Standard,
|
||||
Some(&[Warning, ContentRemoval, ProfileReset, CommunityRemoval]),
|
||||
),
|
||||
]
|
||||
};
|
||||
|
||||
pub fn enforcement_reason(code: i32) -> Option<&'static EnforcementReason> {
|
||||
ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.find(|reason| reason.code == code)
|
||||
}
|
||||
|
||||
pub const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
@@ -226,7 +392,196 @@ pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::USER_FLAGS;
|
||||
use super::{
|
||||
DELETION_REASONS, ENFORCEMENT_LIMITS, ENFORCEMENT_REASONS, EnforcementHandling, USER_FLAGS,
|
||||
};
|
||||
use crate::api::generated::types::DeletionReasonCode;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const CORE_TS: &str = include_str!("../../packages/constants/src/Core.ts");
|
||||
const ENFORCEMENT_REASONS_TS: &str =
|
||||
include_str!("../../packages/constants/src/EnforcementReasons.ts");
|
||||
|
||||
fn quoted_values(list: &str) -> Vec<String> {
|
||||
list.split('\'')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn deletion_reason_codes() -> HashMap<String, i32> {
|
||||
let block = CORE_TS
|
||||
.split("export const DeletionReasons = {")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('}').next())
|
||||
.expect("DeletionReasons block");
|
||||
block
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (name, code) = line.trim().trim_end_matches(',').split_once(": ")?;
|
||||
Some((name.to_owned(), code.parse().ok()?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kind_lists() -> HashMap<String, Vec<String>> {
|
||||
ENFORCEMENT_REASONS_TS
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let rest = line.strip_prefix("const ")?;
|
||||
let (name, value) = rest.split_once(": ReadonlyArray<EnforcementKind> = ")?;
|
||||
Some((name.to_owned(), quoted_values(value)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typescript_reasons() -> Vec<(i32, String, Option<Vec<String>>)> {
|
||||
let codes = deletion_reason_codes();
|
||||
let lists = kind_lists();
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("export const ENFORCEMENT_REASONS")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_REASONS block");
|
||||
block
|
||||
.split("[DeletionReasons.")
|
||||
.skip(1)
|
||||
.map(|entry| {
|
||||
let name = entry.split(']').next().expect("reason name");
|
||||
let handling = entry
|
||||
.split("handling: '")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('\'').next())
|
||||
.expect("handling");
|
||||
let kinds = entry.split("kinds: ").nth(1).expect("kinds").trim_start();
|
||||
let kinds = if kinds.starts_with("'any'") {
|
||||
None
|
||||
} else if kinds.starts_with('[') {
|
||||
Some(quoted_values(kinds.split(']').next().expect("kind list")))
|
||||
} else {
|
||||
let list = kinds
|
||||
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||||
.next()
|
||||
.expect("kind list name");
|
||||
Some(lists.get(list).expect("named kind list").clone())
|
||||
};
|
||||
(
|
||||
*codes.get(name).expect("reason code"),
|
||||
handling.to_owned(),
|
||||
kinds,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn typescript_reason_rules() -> HashMap<String, Vec<i32>> {
|
||||
let codes = deletion_reason_codes();
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("export const ENFORCEMENT_REASONS")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_REASONS block");
|
||||
let mut rules: HashMap<String, Vec<i32>> = HashMap::new();
|
||||
for entry in block.split("[DeletionReasons.").skip(1) {
|
||||
let name = entry.split(']').next().expect("reason name");
|
||||
let rule = entry
|
||||
.split("rule: '")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split('\'').next())
|
||||
.expect("rule");
|
||||
rules
|
||||
.entry(rule.to_owned())
|
||||
.or_default()
|
||||
.push(*codes.get(name).expect("reason code"));
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
fn typescript_limit_rules(limit: &str) -> Vec<String> {
|
||||
let block = ENFORCEMENT_REASONS_TS
|
||||
.split("const ENFORCEMENT_LIMIT_RULES")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n};").next())
|
||||
.expect("ENFORCEMENT_LIMIT_RULES block");
|
||||
let value = block
|
||||
.split(&format!("{limit}: "))
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(",\n").next())
|
||||
.expect("limit rules");
|
||||
if value.starts_with("ENFORCEMENT_SPAMMER_LIMIT_RULES") {
|
||||
let spammer = ENFORCEMENT_REASONS_TS
|
||||
.split("const ENFORCEMENT_SPAMMER_LIMIT_RULES: ")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("]);").next())
|
||||
.expect("spammer rules");
|
||||
return quoted_values(spammer);
|
||||
}
|
||||
quoted_values(value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforcement_limits_match_the_shared_constants() {
|
||||
let rules = typescript_reason_rules();
|
||||
assert_eq!(ENFORCEMENT_LIMITS.len(), 3);
|
||||
for limit in ENFORCEMENT_LIMITS {
|
||||
let mut expected: Vec<i32> = typescript_limit_rules(limit.value)
|
||||
.iter()
|
||||
.flat_map(|rule| rules.get(rule).cloned().unwrap_or_default())
|
||||
.collect();
|
||||
expected.sort_unstable();
|
||||
let mut actual = limit.codes.to_vec();
|
||||
actual.sort_unstable();
|
||||
assert_eq!(actual, expected, "codes of the {} limit", limit.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_limits_are_offered_only_for_reasons_with_a_limit() {
|
||||
use crate::api::generated::types::EnforcementKindType::FeatureLimit;
|
||||
let offered: Vec<i32> = ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.allows(FeatureLimit))
|
||||
.map(|reason| reason.code)
|
||||
.collect();
|
||||
assert_eq!(offered, vec![3, 6, 8, 12, 14, 18, 21, 29]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restriction_flags_are_read_only_except_a_self_disabled_account() {
|
||||
use super::{premium_flag_is_editable, user_flag_bits as bits, user_flag_is_editable};
|
||||
for flag in [bits::DELETED, bits::SPAMMER, bits::REPORT_BANNED] {
|
||||
assert!(!user_flag_is_editable(flag, 0, false));
|
||||
assert!(!user_flag_is_editable(flag, flag, false));
|
||||
}
|
||||
assert!(!user_flag_is_editable(bits::DISABLED, 0, false));
|
||||
assert!(!user_flag_is_editable(bits::DISABLED, bits::DISABLED, true));
|
||||
assert!(user_flag_is_editable(bits::DISABLED, bits::DISABLED, false));
|
||||
assert!(user_flag_is_editable(bits::ACCOUNT_LIMITED, 0, true));
|
||||
assert!(user_flag_is_editable(bits::STAFF, bits::STAFF, false));
|
||||
assert!(!premium_flag_is_editable(1 << 6));
|
||||
assert!(premium_flag_is_editable(1 << 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforcement_reasons_match_the_shared_constants() {
|
||||
let expected = typescript_reasons();
|
||||
assert_eq!(expected.len(), ENFORCEMENT_REASONS.len());
|
||||
for (code, handling, kinds) in expected {
|
||||
let reason = super::enforcement_reason(code)
|
||||
.unwrap_or_else(|| panic!("{code} is missing from ENFORCEMENT_REASONS"));
|
||||
let rust_handling = match reason.handling {
|
||||
EnforcementHandling::Standard => "standard",
|
||||
EnforcementHandling::Protected => "protected",
|
||||
EnforcementHandling::Restricted => "restricted",
|
||||
};
|
||||
assert_eq!(rust_handling, handling, "handling of {code}");
|
||||
let rust_kinds = reason
|
||||
.kinds
|
||||
.map(|kinds| kinds.iter().map(ToString::to_string).collect::<Vec<_>>());
|
||||
assert_eq!(rust_kinds, kinds, "kinds of {code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_flags_cover_every_flag_in_the_admin_spec() {
|
||||
@@ -251,4 +606,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_reasons_match_the_generated_reason_codes() {
|
||||
let spec: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
|
||||
let mut spec_codes: Vec<i32> = spec["components"]["schemas"]["DeletionReasonCode"]["enum"]
|
||||
.as_array()
|
||||
.expect("DeletionReasonCode values")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
i32::try_from(value.as_i64().expect("numeric reason code")).expect("i32 code")
|
||||
})
|
||||
.collect();
|
||||
spec_codes.sort_unstable();
|
||||
let mut label_codes: Vec<i32> = DELETION_REASONS
|
||||
.iter()
|
||||
.map(|(code, _)| code.parse().expect("numeric reason code"))
|
||||
.collect();
|
||||
label_codes.sort_unstable();
|
||||
assert_eq!(label_codes, spec_codes);
|
||||
for code in label_codes {
|
||||
assert!(
|
||||
DeletionReasonCode::try_from(code).is_ok(),
|
||||
"{code} is not a generated DeletionReasonCode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,11 @@ impl AdminApiClient {
|
||||
pub async fn bulk_delete_user_messages(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
basis: generated_types::BulkModerationBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
basis,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.send(
|
||||
@@ -91,26 +93,18 @@ impl AdminApiClient {
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
reason_code: u32,
|
||||
days_until_deletion: u32,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
basis: generated_types::BulkModerationBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
basis,
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.send(
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{AdminUser, UserMutationResponse};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RestrictionResponse {
|
||||
pub user: AdminUser,
|
||||
pub skipped_ips: Vec<generated_types::SkippedNetworkBlock>,
|
||||
}
|
||||
|
||||
pub struct DecisionSubmission {
|
||||
pub decision: generated_types::EnforcementDecisionInput,
|
||||
pub internal_note: String,
|
||||
}
|
||||
|
||||
pub struct ClosureRequest {
|
||||
pub basis: generated_types::ModerationBasis,
|
||||
pub days_until_deletion: u32,
|
||||
pub remove_evidence: bool,
|
||||
pub block_networks: Option<generated_types::NetworkBlockRequest>,
|
||||
pub replace_pending_deletion_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub internal_note: Option<String>,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn suspend_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
duration_hours: u32,
|
||||
remove_evidence: bool,
|
||||
block_networks: Option<generated_types::NetworkBlockRequest>,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<RestrictionResponse> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
block_networks,
|
||||
decision: submission.decision.clone(),
|
||||
duration_hours: i64::from(duration_hours),
|
||||
remove_evidence: remove_evidence.then_some(true),
|
||||
};
|
||||
self.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_network_block_candidates(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<generated_types::NetworkBlockCandidatesResponse> {
|
||||
self.get(
|
||||
&format!(
|
||||
"/admin/users/{}/network-block-candidates",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn warn_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<generated_types::AdminUserWarningResponse> {
|
||||
let body = generated_types::AdminUserWarningRequest {
|
||||
decision: submission.decision.clone(),
|
||||
};
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/warnings", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn close_account(
|
||||
&self,
|
||||
user_id: &str,
|
||||
request: &ClosureRequest,
|
||||
) -> ApiResult<RestrictionResponse> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
basis: request.basis.clone(),
|
||||
block_networks: request.block_networks.clone(),
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
request.days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
remove_evidence: request.remove_evidence.then_some(true),
|
||||
replace_pending_deletion_at: request.replace_pending_deletion_at,
|
||||
};
|
||||
self.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
request.internal_note.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn limit_features(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: generated_types::AdminUserFeatureLimitRequestLimit,
|
||||
ends_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
submission: &DecisionSubmission,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserFeatureLimitRequest {
|
||||
decision: submission.decision.clone(),
|
||||
ends_at,
|
||||
limit,
|
||||
};
|
||||
let response: UserMutationResponse = self
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!(
|
||||
"/admin/users/{}/feature-limits",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
Some(&submission.internal_note),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.user)
|
||||
}
|
||||
|
||||
pub async fn list_review_queue(
|
||||
&self,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
|
||||
self.get(
|
||||
"/admin/enforcement-decisions",
|
||||
Some(&[("review_queue", "true")]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_user_decisions(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionListResponse> {
|
||||
self.get(
|
||||
"/admin/enforcement-decisions",
|
||||
Some(&[("subject_type", "user"), ("subject_id", user_id)]),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
|
||||
self.get(
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn end_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
outcome: generated_types::EnforcementDecisionEndRequestOutcome,
|
||||
statement: Option<String>,
|
||||
internal_note: &str,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionResponse> {
|
||||
let body = generated_types::EnforcementDecisionEndRequest { outcome, statement };
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}/end",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
Some(&body),
|
||||
Some(internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn annotate_decision(
|
||||
&self,
|
||||
decision_id: &str,
|
||||
body: String,
|
||||
) -> ApiResult<generated_types::EnforcementDecisionNoteResponse> {
|
||||
let body = generated_types::EnforcementDecisionNoteRequest { body };
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/enforcement-decisions/{}/notes",
|
||||
urlencoding::encode(decision_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_error_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http { status, message } = error else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(status, 400 | 403 | 404 | 409) {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = serde_json::from_str(message).ok()?;
|
||||
let detail = body["errors"]
|
||||
.as_array()
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(|error| error["message"].as_str());
|
||||
detail
|
||||
.or_else(|| body["message"].as_str())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::api_error_message;
|
||||
use crate::api::client::ApiError;
|
||||
|
||||
fn http(status: u16, body: &str) -> ApiError {
|
||||
ApiError::Http {
|
||||
status,
|
||||
message: body.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_message_prefers_the_first_field_error() {
|
||||
let body = r#"{"code":"INVALID_FORM_BODY","message":"Invalid form body","errors":[{"path":"decision.statement","code":"ENFORCEMENT_STATEMENT_NOT_ALLOWED","message":"A note to the user is not allowed for this reason"}]}"#;
|
||||
assert_eq!(
|
||||
api_error_message(&http(400, body)).as_deref(),
|
||||
Some("A note to the user is not allowed for this reason")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_error_message_reads_conflicts_and_ignores_server_errors() {
|
||||
let body = r#"{"code":"ENFORCEMENT_DECISION_REPLACED","message":"A later decision replaced this one"}"#;
|
||||
assert_eq!(
|
||||
api_error_message(&http(409, body)).as_deref(),
|
||||
Some("A later decision replaced this one")
|
||||
);
|
||||
assert_eq!(api_error_message(&http(500, body)), None);
|
||||
assert_eq!(api_error_message(&http(409, "not json")), None);
|
||||
assert_eq!(
|
||||
api_error_message(&ApiError::Network("down".to_owned())),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,14 +56,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
|
||||
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
|
||||
}
|
||||
|
||||
pub(crate) fn deletion_reason_code(
|
||||
value: i32,
|
||||
field: &str,
|
||||
) -> Result<types::DeletionReasonCode, String> {
|
||||
types::DeletionReasonCode::try_from(value)
|
||||
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
|
||||
}
|
||||
|
||||
pub(crate) fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -69,13 +69,20 @@ impl AdminApiClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
pub async fn delete_guild(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
basis: generated_types::CommunityRemovalBasis,
|
||||
internal_note: Option<&str>,
|
||||
) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::AdminGuildDeleteRequest { basis };
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
|
||||
Some(&body),
|
||||
internal_note,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn transfer_guild_ownership(
|
||||
|
||||
@@ -4,10 +4,12 @@ use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use crate::templates::components::enforcement_fields::BasisSubmission;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
|
||||
BrowseChannelResponse, LookupMessageResponse, MessageShredStatusResponse,
|
||||
SearchChannelMessagesResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -15,8 +17,10 @@ impl AdminApiClient {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
basis: generated_types::MessageDeletionBasis,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminMessageDeleteRequest { basis };
|
||||
let _: serde_json::Value = self
|
||||
.send(
|
||||
Method::DELETE,
|
||||
@@ -25,7 +29,7 @@ impl AdminApiClient {
|
||||
urlencoding::encode(channel_id),
|
||||
urlencoding::encode(message_id)
|
||||
),
|
||||
None::<&serde_json::Value>,
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
@@ -54,35 +58,44 @@ impl AdminApiClient {
|
||||
pub async fn queue_message_shred(
|
||||
&self,
|
||||
user_id: &str,
|
||||
entries: &[serde_json::Value],
|
||||
) -> ApiResult<MessageShredResponse> {
|
||||
let entries = entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::AdminUserMessageShredRequest { entries };
|
||||
let response = self
|
||||
.generated()
|
||||
.shred_admin_user_messages(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
entries: Vec<generated_types::AdminUserMessageShredRequestEntriesItem>,
|
||||
basis: &BasisSubmission,
|
||||
) -> ApiResult<generated_types::MessageShredResponse> {
|
||||
let body = generated_types::AdminUserMessageShredRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
entries,
|
||||
};
|
||||
self.send(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"/admin/users/{}/message-shreds",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.internal_note(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_all_user_messages(
|
||||
&self,
|
||||
user_id: &str,
|
||||
dry_run: bool,
|
||||
) -> ApiResult<DeleteAllUserMessagesResponse> {
|
||||
let dry_run = if dry_run { "true" } else { "false" };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
basis: Option<&BasisSubmission>,
|
||||
) -> ApiResult<generated_types::DeleteAllUserMessagesResponse> {
|
||||
let body = generated_types::AdminUserMessageDeleteRequest {
|
||||
basis: basis.map(BasisSubmission::moderation_basis),
|
||||
};
|
||||
let dry_run = if basis.is_some() { "false" } else { "true" };
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/users/{}/messages?dry_run={dry_run}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.and_then(BasisSubmission::internal_note),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_message_shred_status(
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod bulk;
|
||||
pub mod client;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
pub mod enforcement;
|
||||
pub mod guild_assets;
|
||||
pub mod guild_threads;
|
||||
pub mod guilds;
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
|
||||
use crate::api::generated::{
|
||||
snowflake,
|
||||
types::{
|
||||
UpdateReportRequest, UpdateReportRequestOutcome, UpdateReportRequestResolution,
|
||||
UpdateReportRequestStatus,
|
||||
},
|
||||
};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
@@ -44,20 +50,24 @@ impl AdminApiClient {
|
||||
&self,
|
||||
report_id: &str,
|
||||
resolution: UpdateReportRequestResolution,
|
||||
outcome: Option<UpdateReportRequestOutcome>,
|
||||
duplicate_of: Option<&str>,
|
||||
public_comment: Option<&str>,
|
||||
notify_reporter: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
|
||||
let body = UpdateReportRequest {
|
||||
duplicate_of: duplicate_of.map(snowflake),
|
||||
notify_reporter,
|
||||
outcome,
|
||||
public_comment: public_comment.map(str::to_owned),
|
||||
resolution,
|
||||
status: UpdateReportRequestStatus::Resolved,
|
||||
};
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -118,6 +118,8 @@ pub struct AdminUser {
|
||||
pub deletion_scheduled_by: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deletion_scheduled_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enforcement_decision_id: Option<String>,
|
||||
pub last_active_at: Option<String>,
|
||||
pub last_active_ip: Option<String>,
|
||||
pub last_active_ip_reverse: Option<String>,
|
||||
@@ -253,6 +255,8 @@ pub struct BanCheckResult {
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub decision_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,32 +8,12 @@ pub struct LookupMessageResponse {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct MessageShredResponse {
|
||||
pub success: Option<bool>,
|
||||
pub job_id: Option<String>,
|
||||
pub requested: Option<u64>,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct MessageShredStatusResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeleteAllUserMessagesResponse {
|
||||
pub success: Option<bool>,
|
||||
pub dry_run: bool,
|
||||
pub channel_count: u64,
|
||||
pub message_count: u64,
|
||||
pub job_id: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BrowseChannelResponse {
|
||||
#[serde(flatten)]
|
||||
|
||||
@@ -6,6 +6,8 @@ use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use crate::templates::components::enforcement_fields::BasisSubmission;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
|
||||
@@ -282,6 +284,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
fields: &[String],
|
||||
basis: &BasisSubmission,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
@@ -290,14 +293,22 @@ impl AdminApiClient {
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::AdminUserClearFieldsRequest { fields };
|
||||
let response = self
|
||||
.generated()
|
||||
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
let body = generated_types::AdminUserClearFieldsRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
fields,
|
||||
};
|
||||
let response: UserMutationResponse = self
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/users/{}/profile-fields",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
basis.internal_note(),
|
||||
)
|
||||
.await?;
|
||||
Ok(response.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
@@ -355,93 +366,28 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn temp_ban_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
duration_hours: u32,
|
||||
reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
notify_user,
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn unban_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
public_reason: Option<&str>,
|
||||
notify_user: bool,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserUnbanRequest {
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let response = self
|
||||
.generated_with_reason(private_reason)?
|
||||
.unban_admin_user(&snowflake(user_id), &body)
|
||||
.unban_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn schedule_deletion(
|
||||
&self,
|
||||
user_id: &str,
|
||||
reason_code: i32,
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
notify_user,
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
replace_pending_deletion_at: None,
|
||||
};
|
||||
let response = self
|
||||
.generated_with_reason(audit_log_reason)?
|
||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|error| self.generated_error(error))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn cancel_deletion(
|
||||
&self,
|
||||
user_id: &str,
|
||||
expected_pending_deletion_at: &str,
|
||||
notify_user: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = serde_json::json!({
|
||||
"expected_pending_deletion_at": expected_pending_deletion_at,
|
||||
"notify_user": notify_user,
|
||||
});
|
||||
let resp: UserMutationResponse = self
|
||||
.send(
|
||||
@@ -454,25 +400,6 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn annotate_ban(
|
||||
&self,
|
||||
user_id: &str,
|
||||
ban_audit_log_id: &str,
|
||||
note: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = serde_json::json!({
|
||||
"ban_audit_log_id": ban_audit_log_id,
|
||||
"note": note,
|
||||
});
|
||||
self.send_void(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserDobUpdateRequest {
|
||||
date_of_birth: dob.to_owned(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::client::{AdminApiClient, ApiResult};
|
||||
use crate::api::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use crate::api::types::{FlashLevel, FlashMessage};
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::templates;
|
||||
@@ -201,6 +201,16 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned && r.decision_id.is_some() => (
|
||||
"info",
|
||||
format!(
|
||||
"{value} is blocked by decision {}{}. End that decision to remove the block.",
|
||||
r.decision_id.unwrap_or_default(),
|
||||
r.expires_at
|
||||
.map(|expires_at| format!(" until {}", format_admin_timestamp(&expires_at)))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => (
|
||||
"info",
|
||||
@@ -226,6 +236,12 @@ fn ban_action_result(
|
||||
) -> (&'static str, String) {
|
||||
match result {
|
||||
Ok(()) => ("success", success_message),
|
||||
Err(ApiError::Http { status: 409, .. }) => (
|
||||
"error",
|
||||
format!(
|
||||
"{error_message}. It belongs to an enforcement decision, so end that decision instead."
|
||||
),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: ban action");
|
||||
("error", error_message)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
enforcement::api_error_message,
|
||||
generated::types::EnforcementDecisionEndRequestOutcome,
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
flash::{self, FlashData},
|
||||
htmx,
|
||||
},
|
||||
state::AppState,
|
||||
templates::pages::enforcement_decision,
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecisionsQuery {
|
||||
id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DecisionActionQuery {
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/decisions", get(decisions)).route(
|
||||
"/decisions/{decision_id}",
|
||||
get(decision_detail).post(decision_post),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_decision_id(value: &str) -> bool {
|
||||
(1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
async fn decisions(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Query(query): Query<DecisionsQuery>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let lookup = query
|
||||
.id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
if let Some(id) = lookup.filter(|id| is_decision_id(id)) {
|
||||
return Redirect::to(&format!("{}/decisions/{id}", config.base_path)).into_response();
|
||||
}
|
||||
let error = lookup.map(|_| "A decision ID is a number.");
|
||||
let admin_acls = auth
|
||||
.0
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or_default();
|
||||
let review_queue = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client.list_review_queue().await;
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!(%error, "admin API request failed: list the review queue");
|
||||
}
|
||||
Some(result)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let markup =
|
||||
enforcement_decision::decisions_page(config, &auth.0, error, review_queue.as_ref());
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn decision_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
Path(decision_id): Path<String>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let decision = if is_decision_id(&decision_id) {
|
||||
client
|
||||
.get_decision(&decision_id)
|
||||
.await
|
||||
.log_error("load enforcement decision")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let users = match &decision {
|
||||
Some(decision) => client
|
||||
.lookup_users_by_ids(&enforcement_decision::decision_user_ids(decision))
|
||||
.await
|
||||
.log_error("load users named by an enforcement decision")
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let markup = enforcement_decision::enforcement_decision_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&decision_id,
|
||||
decision.as_ref(),
|
||||
&users,
|
||||
&csrf.0.0,
|
||||
htmx::targets(&headers, "main-content"),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn decision_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
Path(decision_id): Path<String>,
|
||||
Query(query): Query<DecisionActionQuery>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let redirect = format!("{}/decisions/{decision_id}", config.base_path);
|
||||
let flash = match MultiValueForm::from_request(request).await {
|
||||
Some(form) if is_decision_id(&decision_id) => {
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
dispatch(&client, &decision_id, query.action.as_deref(), &form).await
|
||||
}
|
||||
Some(_) => FlashData::error("A decision ID is a number."),
|
||||
None => FlashData::error("Invalid form data"),
|
||||
};
|
||||
if htmx::is_htmx_request(&headers) && htmx::targets(&headers, "flash-container") {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn failure(context: &str, error: &crate::api::client::ApiError) -> FlashData {
|
||||
tracing::warn!(%error, "admin API request failed: {context}");
|
||||
match api_error_message(error) {
|
||||
Some(message) => FlashData::error(format!("{context}: {message}")),
|
||||
None => FlashData::error(context.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(
|
||||
client: &AdminApiClient,
|
||||
decision_id: &str,
|
||||
action: Option<&str>,
|
||||
form: &MultiValueForm,
|
||||
) -> FlashData {
|
||||
match action {
|
||||
Some("end") => {
|
||||
let Some(outcome) = form
|
||||
.first("outcome")
|
||||
.and_then(|value| EnforcementDecisionEndRequestOutcome::try_from(value).ok())
|
||||
else {
|
||||
return FlashData::error("Choose an outcome");
|
||||
};
|
||||
let Some(internal_note) = form.clean("internal_note") else {
|
||||
return FlashData::error("Add an internal note");
|
||||
};
|
||||
match client
|
||||
.end_decision(
|
||||
decision_id,
|
||||
outcome,
|
||||
form.clean("statement"),
|
||||
&internal_note,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => FlashData::success("Decision ended"),
|
||||
Err(error) => failure("Failed to end the decision", &error),
|
||||
}
|
||||
}
|
||||
Some("add_note") => {
|
||||
let Some(body) = form.clean("body") else {
|
||||
return FlashData::error("Write a note first");
|
||||
};
|
||||
match client.annotate_decision(decision_id, body).await {
|
||||
Ok(_) => FlashData::success("Note added"),
|
||||
Err(error) => failure("Failed to add the note", &error),
|
||||
}
|
||||
}
|
||||
_ => FlashData::error("Unknown decision action"),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
enforcement::api_error_message,
|
||||
generated::types::CommunityRemovalBasis,
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf,
|
||||
@@ -10,7 +14,10 @@ use crate::{
|
||||
},
|
||||
routes::guild_tabs,
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{
|
||||
self,
|
||||
components::enforcement_fields::{BasisSubmission, basis_from_form},
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
@@ -308,11 +315,7 @@ async fn dispatch_guild_action(
|
||||
"Guild shutdown requested",
|
||||
"Failed to shut down guild",
|
||||
),
|
||||
"delete_guild" => action_result(
|
||||
client.delete_guild(guild_id).await,
|
||||
"Guild deleted",
|
||||
"Failed to delete guild",
|
||||
),
|
||||
"delete_guild" => remove_community(client, guild_id, form).await,
|
||||
"update_settings" => {
|
||||
let mut settings = serde_json::Map::new();
|
||||
for key in &[
|
||||
@@ -455,6 +458,37 @@ async fn dispatch_guild_action(
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_community(
|
||||
client: &AdminApiClient,
|
||||
guild_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> FlashData {
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return FlashData::error(message),
|
||||
};
|
||||
let community_basis = match &basis {
|
||||
BasisSubmission::Enforcement(submission) => {
|
||||
CommunityRemovalBasis::Enforcement(submission.decision.clone())
|
||||
}
|
||||
BasisSubmission::HolderRequest(_) => CommunityRemovalBasis::HolderRequest,
|
||||
BasisSubmission::Housekeeping(_) => return FlashData::error("Choose a basis"),
|
||||
};
|
||||
match client
|
||||
.delete_guild(guild_id, community_basis, basis.internal_note())
|
||||
.await
|
||||
{
|
||||
Ok(_) => FlashData::success("Community removed"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, guild_id, "admin API request failed: remove community");
|
||||
FlashData::error(api_error_message(&error).map_or_else(
|
||||
|| "Failed to remove the community".to_owned(),
|
||||
|message| format!("Failed to remove the community: {message}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
success_message: &'static str,
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::AdminApiClient,
|
||||
enforcement::api_error_message,
|
||||
generated::types::{BulkModerationBasis, MessageDeletionBasis},
|
||||
},
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
flash::{self, FlashData},
|
||||
},
|
||||
state::AppState,
|
||||
templates::{
|
||||
components::enforcement_fields::{bulk_decision_from_form, can_take_action},
|
||||
pages::messages_page::MessageActions,
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, Request, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
@@ -93,17 +100,31 @@ pub(crate) async fn messages_post(
|
||||
.into_response()
|
||||
}
|
||||
"delete" => {
|
||||
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
|
||||
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
|
||||
let back = format!("{base}/messages?channel_id={}", encode_opt(&channel_id));
|
||||
let basis = form
|
||||
.first("basis")
|
||||
.and_then(|value| MessageDeletionBasis::try_from(value).ok());
|
||||
let flash = match (&channel_id, &message_id, basis) {
|
||||
(Some(cid), Some(mid), Some(basis)) => {
|
||||
let internal_note = form.clean("internal_note");
|
||||
match client
|
||||
.delete_message(cid, mid, basis, internal_note.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => FlashData::success("Message deleted"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: delete message");
|
||||
FlashData::error(api_error_message(&error).map_or_else(
|
||||
|| "Failed to delete the message".to_owned(),
|
||||
|message| format!("Failed to delete the message: {message}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), None) => FlashData::error("Choose a basis"),
|
||||
_ => FlashData::error("Enter the channel ID and message ID"),
|
||||
};
|
||||
let audit_log_reason = form.clean("audit_log_reason");
|
||||
match client
|
||||
.delete_message(cid, mid, audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
|
||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||
}
|
||||
flash::redirect_with_flash(&back, flash, config.secure_cookies())
|
||||
}
|
||||
_ => Redirect::to(&format!("{base}/messages")).into_response(),
|
||||
}
|
||||
@@ -150,6 +171,15 @@ pub(crate) async fn system_dms_post(
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_basis(form: &MultiValueForm) -> Result<BulkModerationBasis, String> {
|
||||
match form.first("basis") {
|
||||
Some("enforcement") => bulk_decision_from_form(form).map(BulkModerationBasis::Enforcement),
|
||||
Some("holder_request") => Ok(BulkModerationBasis::HolderRequest),
|
||||
Some("housekeeping") => Ok(BulkModerationBasis::Housekeeping),
|
||||
_ => Err("Choose a basis".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn bulk_actions_post(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
@@ -197,35 +227,48 @@ pub(crate) async fn bulk_actions_post(
|
||||
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||
"bulk-schedule-user-deletion" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let (Ok(reason_code), Ok(days)) = (
|
||||
form.parse_value::<u32>("reason_code"),
|
||||
form.parse_value::<u32>("days_until_deletion"),
|
||||
) else {
|
||||
let basis = match bulk_basis(&form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let Ok(Some(days)) = form.parse_value::<u32>("days_until_deletion") else {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid deletion reason code or delay"),
|
||||
FlashData::error("Enter the days until deletion"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let public_reason = form.clean("public_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
client
|
||||
.bulk_schedule_user_deletion(
|
||||
&user_ids,
|
||||
reason_code.unwrap_or(2),
|
||||
days.unwrap_or(14),
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
audit_log_reason.as_deref(),
|
||||
days,
|
||||
basis,
|
||||
form.clean("internal_note").as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"bulk-delete-user-messages" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let basis = match bulk_basis(&form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
client
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.bulk_delete_user_messages(&user_ids, basis, form.clean("internal_note").as_deref())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
@@ -295,7 +338,13 @@ 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,
|
||||
MessageActions {
|
||||
delete: can_delete,
|
||||
take_action: can_take_action(admin_acls),
|
||||
},
|
||||
None,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -325,14 +374,6 @@ fn encode_opt(value: &Option<String>) -> String {
|
||||
urlencoding::encode(value.as_deref().unwrap_or("")).into_owned()
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, message: &str) -> Response {
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({"success": false, "error": message})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn archives_download(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, components::enforcement_fields::can_take_action},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -137,6 +137,7 @@ async fn messages_page(
|
||||
prefill_channel_id: channel_id,
|
||||
can_lookup,
|
||||
can_delete,
|
||||
can_take_action: can_take_action(admin_acls),
|
||||
lookup_result: lookup_result.as_ref(),
|
||||
browse_result: browse_result.as_ref(),
|
||||
search_result: search_result.as_ref(),
|
||||
@@ -201,13 +202,6 @@ async fn bulk_actions_page(
|
||||
csrf: axum::Extension<CsrfToken>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let account_identity = state.account_identity(&client).await;
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(
|
||||
config,
|
||||
&auth.0,
|
||||
&csrf.0.0,
|
||||
account_identity.is_username(),
|
||||
);
|
||||
let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod bans_actions;
|
||||
mod billing_actions;
|
||||
pub mod codes;
|
||||
pub mod discovery;
|
||||
pub mod enforcement;
|
||||
mod guild_tabs;
|
||||
pub mod guilds;
|
||||
pub mod jobs;
|
||||
@@ -64,6 +65,7 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.merge(applications::router())
|
||||
.merge(codes::router())
|
||||
.merge(discovery::router())
|
||||
.merge(enforcement::router())
|
||||
.merge(registrations::router())
|
||||
.merge(jobs::router())
|
||||
.merge(messages::router())
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError, ApiResultExt},
|
||||
generated::types::{UpdateReportRequestOutcome, UpdateReportRequestResolution},
|
||||
reports::SearchReportsParams,
|
||||
types::ReportEntry,
|
||||
},
|
||||
@@ -15,7 +16,7 @@ use crate::{
|
||||
htmx,
|
||||
},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, components::enforcement_fields::is_snowflake},
|
||||
utils::{forms::clean_string, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use axum::{
|
||||
@@ -78,6 +79,10 @@ struct ResolveForm {
|
||||
#[serde(default)]
|
||||
resolution: Option<String>,
|
||||
#[serde(default)]
|
||||
outcome: Option<String>,
|
||||
#[serde(default)]
|
||||
duplicate_of: Option<String>,
|
||||
#[serde(default)]
|
||||
public_comment: Option<String>,
|
||||
#[serde(default)]
|
||||
notify_reporter: Option<String>,
|
||||
@@ -323,6 +328,35 @@ async fn report_resolve(
|
||||
config.secure_cookies(),
|
||||
);
|
||||
};
|
||||
let outcome = match resolution {
|
||||
UpdateReportRequestResolution::NoViolation => Some(UpdateReportRequestOutcome::NoViolation),
|
||||
UpdateReportRequestResolution::Actioned => form
|
||||
.outcome
|
||||
.as_deref()
|
||||
.and_then(|value| UpdateReportRequestOutcome::try_from(value).ok())
|
||||
.filter(|outcome| *outcome != UpdateReportRequestOutcome::NoViolation),
|
||||
UpdateReportRequestResolution::Duplicate => None,
|
||||
};
|
||||
let duplicate_of = form
|
||||
.duplicate_of
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| is_snowflake(value))
|
||||
.map(str::to_owned);
|
||||
let missing = match resolution {
|
||||
UpdateReportRequestResolution::Actioned if outcome.is_none() => Some(OUTCOME_REQUIRED),
|
||||
UpdateReportRequestResolution::Duplicate if duplicate_of.is_none() => {
|
||||
Some(DUPLICATE_OF_REQUIRED)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(message) = missing {
|
||||
return flash::redirect_with_flash(
|
||||
&back,
|
||||
FlashData::error(message),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
|
||||
let notify_reporter =
|
||||
@@ -331,9 +365,10 @@ async fn report_resolve(
|
||||
.resolve_report(
|
||||
&report_id,
|
||||
resolution,
|
||||
outcome,
|
||||
duplicate_of.as_deref(),
|
||||
public_comment.as_deref(),
|
||||
notify_reporter,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -347,6 +382,8 @@ async fn report_resolve(
|
||||
}
|
||||
|
||||
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
|
||||
const OUTCOME_REQUIRED: &str = "Choose what happened to the reported content or account";
|
||||
const DUPLICATE_OF_REQUIRED: &str = "Enter the ID of the report this one repeats";
|
||||
|
||||
async fn report_legal_hold(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
enforcement::{ClosureRequest, api_error_message},
|
||||
generated::{
|
||||
snowflake,
|
||||
types::{AdminUserFeatureLimitRequestLimit, AdminUserMessageShredRequestEntriesItem},
|
||||
},
|
||||
},
|
||||
middleware::flash::FlashData,
|
||||
templates::components::enforcement_fields::{
|
||||
BasisSubmission, basis_from_form, decision_from_form, network_blocks_from_form,
|
||||
skipped_networks_summary,
|
||||
},
|
||||
utils::forms::MultiValueForm,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
@@ -74,16 +85,21 @@ pub async fn dispatch(
|
||||
return DispatchOutcome::error("User not found");
|
||||
}
|
||||
};
|
||||
let suspended = user.temp_banned_until.is_some();
|
||||
let editable =
|
||||
|flag: &u64| admin_flags::user_flag_is_editable(*flag, user.flags, suspended);
|
||||
let add = submitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|flag| user.flags & *flag == 0)
|
||||
.filter(|flag| user.flags & *flag == 0 && editable(flag))
|
||||
.map(|flag| flag.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let remove = admin_flags::USER_FLAGS
|
||||
.iter()
|
||||
.map(|flag| flag.value)
|
||||
.filter(|flag| user.flags & *flag != 0 && !selected.contains(flag))
|
||||
.filter(|flag| {
|
||||
user.flags & *flag != 0 && !selected.contains(flag) && editable(flag)
|
||||
})
|
||||
.map(|flag| flag.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
DispatchOutcome::from_result(
|
||||
@@ -122,12 +138,18 @@ pub async fn dispatch(
|
||||
let add = submitted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|flag| current & *flag != *flag)
|
||||
.filter(|flag| {
|
||||
current & *flag != *flag && admin_flags::premium_flag_is_editable(*flag)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let remove = admin_flags::PREMIUM_FLAGS
|
||||
.iter()
|
||||
.map(|flag| flag.value)
|
||||
.filter(|flag| current & *flag == *flag && !selected.contains(flag))
|
||||
.filter(|flag| {
|
||||
current & *flag == *flag
|
||||
&& !selected.contains(flag)
|
||||
&& admin_flags::premium_flag_is_editable(*flag)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
DispatchOutcome::from_result(
|
||||
client.update_premium_flags(user_id, &add, &remove).await,
|
||||
@@ -172,12 +194,20 @@ pub async fn dispatch(
|
||||
"Failed to terminate user sessions",
|
||||
),
|
||||
"clear_fields" => {
|
||||
let f = form.list_values_any(&["fields[]", "fields"]);
|
||||
DispatchOutcome::from_result(
|
||||
client.clear_user_fields(user_id, &f).await,
|
||||
"User fields cleared successfully",
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
let fields = form.list_values_any(&["fields[]", "fields"]);
|
||||
if fields.is_empty() {
|
||||
return DispatchOutcome::error("Choose at least one field");
|
||||
}
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.clear_user_fields(user_id, &fields, &basis).await {
|
||||
Ok(_) => DispatchOutcome::success("Profile fields cleared"),
|
||||
Err(error) => {
|
||||
decision_failure("Failed to clear the profile fields", user_id, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
@@ -218,43 +248,61 @@ pub async fn dispatch(
|
||||
"Failed to change email",
|
||||
)
|
||||
}
|
||||
"temp_ban" => {
|
||||
let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
"suspend" => {
|
||||
let Ok(Some(duration_hours)) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Choose a duration");
|
||||
};
|
||||
let reason = get("reason");
|
||||
let private = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.temp_ban_user(
|
||||
user_id,
|
||||
duration.unwrap_or(24),
|
||||
reason.as_deref(),
|
||||
notify_user,
|
||||
private.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User temporarily banned successfully",
|
||||
"Failed to temporarily ban user",
|
||||
)
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let block_networks = match network_blocks_from_form(form) {
|
||||
Ok(block_networks) => block_networks,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let remove_evidence = form.bool_value("remove_evidence");
|
||||
match client
|
||||
.suspend_user(
|
||||
user_id,
|
||||
duration_hours,
|
||||
remove_evidence,
|
||||
block_networks,
|
||||
&submission,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let done = if duration_hours == 0 {
|
||||
"Account banned."
|
||||
} else {
|
||||
"Account suspended."
|
||||
};
|
||||
DispatchOutcome::success(with_skipped(done, &response.skipped_ips))
|
||||
}
|
||||
Err(error) => decision_failure("Failed to suspend the account", user_id, &error),
|
||||
}
|
||||
}
|
||||
"warn" => {
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.warn_user(user_id, &submission).await {
|
||||
Ok(response) => DispatchOutcome::success(format!(
|
||||
"Warning sent as decision {}",
|
||||
response.decision_id.as_str()
|
||||
)),
|
||||
Err(error) => decision_failure("Failed to send the warning", user_id, &error),
|
||||
}
|
||||
}
|
||||
"unban" => {
|
||||
let public_reason = get("public_reason");
|
||||
let private_reason = get("private_reason");
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.unban_user(
|
||||
user_id,
|
||||
public_reason.as_deref(),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User unbanned successfully",
|
||||
"Failed to unban user",
|
||||
)
|
||||
let Some(internal_note) = get("internal_note") else {
|
||||
return DispatchOutcome::error("Add an internal note");
|
||||
};
|
||||
match client.unban_user(user_id, Some(&internal_note)).await {
|
||||
Ok(_) => DispatchOutcome::success("Suspension lifted"),
|
||||
Err(error) => decision_failure("Failed to lift the suspension", user_id, &error),
|
||||
}
|
||||
}
|
||||
"ban_ip" => {
|
||||
let Some(ip) = get("ip") else {
|
||||
@@ -279,30 +327,33 @@ pub async fn dispatch(
|
||||
"Failed to ban avatar hash",
|
||||
)
|
||||
}
|
||||
"schedule_deletion" => {
|
||||
let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
|
||||
return DispatchOutcome::error("Invalid deletion reason code");
|
||||
"close" => close_account(client, user_id, form).await,
|
||||
"limit_features" => {
|
||||
let Some(limit) = form
|
||||
.first("limit")
|
||||
.and_then(|value| AdminUserFeatureLimitRequestLimit::try_from(value).ok())
|
||||
else {
|
||||
return DispatchOutcome::error("Choose a limit");
|
||||
};
|
||||
let public_reason = get("public_reason");
|
||||
let private_reason = get("private_reason");
|
||||
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
|
||||
return DispatchOutcome::error("Invalid deletion delay");
|
||||
let ends_at = match (limit, form.first("ends_on")) {
|
||||
(AdminUserFeatureLimitRequestLimit::Reports, Some(value)) => match parse_day(value)
|
||||
{
|
||||
Some(ends_at) => Some(ends_at),
|
||||
None => return DispatchOutcome::error("Enter the end date as a date"),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let notify_user = form.opt_out_value("notify_user");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.schedule_deletion(
|
||||
user_id,
|
||||
reason_code.unwrap_or(0),
|
||||
public_reason.as_deref(),
|
||||
days.unwrap_or(60),
|
||||
notify_user,
|
||||
private_reason.as_deref(),
|
||||
)
|
||||
.await,
|
||||
"User deletion scheduled successfully",
|
||||
"Failed to schedule user deletion",
|
||||
)
|
||||
let submission = match decision_from_form(form) {
|
||||
Ok(submission) => submission,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client
|
||||
.limit_features(user_id, limit, ends_at, &submission)
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("Feature limited"),
|
||||
Err(error) => decision_failure("Failed to limit the feature", user_id, &error),
|
||||
}
|
||||
}
|
||||
"cancel_deletion" => {
|
||||
let Some(expected) = get("expected_pending_deletion_at") else {
|
||||
@@ -315,40 +366,15 @@ pub async fn dispatch(
|
||||
"Confirm whose deletion you are canceling before submitting",
|
||||
);
|
||||
}
|
||||
let Some(private_reason) = get("private_reason") else {
|
||||
return DispatchOutcome::error("A private reason is required to cancel a deletion");
|
||||
let Some(internal_note) = get("internal_note") else {
|
||||
return DispatchOutcome::error("Add an internal note");
|
||||
};
|
||||
let notify_user = form.bool_value("notify_user");
|
||||
match client
|
||||
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
|
||||
.cancel_deletion(user_id, &expected, Some(&internal_note))
|
||||
.await
|
||||
{
|
||||
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The pending deletion changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
|
||||
DispatchOutcome::error("Failed to cancel user deletion")
|
||||
}
|
||||
}
|
||||
}
|
||||
"annotate_ban" => {
|
||||
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
|
||||
return DispatchOutcome::error("The ban audit log entry is missing from the form");
|
||||
};
|
||||
let Some(note) = get("note") else {
|
||||
return DispatchOutcome::error("Note is required");
|
||||
};
|
||||
match client.annotate_ban(user_id, &ban_audit_log_id, ¬e).await {
|
||||
Ok(()) => DispatchOutcome::success("Note added to the ban"),
|
||||
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
|
||||
"The ban changed since this page loaded. Reload and review.",
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
|
||||
DispatchOutcome::error("Failed to add the note to the ban")
|
||||
}
|
||||
Ok(_) => DispatchOutcome::success("Deletion cancelled"),
|
||||
Err(error) => decision_failure("Failed to cancel the deletion", user_id, &error),
|
||||
}
|
||||
}
|
||||
"change_dob" => {
|
||||
@@ -431,79 +457,172 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete_all_messages" => {
|
||||
let dry_run = parse_dry_run(form.first("dry_run"));
|
||||
match client.delete_all_user_messages(user_id, dry_run).await {
|
||||
Ok(response) if dry_run => DispatchOutcome {
|
||||
flash: FlashData::success(format!(
|
||||
"Dry run found {} across {}. Confirm to delete them permanently.",
|
||||
count_noun(response.message_count, "message", "messages"),
|
||||
count_noun(response.channel_count, "channel", "channels")
|
||||
)),
|
||||
redirect_params: vec![
|
||||
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
|
||||
(
|
||||
"delete_all_messages_channel_count".to_owned(),
|
||||
response.channel_count.to_string(),
|
||||
),
|
||||
(
|
||||
"delete_all_messages_message_count".to_owned(),
|
||||
response.message_count.to_string(),
|
||||
),
|
||||
],
|
||||
},
|
||||
Ok(response) => {
|
||||
if let Some(job_id) = response.job_id {
|
||||
DispatchOutcome {
|
||||
flash: FlashData::success(
|
||||
"Delete job queued. Monitor progress in the status panel.",
|
||||
),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
}
|
||||
} else {
|
||||
DispatchOutcome::success("No messages found for deletion.")
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: delete all user messages");
|
||||
DispatchOutcome::error("Failed to delete all user messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
"delete_all_messages" => delete_all_messages(client, user_id, form).await,
|
||||
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
|
||||
client.cancel_bulk_message_deletion(user_id).await,
|
||||
"Bulk message deletion canceled successfully",
|
||||
"Failed to cancel bulk message deletion",
|
||||
),
|
||||
"message_shred" => {
|
||||
let csv = form.first("csv_data").unwrap_or_default();
|
||||
match parse_message_shred_csv(csv) {
|
||||
Ok(entries) if entries.is_empty() => DispatchOutcome::error(
|
||||
"CSV did not contain any valid channel_id,message_id pairs.",
|
||||
),
|
||||
Ok(entries) => match client.queue_message_shred(user_id, &entries).await {
|
||||
Ok(response) => {
|
||||
if let Some(job_id) = response.job_id {
|
||||
DispatchOutcome {
|
||||
flash: FlashData::success("Message shred job queued"),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
}
|
||||
} else {
|
||||
DispatchOutcome::success("Message shred job queued")
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: queue message shred job");
|
||||
DispatchOutcome::error("Failed to queue message shred job")
|
||||
}
|
||||
let entries = match parse_message_shred_csv(form.first("csv_data").unwrap_or_default())
|
||||
{
|
||||
Ok(entries) if entries.is_empty() => {
|
||||
return DispatchOutcome::error("The CSV has no channel_id,message_id rows.");
|
||||
}
|
||||
Ok(entries) => entries,
|
||||
Err(error) => return DispatchOutcome::error(error),
|
||||
};
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
match client.queue_message_shred(user_id, entries, &basis).await {
|
||||
Ok(response) => DispatchOutcome {
|
||||
flash: FlashData::success(removal_message(
|
||||
"Message shred queued",
|
||||
response.decision_id.as_ref().map(|id| id.as_str()),
|
||||
)),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), response.job_id)],
|
||||
},
|
||||
Err(error) => DispatchOutcome::error(error),
|
||||
Err(error) => {
|
||||
decision_failure("Failed to queue the message shred", user_id, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => DispatchOutcome::error(format!("Unknown user action: {action}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_skipped(
|
||||
done: &str,
|
||||
skipped: &[crate::api::generated::types::SkippedNetworkBlock],
|
||||
) -> String {
|
||||
match skipped_networks_summary(skipped) {
|
||||
Some(summary) => format!("{done} {summary}"),
|
||||
None => done.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_account(
|
||||
client: &AdminApiClient,
|
||||
user_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> DispatchOutcome {
|
||||
let Ok(Some(days_until_deletion)) = form.parse_value::<u32>("days_until_deletion") else {
|
||||
return DispatchOutcome::error("Enter the days until deletion");
|
||||
};
|
||||
let replace_pending_deletion_at = match form.clean("replace_pending_deletion_at") {
|
||||
Some(value) => match chrono::DateTime::parse_from_rfc3339(&value) {
|
||||
Ok(at) => Some(at.with_timezone(&chrono::Utc)),
|
||||
Err(_) => {
|
||||
return DispatchOutcome::error(
|
||||
"The pending deletion is missing from the form. Reload and review.",
|
||||
);
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let basis = match basis_from_form(form) {
|
||||
Ok(basis) => basis,
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let enforcement = matches!(basis, BasisSubmission::Enforcement(_));
|
||||
let block_networks = match network_blocks_from_form(form) {
|
||||
Ok(block_networks) => block_networks.filter(|_| enforcement),
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
};
|
||||
let request = ClosureRequest {
|
||||
basis: basis.moderation_basis(),
|
||||
days_until_deletion,
|
||||
remove_evidence: enforcement && form.bool_value("remove_evidence"),
|
||||
block_networks,
|
||||
replace_pending_deletion_at,
|
||||
internal_note: basis.internal_note().map(str::to_owned),
|
||||
};
|
||||
match client.close_account(user_id, &request).await {
|
||||
Ok(response) => {
|
||||
DispatchOutcome::success(with_skipped("Account closed.", &response.skipped_ips))
|
||||
}
|
||||
Err(error) => decision_failure("Failed to close the account", user_id, &error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_all_messages(
|
||||
client: &AdminApiClient,
|
||||
user_id: &str,
|
||||
form: &MultiValueForm,
|
||||
) -> DispatchOutcome {
|
||||
let basis = if parse_dry_run(form.first("dry_run")) {
|
||||
None
|
||||
} else {
|
||||
match basis_from_form(form) {
|
||||
Ok(basis) => Some(basis),
|
||||
Err(message) => return DispatchOutcome::error(message),
|
||||
}
|
||||
};
|
||||
let response = match client
|
||||
.delete_all_user_messages(user_id, basis.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => return decision_failure("Failed to delete the messages", user_id, &error),
|
||||
};
|
||||
let message_count = u64::try_from(*response.message_count).unwrap_or_default();
|
||||
let channel_count = u64::try_from(*response.channel_count).unwrap_or_default();
|
||||
if basis.is_none() {
|
||||
return DispatchOutcome {
|
||||
flash: FlashData::success(format!(
|
||||
"Found {} in {}. Choose a basis to delete them.",
|
||||
count_noun(message_count, "message", "messages"),
|
||||
count_noun(channel_count, "channel", "channels")
|
||||
)),
|
||||
redirect_params: vec![
|
||||
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
|
||||
(
|
||||
"delete_all_messages_channel_count".to_owned(),
|
||||
channel_count.to_string(),
|
||||
),
|
||||
(
|
||||
"delete_all_messages_message_count".to_owned(),
|
||||
message_count.to_string(),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
match response.job_id {
|
||||
Some(job_id) => DispatchOutcome {
|
||||
flash: FlashData::success(removal_message(
|
||||
"Delete job queued",
|
||||
response.decision_id.as_ref().map(|id| id.as_str()),
|
||||
)),
|
||||
redirect_params: vec![("message_shred_job_id".to_owned(), job_id)],
|
||||
},
|
||||
None => DispatchOutcome::success("No messages to delete"),
|
||||
}
|
||||
}
|
||||
|
||||
fn removal_message(queued: &str, decision_id: Option<&str>) -> String {
|
||||
match decision_id {
|
||||
Some(decision_id) => format!("{queued} as decision {decision_id}"),
|
||||
None => queued.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_day(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")
|
||||
.ok()?
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|at| at.and_utc())
|
||||
}
|
||||
|
||||
fn decision_failure(context: &str, user_id: &str, error: &ApiError) -> DispatchOutcome {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: {context}");
|
||||
match api_error_message(error) {
|
||||
Some(message) => DispatchOutcome::error(format!("{context}: {message}")),
|
||||
None => DispatchOutcome::error(context.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_legacy_flag_delta_fields(form: &MultiValueForm) -> bool {
|
||||
["add_flags[]", "add_flags", "remove_flags[]", "remove_flags"]
|
||||
.iter()
|
||||
@@ -521,7 +640,9 @@ fn parse_dry_run(value: Option<&str>) -> bool {
|
||||
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
|
||||
}
|
||||
|
||||
fn parse_message_shred_csv(csv: &str) -> Result<Vec<serde_json::Value>, String> {
|
||||
fn parse_message_shred_csv(
|
||||
csv: &str,
|
||||
) -> Result<Vec<AdminUserMessageShredRequestEntriesItem>, String> {
|
||||
let mut entries = Vec::new();
|
||||
for line in csv.trim().lines() {
|
||||
let trimmed = line.trim();
|
||||
@@ -534,12 +655,10 @@ fn parse_message_shred_csv(csv: &str) -> Result<Vec<serde_json::Value>, String>
|
||||
"Each row must contain channel_id and message_id separated by a comma".to_owned(),
|
||||
);
|
||||
}
|
||||
let channel_id = parse_csv_snowflake(parts[0], "channel_id", trimmed)?;
|
||||
let message_id = parse_csv_snowflake(parts[1], "message_id", trimmed)?;
|
||||
entries.push(serde_json::json!({
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
}));
|
||||
entries.push(AdminUserMessageShredRequestEntriesItem {
|
||||
channel_id: snowflake(&parse_csv_snowflake(parts[0], "channel_id", trimmed)?),
|
||||
message_id: snowflake(&parse_csv_snowflake(parts[1], "message_id", trimmed)?),
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -572,8 +691,10 @@ mod tests {
|
||||
let entries = parse_message_shred_csv("channel_id,message_id\n123,456\n789,101112")
|
||||
.expect("valid csv");
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["channel_id"], "123");
|
||||
assert_eq!(entries[0]["message_id"], "456");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&entries[0]).expect("serializable entry"),
|
||||
serde_json::json!({"channel_id": "123", "message_id": "456"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct TabQuery {
|
||||
pub delete_all_messages_dry_run: Option<String>,
|
||||
pub delete_all_messages_channel_count: Option<u64>,
|
||||
pub delete_all_messages_message_count: Option<u64>,
|
||||
pub report_id: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -114,7 +116,8 @@ pub async fn render(
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ban_logs = if u.temp_banned_until.is_some()
|
||||
let legacy_logs = if u.temp_banned_until.is_some()
|
||||
&& u.enforcement_decision_id.is_none()
|
||||
&& acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW)
|
||||
{
|
||||
client
|
||||
@@ -130,16 +133,41 @@ pub async fn render(
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.log_error("load ban audit logs")
|
||||
.log_error("load suspension audit logs")
|
||||
.map(|response| response.logs)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let decisions = if acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW) {
|
||||
Some(client.list_user_decisions(user_id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Err(error)) = &decisions {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: list user decisions");
|
||||
}
|
||||
let can_block_networks = acl::has_permission(admin_acls, acl::USER_VIEW_IP)
|
||||
&& acl::has_permission(admin_acls, acl::BAN_IP_ADD)
|
||||
&& (acl::has_permission(admin_acls, acl::USER_TEMP_BAN)
|
||||
|| acl::has_permission(admin_acls, acl::USER_DELETE));
|
||||
let network_candidates = if can_block_networks {
|
||||
Some(client.list_network_block_candidates(user_id).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(Err(error)) = &network_candidates {
|
||||
tracing::warn!(%error, user_id, "admin API request failed: list network block candidates");
|
||||
}
|
||||
let context = tabs::moderation::ModerationContext {
|
||||
deletion_scheduler: deletion_scheduler.as_ref(),
|
||||
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
|
||||
username_sign_in: account_identity.is_username(),
|
||||
legacy_suspension: tabs::moderation::find_legacy_suspension(&u, &legacy_logs),
|
||||
decisions: decisions.as_ref(),
|
||||
network_candidates: network_candidates.as_ref(),
|
||||
prefill: tabs::moderation::DecisionPrefill {
|
||||
report_ids: query.report_id.iter().cloned().collect(),
|
||||
messages: query.message.iter().cloned().collect(),
|
||||
},
|
||||
};
|
||||
Some(tabs::moderation::moderation_tab(
|
||||
config,
|
||||
|
||||
@@ -53,6 +53,8 @@ struct UserDetailQuery {
|
||||
delete_all_messages_dry_run: Option<String>,
|
||||
delete_all_messages_channel_count: Option<u64>,
|
||||
delete_all_messages_message_count: Option<u64>,
|
||||
report_id: Option<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -380,6 +382,8 @@ async fn user_tab(
|
||||
delete_all_messages_dry_run: None,
|
||||
delete_all_messages_channel_count: None,
|
||||
delete_all_messages_message_count: None,
|
||||
report_id: None,
|
||||
message: None,
|
||||
};
|
||||
let admin_acls = auth
|
||||
.0
|
||||
@@ -458,6 +462,8 @@ fn to_tab_query(q: &UserDetailQuery) -> user_tabs::TabQuery {
|
||||
delete_all_messages_dry_run: q.delete_all_messages_dry_run.clone(),
|
||||
delete_all_messages_channel_count: q.delete_all_messages_channel_count,
|
||||
delete_all_messages_message_count: q.delete_all_messages_message_count,
|
||||
report_id: q.report_id.clone(),
|
||||
message: q.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,854 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
admin_flags::{
|
||||
self, COPYRIGHT_REASON_CODE, ENFORCEMENT_REASONS, EnforcementHandling, user_flag_bits,
|
||||
},
|
||||
api::{
|
||||
client::ApiResult,
|
||||
enforcement::DecisionSubmission,
|
||||
generated::{snowflake, types as generated_types},
|
||||
},
|
||||
templates::components::form::checkbox,
|
||||
utils::{forms::MultiValueForm, plural::count_noun, timestamps::format_admin_timestamp},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub const FIELD_LABEL_CLASS: &str = "block text-sm font-medium text-neutral-700";
|
||||
pub const FIELD_HELP_CLASS: &str = "text-xs text-neutral-500";
|
||||
pub const FIELD_INPUT_CLASS: &str = "block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
pub const INTERNAL_NOTE_MAX_CHARS: usize = 512;
|
||||
const STATEMENT_MAX_CHARS: usize = 500;
|
||||
const ACCOUNT_DELETION_MIN_DAYS: u32 = 14;
|
||||
const ENFORCEMENT_CLOSURE_MIN_DAYS: u32 = 74;
|
||||
const RIGHTS_HOLDER_MAX_CHARS: usize = 200;
|
||||
|
||||
const AUTOMATED_LIMIT_FLAGS: &[(u64, &str)] = &[
|
||||
(user_flag_bits::ACCOUNT_LIMITED, "Account limited"),
|
||||
(user_flag_bits::PROFILE_HIDDEN, "Profile hidden"),
|
||||
];
|
||||
|
||||
const HANDLING_GROUPS: &[(EnforcementHandling, &str)] = &[
|
||||
(EnforcementHandling::Standard, "Standard"),
|
||||
(
|
||||
EnforcementHandling::Protected,
|
||||
"Protected: fewer details in the notice",
|
||||
),
|
||||
(
|
||||
EnforcementHandling::Restricted,
|
||||
"Restricted: no details in the notice",
|
||||
),
|
||||
];
|
||||
|
||||
pub struct DecisionFields<'a> {
|
||||
pub id_prefix: &'a str,
|
||||
pub kind: generated_types::EnforcementKindType,
|
||||
pub report_ids: &'a [String],
|
||||
pub messages: Option<&'a [String]>,
|
||||
pub user_flags: Option<u64>,
|
||||
pub allow_restricted: bool,
|
||||
}
|
||||
|
||||
pub struct BasisFields<'a> {
|
||||
pub decision: DecisionFields<'a>,
|
||||
pub choices: &'a [(&'a str, &'a str)],
|
||||
pub plain_note_help: &'a str,
|
||||
pub enforcement_extra: Markup,
|
||||
}
|
||||
|
||||
pub enum BasisSubmission {
|
||||
Enforcement(DecisionSubmission),
|
||||
HolderRequest(Option<String>),
|
||||
Housekeeping(Option<String>),
|
||||
}
|
||||
|
||||
impl BasisSubmission {
|
||||
pub fn internal_note(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Enforcement(submission) => Some(&submission.internal_note),
|
||||
Self::HolderRequest(note) | Self::Housekeeping(note) => note.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn moderation_basis(&self) -> generated_types::ModerationBasis {
|
||||
match self {
|
||||
Self::Enforcement(submission) => {
|
||||
generated_types::ModerationBasis::Enforcement(submission.decision.clone())
|
||||
}
|
||||
Self::HolderRequest(_) => generated_types::ModerationBasis::HolderRequest,
|
||||
Self::Housekeeping(_) => generated_types::ModerationBasis::Housekeeping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn automated_limits(flags: u64) -> Vec<&'static str> {
|
||||
AUTOMATED_LIMIT_FLAGS
|
||||
.iter()
|
||||
.filter(|(bit, _)| flags & bit != 0)
|
||||
.map(|(_, label)| *label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn field_label(for_id: &str, text: &str) -> Markup {
|
||||
html! {
|
||||
label for=(for_id) class=(FIELD_LABEL_CLASS) { (text) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal_note_field(id_prefix: &str, help: &str) -> Markup {
|
||||
let id = format!("{id_prefix}-internal-note");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Internal note"))
|
||||
input type="text" id=(id) name="internal_note" required
|
||||
maxlength=(INTERNAL_NOTE_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deletion_days_field(id: &str, self_hosted: bool) -> Markup {
|
||||
let (days, help) = if self_hosted {
|
||||
(
|
||||
ACCOUNT_DELETION_MIN_DAYS,
|
||||
format!("At least {ACCOUNT_DELETION_MIN_DAYS} days."),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
ENFORCEMENT_CLOSURE_MIN_DAYS,
|
||||
format!(
|
||||
"At least {ENFORCEMENT_CLOSURE_MIN_DAYS} days when a rule was broken. At least {ACCOUNT_DELETION_MIN_DAYS} days otherwise."
|
||||
),
|
||||
)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(id, "Days until deletion"))
|
||||
input type="number" id=(id) name="days_until_deletion"
|
||||
value=(days) min="1" max="365" required class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn statement_field(id_prefix: &str, help: &str) -> Markup {
|
||||
let id = format!("{id_prefix}-statement");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Note to the user"))
|
||||
textarea id=(id) name="statement" rows="2" maxlength=(STATEMENT_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn radio_group(legend: &str, name: &str, id_prefix: &str, options: &[(&str, &str)]) -> Markup {
|
||||
html! {
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { (legend) }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for (value, label) in options {
|
||||
@let id = format!("{id_prefix}-{name}-{value}");
|
||||
div class="flex items-center gap-2" {
|
||||
input type="radio" id=(id) name=(name) value=(value) required
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(id) class="text-sm text-neutral-900" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn codes_attribute(handling: EnforcementHandling) -> String {
|
||||
ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.handling == handling)
|
||||
.map(|reason| reason.code.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn reason_select(
|
||||
id_prefix: &str,
|
||||
kind: generated_types::EnforcementKindType,
|
||||
allow_restricted: bool,
|
||||
) -> Markup {
|
||||
let id = format!("{id_prefix}-reason");
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(field_label(&id, "Reason"))
|
||||
select id=(id) name="reason_code" required class=(FIELD_INPUT_CLASS) {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for (handling, group) in HANDLING_GROUPS
|
||||
.iter()
|
||||
.filter(|(handling, _)| allow_restricted || *handling != EnforcementHandling::Restricted)
|
||||
{
|
||||
@let reasons = ENFORCEMENT_REASONS
|
||||
.iter()
|
||||
.filter(|reason| reason.handling == *handling && reason.allows(kind))
|
||||
.collect::<Vec<_>>();
|
||||
@if !reasons.is_empty() {
|
||||
optgroup label=(group) {
|
||||
@for reason in reasons {
|
||||
option value=(reason.code) {
|
||||
(admin_flags::deletion_reason_label(reason.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn automated_limits_hint(flags: u64) -> Markup {
|
||||
let limits = automated_limits(flags);
|
||||
html! {
|
||||
p class=(FIELD_HELP_CLASS) data-automated-limits="" {
|
||||
@if limits.is_empty() {
|
||||
"No automated limits on this account now."
|
||||
} @else {
|
||||
"Automated limits on this account now: " (limits.join(", ")) "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforcement_fields(fields: &DecisionFields<'_>) -> Markup {
|
||||
let prefix = fields.id_prefix;
|
||||
let restricted_codes = codes_attribute(EnforcementHandling::Restricted);
|
||||
let reports_id = format!("{prefix}-report-ids");
|
||||
let messages_id = format!("{prefix}-evidence");
|
||||
let rights_holder_id = format!("{prefix}-rights-holder");
|
||||
html! {
|
||||
div class="space-y-4" data-enforcement-fields="" {
|
||||
(reason_select(prefix, fields.kind, fields.allow_restricted))
|
||||
(radio_group("How did we find this?", "source", prefix, &[
|
||||
("report", "A report"),
|
||||
("own_review", "Our own review"),
|
||||
]))
|
||||
div class="space-y-1" {
|
||||
(radio_group("Automated tools", "automation", prefix, &[
|
||||
("none", "Not involved"),
|
||||
("flagged", "Flagged this for review"),
|
||||
]))
|
||||
@if let Some(flags) = fields.user_flags {
|
||||
(automated_limits_hint(flags))
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
(field_label(&reports_id, "Reports"))
|
||||
textarea id=(reports_id) name="report_ids" rows="2"
|
||||
placeholder="One report ID per line" class=(FIELD_INPUT_CLASS) {
|
||||
(fields.report_ids.join("\n"))
|
||||
}
|
||||
}
|
||||
@if let Some(messages) = fields.messages {
|
||||
div class="space-y-1" {
|
||||
(field_label(&messages_id, "Messages"))
|
||||
textarea id=(messages_id) name="evidence" rows="3"
|
||||
placeholder="One message link or channel_id/message_id per line"
|
||||
class=(FIELD_INPUT_CLASS) {
|
||||
(messages.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
div data-hide-for-codes=(restricted_codes) {
|
||||
(statement_field(prefix, "Shown to the user. Never name or describe other people. Not sent for child safety or violent extremism decisions."))
|
||||
}
|
||||
div class="space-y-1" data-show-for-codes=(COPYRIGHT_REASON_CODE) hidden {
|
||||
(field_label(&rights_holder_id, "Rights holder or right concerned"))
|
||||
input type="text" id=(rights_holder_id) name="rights_holder" required disabled
|
||||
maxlength=(RIGHTS_HOLDER_MAX_CHARS) class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"For example Example Records, or trademark. This is the one place a name is shown to the user."
|
||||
}
|
||||
}
|
||||
div class="space-y-2" data-show-for-codes=(restricted_codes) hidden {
|
||||
(checkbox("confirmations", "evidence_preserved", "Evidence is preserved", false, true))
|
||||
(checkbox("confirmations", "reports_filed_or_not_required", "Any required report is filed, or none is required", false, true))
|
||||
}
|
||||
(internal_note_field(prefix, "Staff only. Saved as the first note on the decision."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const NETWORK_BLOCK_DEFAULT_DAYS: u64 = 90;
|
||||
|
||||
fn network_note(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> String {
|
||||
use generated_types::NetworkBlockCandidatesResponseNetworksItemSkipReason as SkipReason;
|
||||
match network.skip_reason {
|
||||
Some(SkipReason::Exempt) => "Exempt, never blocked".to_owned(),
|
||||
Some(SkipReason::SpecialPurpose) => {
|
||||
"Private or special-purpose range, never blocked".to_owned()
|
||||
}
|
||||
None => {
|
||||
let others = u64::try_from(*network.other_unrestricted_accounts).unwrap_or(0);
|
||||
format!(
|
||||
"{} last active here",
|
||||
count_noun(
|
||||
others,
|
||||
"other unrestricted account",
|
||||
"other unrestricted accounts"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_blockable(network: &generated_types::NetworkBlockCandidatesResponseNetworksItem) -> bool {
|
||||
network.skip_reason.is_none() && *network.other_unrestricted_accounts == 0
|
||||
}
|
||||
|
||||
pub fn network_block_fields(
|
||||
id_prefix: &str,
|
||||
candidates: &ApiResult<generated_types::NetworkBlockCandidatesResponse>,
|
||||
) -> Markup {
|
||||
let days_id = format!("{id_prefix}-network-block-days");
|
||||
html! {
|
||||
fieldset class="space-y-2" data-network-blocks="" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Block networks this account used" }
|
||||
@match candidates {
|
||||
Err(_) => {
|
||||
p class="text-sm text-red-700" { "Failed to load the networks this account used." }
|
||||
}
|
||||
Ok(response) if response.networks.is_empty() => {
|
||||
p class=(FIELD_HELP_CLASS) { "This account used no networks in the last 30 days." }
|
||||
}
|
||||
Ok(response) => {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Networks from the last 30 days. A network other unrestricted accounts use can't be blocked."
|
||||
}
|
||||
@for network in &response.networks {
|
||||
div {
|
||||
(checkbox("block_networks", &network.ip, &network.ip, false, is_blockable(network)))
|
||||
p class={(FIELD_HELP_CLASS) " pl-7"} {
|
||||
"Last used " (format_admin_timestamp(&network.last_seen_at)) ". " (network_note(network)) "."
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
(field_label(&days_id, "Block length in days"))
|
||||
input type="number" id=(days_id) name="network_block_days"
|
||||
value=(NETWORK_BLOCK_DEFAULT_DAYS) min="1" max="365" class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "Ending the decision or deleting the account removes the blocks sooner." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn network_blocks_from_form(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<Option<generated_types::NetworkBlockRequest>, String> {
|
||||
let ips = form
|
||||
.values("block_networks")
|
||||
.iter()
|
||||
.map(|ip| ip.trim().to_owned())
|
||||
.filter(|ip| !ip.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if ips.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let days = match form.parse_value::<u64>("network_block_days") {
|
||||
Ok(Some(days)) => days,
|
||||
Ok(None) => NETWORK_BLOCK_DEFAULT_DAYS,
|
||||
Err(_) => return Err("Enter the block length as a number of days".to_owned()),
|
||||
};
|
||||
let days =
|
||||
std::num::NonZeroU64::new(days).ok_or("The block length must be at least one day")?;
|
||||
Ok(Some(generated_types::NetworkBlockRequest { days, ips }))
|
||||
}
|
||||
|
||||
pub fn skipped_networks_summary(
|
||||
skipped: &[generated_types::SkippedNetworkBlock],
|
||||
) -> Option<String> {
|
||||
use generated_types::SkippedNetworkBlockReason as Reason;
|
||||
if skipped.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entries = skipped
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let reason = match entry.reason {
|
||||
Reason::Exempt => "exempt",
|
||||
Reason::SpecialPurpose => "special-purpose range",
|
||||
Reason::AlreadyBlocked => "already blocked",
|
||||
};
|
||||
format!("{} ({reason})", entry.ip)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(format!("Not blocked: {}.", entries.join(", ")))
|
||||
}
|
||||
|
||||
pub fn basis_fields(fields: &BasisFields<'_>) -> Markup {
|
||||
let prefix = fields.decision.id_prefix;
|
||||
let plain = fields
|
||||
.choices
|
||||
.iter()
|
||||
.map(|(value, _)| *value)
|
||||
.filter(|value| *value != "enforcement")
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
html! {
|
||||
(radio_group("Basis", "basis", prefix, fields.choices))
|
||||
fieldset class="space-y-4" data-show-for-basis="enforcement" hidden disabled {
|
||||
(enforcement_fields(&fields.decision))
|
||||
(fields.enforcement_extra)
|
||||
}
|
||||
fieldset data-show-for-basis=(plain) hidden disabled {
|
||||
(internal_note_field(&format!("{prefix}-plain"), fields.plain_note_help))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn basis_from_form(form: &MultiValueForm) -> Result<BasisSubmission, String> {
|
||||
match form.first("basis") {
|
||||
Some("enforcement") => decision_from_form(form).map(BasisSubmission::Enforcement),
|
||||
Some("holder_request") => Ok(BasisSubmission::HolderRequest(form.clean("internal_note"))),
|
||||
Some("housekeeping") => Ok(BasisSubmission::Housekeeping(form.clean("internal_note"))),
|
||||
_ => Err("Choose a basis".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision_from_form(form: &MultiValueForm) -> Result<DecisionSubmission, String> {
|
||||
let reason_code = form
|
||||
.first("reason_code")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok())
|
||||
.ok_or("Choose a reason")?;
|
||||
let source = form
|
||||
.first("source")
|
||||
.and_then(|value| generated_types::EnforcementDecisionInputSource::try_from(value).ok())
|
||||
.ok_or("Choose how we found this")?;
|
||||
let automation = form
|
||||
.first("automation")
|
||||
.and_then(|value| generated_types::EnforcementDecisionInputAutomation::try_from(value).ok())
|
||||
.ok_or("Choose whether automated tools were involved")?;
|
||||
let report_ids = lines(form.first("report_ids"))
|
||||
.map(|line| {
|
||||
is_snowflake(line)
|
||||
.then(|| snowflake(line))
|
||||
.ok_or_else(|| format!("Not a report ID: {line}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let evidence = lines(form.first("evidence"))
|
||||
.map(|line| {
|
||||
parse_message_reference(line)
|
||||
.map(|(channel_id, message_id)| {
|
||||
generated_types::EnforcementDecisionInputEvidenceItem {
|
||||
channel_id: snowflake(&channel_id),
|
||||
message_id: snowflake(&message_id),
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| format!("Not a message link or channel_id/message_id: {line}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let confirmations = form
|
||||
.values("confirmations")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::EnforcementDecisionInputConfirmationsItem::try_from(value.as_str())
|
||||
.map_err(|_| format!("Unknown confirmation: {value}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let internal_note = form.clean("internal_note").ok_or("Add an internal note")?;
|
||||
Ok(DecisionSubmission {
|
||||
decision: generated_types::EnforcementDecisionInput {
|
||||
automation,
|
||||
confirmations,
|
||||
evidence,
|
||||
reason_code,
|
||||
report_ids,
|
||||
rights_holder: form.clean("rights_holder"),
|
||||
source,
|
||||
statement: form.clean("statement"),
|
||||
},
|
||||
internal_note,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn bulk_decision_from_form(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<generated_types::BulkEnforcementDecisionInput, String> {
|
||||
let reason_code = form
|
||||
.first("reason_code")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
.and_then(|code| generated_types::EnforcementReasonCodeType::try_from(code).ok())
|
||||
.ok_or("Choose a reason")?;
|
||||
let automation = form
|
||||
.first("automation")
|
||||
.and_then(|value| {
|
||||
generated_types::BulkEnforcementDecisionInputAutomation::try_from(value).ok()
|
||||
})
|
||||
.ok_or("Choose whether automated tools were involved")?;
|
||||
let statement = form.clean("statement").ok_or("Add a note to the user")?;
|
||||
Ok(generated_types::BulkEnforcementDecisionInput {
|
||||
automation,
|
||||
reason_code,
|
||||
source: generated_types::BulkEnforcementDecisionInputSource::OwnReview,
|
||||
statement,
|
||||
})
|
||||
}
|
||||
|
||||
fn lines(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn is_snowflake(value: &str) -> bool {
|
||||
(1..=20).contains(&value.len()) && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn snowflake_pair(channel_id: &str, message_id: &str) -> Option<(String, String)> {
|
||||
(is_snowflake(channel_id) && is_snowflake(message_id))
|
||||
.then(|| (channel_id.to_owned(), message_id.to_owned()))
|
||||
}
|
||||
|
||||
pub fn parse_message_reference(line: &str) -> Option<(String, String)> {
|
||||
let line = line.trim();
|
||||
if let Some((channel_id, message_id)) = line.split_once(['/', ','])
|
||||
&& let Some(pair) = snowflake_pair(channel_id.trim(), message_id.trim())
|
||||
{
|
||||
return Some(pair);
|
||||
}
|
||||
let url = url::Url::parse(line)
|
||||
.or_else(|_| url::Url::parse("https://admin.invalid/")?.join(line))
|
||||
.ok()?;
|
||||
let query_value = |key: &str| {
|
||||
url.query_pairs()
|
||||
.find(|(name, _)| name == key)
|
||||
.map(|(_, value)| value.into_owned())
|
||||
};
|
||||
if let (Some(channel_id), Some(message_id)) =
|
||||
(query_value("channel_id"), query_value("message_id"))
|
||||
{
|
||||
return snowflake_pair(&channel_id, &message_id);
|
||||
}
|
||||
let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect();
|
||||
match segments.as_slice() {
|
||||
[.., "channels", _, channel_id, message_id] => snowflake_pair(channel_id, message_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TakeAction<'a> {
|
||||
pub author_id: Option<&'a str>,
|
||||
pub report_id: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn can_take_action(admin_acls: &[String]) -> bool {
|
||||
acl::has_any_permission(admin_acls, &[acl::USER_TEMP_BAN, acl::USER_UPDATE_FLAGS])
|
||||
}
|
||||
|
||||
pub fn take_action_href(
|
||||
base: &str,
|
||||
user_id: &str,
|
||||
report_id: Option<&str>,
|
||||
message: Option<(&str, &str)>,
|
||||
) -> String {
|
||||
let mut href = format!("{base}/users/{user_id}?tab=moderation");
|
||||
if let Some(report_id) = report_id {
|
||||
href.push_str("&report_id=");
|
||||
href.push_str(&urlencoding::encode(report_id));
|
||||
}
|
||||
if let Some((channel_id, message_id)) = message {
|
||||
href.push_str("&message=");
|
||||
href.push_str(&urlencoding::encode(&format!("{channel_id}/{message_id}")));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types::EnforcementKindType;
|
||||
|
||||
fn form(body: &str) -> MultiValueForm {
|
||||
MultiValueForm::parse(body.as_bytes())
|
||||
}
|
||||
|
||||
fn render(kind: EnforcementKindType, flags: u64) -> String {
|
||||
enforcement_fields(&DecisionFields {
|
||||
id_prefix: "suspend",
|
||||
kind,
|
||||
report_ids: &["1600000000000000001".to_owned()],
|
||||
messages: Some(&["1700000000000000001/1700000000000000002".to_owned()]),
|
||||
user_flags: Some(flags),
|
||||
allow_restricted: true,
|
||||
})
|
||||
.into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_days_default_to_the_closure_floor_of_each_mode() {
|
||||
let hosted = deletion_days_field("close-days", false).into_string();
|
||||
assert!(hosted.contains(r#"name="days_until_deletion" value="74""#));
|
||||
assert!(
|
||||
hosted.contains("At least 74 days when a rule was broken. At least 14 days otherwise.")
|
||||
);
|
||||
let self_hosted = deletion_days_field("close-days", true).into_string();
|
||||
assert!(self_hosted.contains(r#"name="days_until_deletion" value="14""#));
|
||||
assert!(self_hosted.contains(">At least 14 days.</p>"));
|
||||
assert!(!self_hosted.contains("74"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fieldset_renders_every_input_with_prefills() {
|
||||
let html = render(EnforcementKindType::Suspension, 0);
|
||||
for marker in [
|
||||
r#"name="reason_code""#,
|
||||
r#"name="source" value="report""#,
|
||||
r#"name="source" value="own_review""#,
|
||||
r#"name="automation" value="none""#,
|
||||
r#"name="automation" value="flagged""#,
|
||||
r#"name="report_ids""#,
|
||||
r#"name="evidence""#,
|
||||
r#"name="statement""#,
|
||||
r#"name="rights_holder""#,
|
||||
r#"name="confirmations" value="evidence_preserved""#,
|
||||
r#"name="confirmations" value="reports_filed_or_not_required""#,
|
||||
r#"name="internal_note""#,
|
||||
">1600000000000000001</textarea>",
|
||||
">1700000000000000001/1700000000000000002</textarea>",
|
||||
"No automated limits on this account now.",
|
||||
] {
|
||||
assert!(html.contains(marker), "missing {marker}\n{html}");
|
||||
}
|
||||
assert!(!html.contains(r#"name="automation" value="automated""#));
|
||||
assert!(!html.contains(r#"checked"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rights_holder_and_confirmations_start_hidden_and_follow_their_codes() {
|
||||
let html = render(EnforcementKindType::Suspension, 0);
|
||||
assert!(html.contains(r#"data-show-for-codes="31" hidden"#));
|
||||
assert!(html.contains(r#"data-show-for-codes="9 13 24" hidden"#));
|
||||
assert!(html.contains(r#"data-hide-for-codes="9 13 24""#));
|
||||
assert!(html.contains(r#"name="rights_holder" required disabled"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_are_grouped_by_handling_and_filtered_by_kind() {
|
||||
let suspend = render(EnforcementKindType::Suspension, 0);
|
||||
assert!(suspend.contains(
|
||||
r#"<optgroup label="Restricted: no details in the notice"><option value="9">"#
|
||||
));
|
||||
assert!(suspend.contains(
|
||||
r#"<optgroup label="Protected: fewer details in the notice"><option value="7">"#
|
||||
));
|
||||
for omitted in [1, 2, 19, 30, 31] {
|
||||
assert!(
|
||||
!suspend.contains(&format!(r#"<option value="{omitted}">"#)),
|
||||
"suspension offers {omitted}"
|
||||
);
|
||||
}
|
||||
let warn = render(EnforcementKindType::Warning, 0);
|
||||
assert!(!warn.contains("Restricted: no details"));
|
||||
assert!(warn.contains(r#"<option value="31">Copyright</option>"#));
|
||||
assert!(warn.contains(r#"<option value="29">"#));
|
||||
assert!(!warn.contains(r#"<option value="30">"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_limits_list_the_neutral_hook_flags() {
|
||||
let html = render(
|
||||
EnforcementKindType::Warning,
|
||||
user_flag_bits::ACCOUNT_LIMITED
|
||||
| user_flag_bits::PROFILE_HIDDEN
|
||||
| user_flag_bits::SPAMMER,
|
||||
);
|
||||
assert!(
|
||||
html.contains("Automated limits on this account now: Account limited, Profile hidden.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_builds_the_request() {
|
||||
let submission = decision_from_form(&form(
|
||||
"reason_code=31&source=report&automation=flagged\
|
||||
&report_ids=1600000000000000001%0D%0A%0D%0A1600000000000000002\
|
||||
&evidence=1700000000000000001%2F1700000000000000002%0Ahttps%3A%2F%2Fweb.example%2Fchannels%2F%40me%2F1700000000000000003%2F1700000000000000004\
|
||||
&statement=+Please+stop+&rights_holder=Example+Records\
|
||||
&internal_note=Ticket+42",
|
||||
))
|
||||
.expect("valid form");
|
||||
assert_eq!(submission.internal_note, "Ticket 42");
|
||||
let body = serde_json::to_value(&submission.decision).expect("serializable decision");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"reason_code": 31,
|
||||
"source": "report",
|
||||
"automation": "flagged",
|
||||
"report_ids": ["1600000000000000001", "1600000000000000002"],
|
||||
"evidence": [
|
||||
{"channel_id": "1700000000000000001", "message_id": "1700000000000000002"},
|
||||
{"channel_id": "1700000000000000003", "message_id": "1700000000000000004"}
|
||||
],
|
||||
"statement": "Please stop",
|
||||
"rights_holder": "Example Records"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_sends_restricted_confirmations_and_omits_empty_fields() {
|
||||
let submission = decision_from_form(&form(
|
||||
"reason_code=13&source=own_review&automation=none&report_ids=&evidence=1%2F2\
|
||||
&statement=&confirmations=evidence_preserved\
|
||||
&confirmations=reports_filed_or_not_required&internal_note=Filed",
|
||||
))
|
||||
.expect("valid form");
|
||||
let body = serde_json::to_value(&submission.decision).expect("serializable decision");
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"reason_code": 13,
|
||||
"source": "own_review",
|
||||
"automation": "none",
|
||||
"evidence": [{"channel_id": "1", "message_id": "2"}],
|
||||
"confirmations": ["evidence_preserved", "reports_filed_or_not_required"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_from_form_refuses_incomplete_input() {
|
||||
let cases = [
|
||||
(
|
||||
"source=report&automation=none&internal_note=x",
|
||||
"Choose a reason",
|
||||
),
|
||||
(
|
||||
"reason_code=2&source=report&automation=none&internal_note=x",
|
||||
"Choose a reason",
|
||||
),
|
||||
(
|
||||
"reason_code=3&automation=none&internal_note=x",
|
||||
"Choose how we found this",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=automated&internal_note=x",
|
||||
"Choose whether automated tools were involved",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=+",
|
||||
"Add an internal note",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=x&report_ids=abc",
|
||||
"Not a report ID: abc",
|
||||
),
|
||||
(
|
||||
"reason_code=3&source=report&automation=none&internal_note=x&evidence=hello",
|
||||
"Not a message link or channel_id/message_id: hello",
|
||||
),
|
||||
];
|
||||
for (body, expected) in cases {
|
||||
let error = decision_from_form(&form(body)).err();
|
||||
assert_eq!(error.as_deref(), Some(expected), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_fields_hide_the_decision_until_a_rule_was_broken() {
|
||||
let html = basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "shred",
|
||||
kind: EnforcementKindType::ContentRemoval,
|
||||
report_ids: &[],
|
||||
messages: None,
|
||||
user_flags: None,
|
||||
allow_restricted: false,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
],
|
||||
plain_note_help: "Staff only.",
|
||||
enforcement_extra: html! {},
|
||||
})
|
||||
.into_string();
|
||||
assert!(html.contains(r#"data-show-for-basis="enforcement" hidden disabled"#));
|
||||
assert!(
|
||||
html.contains(r#"data-show-for-basis="holder_request housekeeping" hidden disabled"#)
|
||||
);
|
||||
assert!(!html.contains(r#"name="evidence""#));
|
||||
assert!(!html.contains("Restricted: no details"));
|
||||
assert!(!html.contains("data-automated-limits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_from_form_reads_each_basis() {
|
||||
assert!(matches!(
|
||||
basis_from_form(&form("basis=holder_request&internal_note=Asked+by+email")),
|
||||
Ok(BasisSubmission::HolderRequest(Some(note))) if note == "Asked by email"
|
||||
));
|
||||
assert!(matches!(
|
||||
basis_from_form(&form("basis=housekeeping")),
|
||||
Ok(BasisSubmission::Housekeeping(None))
|
||||
));
|
||||
let enforcement = basis_from_form(&form(
|
||||
"basis=enforcement&reason_code=3&source=own_review&automation=none&internal_note=x",
|
||||
))
|
||||
.expect("valid enforcement basis");
|
||||
assert_eq!(enforcement.internal_note(), Some("x"));
|
||||
assert!(matches!(
|
||||
enforcement.moderation_basis(),
|
||||
generated_types::ModerationBasis::Enforcement(_)
|
||||
));
|
||||
assert_eq!(
|
||||
basis_from_form(&form("internal_note=x")).err().as_deref(),
|
||||
Some("Choose a basis")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_references_accept_ids_admin_links_and_app_links() {
|
||||
let expected = Some(("111".to_owned(), "222".to_owned()));
|
||||
for line in [
|
||||
"111/222",
|
||||
" 111 / 222 ",
|
||||
"111,222",
|
||||
"https://admin.example/messages?channel_id=111&message_id=222&context_limit=50",
|
||||
"/messages?channel_id=111&message_id=222",
|
||||
"https://web.example/channels/@me/111/222",
|
||||
"https://web.example/channels/999/111/222",
|
||||
] {
|
||||
assert_eq!(parse_message_reference(line), expected, "{line}");
|
||||
}
|
||||
for line in [
|
||||
"111",
|
||||
"abc/222",
|
||||
"https://web.example/channels/999/111",
|
||||
"https://web.example/invite/111/222",
|
||||
"/messages?channel_id=111",
|
||||
] {
|
||||
assert_eq!(parse_message_reference(line), None, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_action_links_open_the_moderation_tab_with_prefills() {
|
||||
assert_eq!(
|
||||
take_action_href("/admin", "1", Some("2"), Some(("3", "4"))),
|
||||
"/admin/users/1?tab=moderation&report_id=2&message=3%2F4"
|
||||
);
|
||||
assert_eq!(
|
||||
take_action_href("", "1", None, None),
|
||||
"/users/1?tab=moderation"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::enforcement_fields::{TakeAction, take_action_href};
|
||||
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};
|
||||
@@ -117,6 +117,7 @@ fn message_row(
|
||||
include_delete: bool,
|
||||
is_highlighted: bool,
|
||||
is_grouped: bool,
|
||||
take_action: Option<&str>,
|
||||
) -> Markup {
|
||||
let hover = if is_highlighted {
|
||||
" hover:bg-amber-100"
|
||||
@@ -152,6 +153,9 @@ fn message_row(
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
@@ -235,6 +239,9 @@ fn message_row(
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
@if let Some(href) = take_action {
|
||||
a href=(href) class="ml-2 text-blue-600 hover:underline" { "Take action" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
@@ -257,6 +264,7 @@ pub fn message_list(
|
||||
messages: &[Message],
|
||||
include_delete: bool,
|
||||
highlight_message_id: Option<&str>,
|
||||
take_action: Option<&TakeAction<'_>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@@ -268,68 +276,46 @@ pub fn message_list(
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
);
|
||||
@let take_action_href = take_action
|
||||
.filter(|action| {
|
||||
msg.webhook_id.is_none()
|
||||
&& action.author_id.is_none_or(|author_id| author_id == msg.author_id)
|
||||
})
|
||||
.map(|action| take_action_href(
|
||||
base_path,
|
||||
&msg.author_id,
|
||||
action.report_id,
|
||||
Some((&msg.channel_id, &msg.id)),
|
||||
));
|
||||
(message_row(
|
||||
base_path, &avatar_url, msg,
|
||||
include_delete, is_highlighted, is_grouped,
|
||||
take_action_href.as_deref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
let csrf = json_string(csrf_token);
|
||||
let script = r#"(function() {
|
||||
var csrf = __CSRF__;
|
||||
function bp() {
|
||||
return document.documentElement.dataset.basePath || '';
|
||||
}
|
||||
function toast(level, message) {
|
||||
document.body.dispatchEvent(new CustomEvent('showFlash', {detail: {level: level, message: message}}));
|
||||
}
|
||||
function post(action, fields) {
|
||||
fields.append('_csrf', csrf);
|
||||
return fetch(bp() + '/messages?action=' + action, {
|
||||
method: 'POST',
|
||||
body: fields,
|
||||
credentials: 'same-origin',
|
||||
headers: {'x-csrf-token': csrf}
|
||||
});
|
||||
}
|
||||
function deleteMessage(b) {
|
||||
var fields = new URLSearchParams();
|
||||
fields.append('channel_id', b.dataset.channelId || '');
|
||||
fields.append('message_id', b.dataset.messageId || '');
|
||||
b.disabled = true;
|
||||
b.textContent = 'Deleting...';
|
||||
toast('info', 'Deleting message...');
|
||||
post('delete', fields).then(function(r) {
|
||||
if (!r.ok) throw new Error('Failed');
|
||||
var row = b.closest('[data-message-id]');
|
||||
if (row) {
|
||||
row.style.opacity = '0.5';
|
||||
row.style.pointerEvents = 'none';
|
||||
}
|
||||
b.textContent = 'Deleted';
|
||||
toast('success', 'Message deleted.');
|
||||
}).catch(function() {
|
||||
b.disabled = false;
|
||||
b.textContent = 'Delete';
|
||||
toast('error', 'Failed to delete message.');
|
||||
});
|
||||
pub fn message_delete_prefill_script() -> Markup {
|
||||
html! {
|
||||
script defer { (PreEscaped(MESSAGE_DELETE_PREFILL_SCRIPT)) }
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_DELETE_PREFILL_SCRIPT: &str = r#"(function() {
|
||||
if (window.__fluxerMessageDeletePrefill) return;
|
||||
window.__fluxerMessageDeletePrefill = true;
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
var d = t.closest('.delete-message-btn');
|
||||
if (d instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
deleteMessage(d);
|
||||
}
|
||||
var button = t.closest('.delete-message-btn');
|
||||
var form = document.getElementById('delete-message-form');
|
||||
if (!(button instanceof HTMLButtonElement) || !form) return;
|
||||
e.preventDefault();
|
||||
form.querySelector('[name="channel_id"]').value = button.dataset.channelId || '';
|
||||
form.querySelector('[name="message_id"]').value = button.dataset.messageId || '';
|
||||
form.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
form.querySelector('[name="basis"]').focus({preventScroll: true});
|
||||
});
|
||||
})();"#
|
||||
.replace("__CSRF__", &csrf);
|
||||
html! {
|
||||
script defer { (PreEscaped(script)) }
|
||||
}
|
||||
}
|
||||
})();"#;
|
||||
@@ -9,6 +9,7 @@ pub mod data_field;
|
||||
pub mod detail_tabs;
|
||||
pub mod drawer;
|
||||
pub mod empty_state;
|
||||
pub mod enforcement_fields;
|
||||
pub mod error_display;
|
||||
pub mod form;
|
||||
pub mod icons;
|
||||
|
||||
@@ -10,8 +10,8 @@ use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
use super::components::drawer::drawer_controller_script;
|
||||
use super::layout_header::render_header;
|
||||
use super::layout_scripts::{
|
||||
ADMIN_ACTION_FORM_SCRIPT, COPY_TO_CLIPBOARD_SCRIPT, HTMX_FLASH_SCRIPT,
|
||||
HTMX_SCROLL_PRESERVER_SCRIPT, SH_LINK_REWRITE_SCRIPT, SIDEBAR_SCRIPT,
|
||||
ADMIN_ACTION_FORM_SCRIPT, COPY_TO_CLIPBOARD_SCRIPT, ENFORCEMENT_FIELDS_SCRIPT,
|
||||
HTMX_FLASH_SCRIPT, HTMX_SCROLL_PRESERVER_SCRIPT, SH_LINK_REWRITE_SCRIPT, SIDEBAR_SCRIPT,
|
||||
};
|
||||
use super::layout_sidebar::render_sidebar;
|
||||
|
||||
@@ -128,6 +128,7 @@ pub fn admin_layout_ext(
|
||||
script defer { (PreEscaped(HTMX_SCROLL_PRESERVER_SCRIPT)) }
|
||||
script defer { (PreEscaped(HTMX_FLASH_SCRIPT)) }
|
||||
script defer { (PreEscaped(COPY_TO_CLIPBOARD_SCRIPT)) }
|
||||
script defer { (PreEscaped(ENFORCEMENT_FIELDS_SCRIPT)) }
|
||||
(drawer_controller_script())
|
||||
@if let Some(extra) = options.extra_scripts {
|
||||
script defer { (PreEscaped(extra)) }
|
||||
|
||||
@@ -628,3 +628,44 @@ pub const SH_LINK_REWRITE_SCRIPT: &str = r#"
|
||||
})();
|
||||
|
||||
"#;
|
||||
|
||||
pub const ENFORCEMENT_FIELDS_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
if (window.__fluxerEnforcementFields) return;
|
||||
window.__fluxerEnforcementFields = true;
|
||||
function codes(section, name) {
|
||||
var value = section.getAttribute(name);
|
||||
return value === null ? null : value.split(' ');
|
||||
}
|
||||
function syncReason(form, code) {
|
||||
form.querySelectorAll('[data-show-for-codes], [data-hide-for-codes]').forEach(function (section) {
|
||||
var showFor = codes(section, 'data-show-for-codes');
|
||||
var hideFor = codes(section, 'data-hide-for-codes');
|
||||
var shown = showFor ? showFor.indexOf(code) >= 0 : hideFor.indexOf(code) < 0;
|
||||
section.hidden = !shown;
|
||||
section.querySelectorAll('input, textarea, select').forEach(function (control) {
|
||||
control.disabled = !shown;
|
||||
});
|
||||
});
|
||||
}
|
||||
function syncChoice(form, name, value) {
|
||||
form.querySelectorAll('[data-show-for-' + name + ']').forEach(function (section) {
|
||||
var shown = codes(section, 'data-show-for-' + name).indexOf(value) >= 0;
|
||||
section.hidden = !shown;
|
||||
section.disabled = !shown;
|
||||
});
|
||||
}
|
||||
document.addEventListener('change', function (event) {
|
||||
var control = event.target;
|
||||
var form = control instanceof Element ? control.closest('form') : null;
|
||||
if (!form) return;
|
||||
if (control instanceof HTMLSelectElement && control.name === 'reason_code') {
|
||||
syncReason(form, control.value);
|
||||
} else if (control instanceof HTMLSelectElement && (control.name === 'resolution' || control.name === 'duration_hours')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
} else if (control instanceof HTMLInputElement && control.type === 'radio' && (control.name === 'basis' || control.name === 'limit')) {
|
||||
syncChoice(form, control.name, control.value);
|
||||
}
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
@@ -40,6 +40,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
title: "Moderation",
|
||||
items: &[
|
||||
item!("Reports", "/reports", "reports", [acl::REPORT_VIEW]),
|
||||
item!(
|
||||
"Decisions",
|
||||
"/decisions",
|
||||
"decisions",
|
||||
[acl::ENFORCEMENT_VIEW]
|
||||
),
|
||||
item!(
|
||||
"Discovery Review",
|
||||
"/discovery",
|
||||
|
||||
@@ -21,11 +21,16 @@ pub fn format_action(action: &str) -> String {
|
||||
pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||
match action {
|
||||
"temp_ban" | "schedule_deletion" | "ban_ip" | "ban_email" => BadgeVariant::Danger,
|
||||
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" => BadgeVariant::Success,
|
||||
"update_flags" | "update_features" | "set_acls" | "update_settings" | "annotate_ban" => {
|
||||
BadgeVariant::Info
|
||||
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" | "end_enforcement_decision" => {
|
||||
BadgeVariant::Success
|
||||
}
|
||||
"delete_message" => BadgeVariant::Warning,
|
||||
"update_flags"
|
||||
| "update_features"
|
||||
| "set_acls"
|
||||
| "update_settings"
|
||||
| "annotate_ban"
|
||||
| "annotate_enforcement_decision" => BadgeVariant::Info,
|
||||
"delete_message" | "warn_user" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
}
|
||||
@@ -126,6 +131,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||
}
|
||||
}))
|
||||
},
|
||||
"enforcement_decision" => html! {
|
||||
a href={(base) "/decisions/" (entry.target_id)}
|
||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Decision" }
|
||||
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||
}
|
||||
}
|
||||
},
|
||||
"message" => html! {
|
||||
div class="flex flex-col" {
|
||||
span class="text-sm font-medium" { "Message" }
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct BanConfig {
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
pub add_title: Option<&'static str>,
|
||||
pub add_help: Option<&'static str>,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
@@ -53,6 +55,10 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
add_title: Some("Block a network"),
|
||||
add_help: Some(
|
||||
"For accounts, block networks from the account's suspension or closure instead.",
|
||||
),
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -65,6 +71,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -77,6 +85,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -89,6 +99,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -101,6 +113,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -113,6 +127,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -125,6 +141,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -137,6 +155,8 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
add_title: None,
|
||||
add_help: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -187,7 +207,14 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
h3 class="text-base font-medium text-neutral-900 mb-4" {
|
||||
"Ban " (cfg.input_label)
|
||||
@if let Some(title) = cfg.add_title {
|
||||
(title)
|
||||
} @else {
|
||||
"Ban " (cfg.input_label)
|
||||
}
|
||||
}
|
||||
@if let Some(help) = cfg.add_help {
|
||||
p class="text-sm text-neutral-500 mb-4" { (help) }
|
||||
}
|
||||
form method="post" action={(base) (cfg.route) "?action=ban&_csrf=" (csrf_token)}
|
||||
hx-post={(base) (cfg.route) "?action=ban&_csrf=" (csrf_token)}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::{DELETION_REASONS, DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::admin_flags::{DEPRECATED_GUILD_FEATURES, GUILD_FEATURES};
|
||||
use crate::{
|
||||
acl,
|
||||
api::generated::types::EnforcementKindType,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
FIELD_HELP_CLASS, FIELD_INPUT_CLASS, deletion_days_field, field_label,
|
||||
internal_note_field, radio_group, reason_select,
|
||||
},
|
||||
form::{
|
||||
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||
form_field_group, opt_out_checkbox, select_chevron, submit_button, text_input,
|
||||
textarea_input,
|
||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, submit_button,
|
||||
text_input, textarea_input,
|
||||
},
|
||||
page_container::page_header,
|
||||
section_card::section_card_simple,
|
||||
@@ -28,12 +32,7 @@ fn guild_feature_label(feature: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_actions_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
@@ -54,7 +53,7 @@ pub fn bulk_actions_page(
|
||||
(bulk_add_guild_members_section(base, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
|
||||
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
|
||||
(bulk_close_accounts_section(base, config.self_hosted, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
|
||||
(bulk_delete_user_messages_section(base, csrf_token))
|
||||
@@ -187,53 +186,53 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
|
||||
fn bulk_decision_fields(
|
||||
id_prefix: &str,
|
||||
kind: EnforcementKindType,
|
||||
statement_help: &str,
|
||||
) -> Markup {
|
||||
let statement_id = format!("{id_prefix}-statement");
|
||||
html! {
|
||||
fieldset class="space-y-4" data-show-for-basis="enforcement" hidden disabled {
|
||||
(reason_select(id_prefix, kind, false))
|
||||
(radio_group("Automated tools", "automation", id_prefix, &[
|
||||
("none", "Not involved"),
|
||||
("flagged", "Flagged this for review"),
|
||||
]))
|
||||
div class="space-y-1" {
|
||||
(field_label(&statement_id, "Note to the user"))
|
||||
textarea id=(statement_id) name="statement" rows="2" maxlength="500" required
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { (statement_help) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_close_accounts_section(base: &str, self_hosted: bool, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Schedule User Deletion",
|
||||
"Close accounts",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-schedule-user-deletion"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||
html! {
|
||||
div class="relative" {
|
||||
select id="reason_code" name="reason_code" required
|
||||
class=(FORM_SELECT_CLASS) {
|
||||
option value="" selected { "Select a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
},
|
||||
(radio_group("Basis", "basis", "bulk-close", &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holders asked"),
|
||||
("housekeeping", "Inactivity"),
|
||||
]))
|
||||
(deletion_days_field("bulk-close-days", self_hosted))
|
||||
(bulk_decision_fields(
|
||||
"bulk-close",
|
||||
EnforcementKindType::Closure,
|
||||
"Shown to every account in the job. It is the only fact a bulk closure can give, because messages and reports cannot be attached in bulk. Never name or describe other people.",
|
||||
))
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Users that already have a pending deletion are skipped and listed as failed with the reason already scheduled. Cancel those from the user page first to schedule them again."
|
||||
(internal_note_field("bulk-close", "Staff only. Saved as the first note on each decision and in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Accounts that already have a pending deletion are skipped and listed as failed. Each account closed for a broken rule gets its own decision and notice."
|
||||
}
|
||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||
html! {
|
||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||
value="60" min="14" max="365" required
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-2 focus:ring-brand-primary/20";
|
||||
},
|
||||
))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
@if username_sign_in {
|
||||
input type="hidden" name="notify_user_present" value="1";
|
||||
} @else {
|
||||
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
|
||||
}
|
||||
(form_actions(html! {
|
||||
(danger_button("Schedule Deletion"))
|
||||
}))
|
||||
(form_actions(danger_button("Close accounts")))
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -242,19 +241,30 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in
|
||||
|
||||
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Delete User Messages",
|
||||
"Delete all messages",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes every message authored by each user across all channels. This cannot be undone."
|
||||
"Deletes every message each account wrote, in every channel. This cannot be undone."
|
||||
}
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
(radio_group("Basis", "basis", "bulk-delete-messages", &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holders asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
]))
|
||||
(bulk_decision_fields(
|
||||
"bulk-delete-messages",
|
||||
EnforcementKindType::ContentRemoval,
|
||||
"Shown to every account in the job. It is the only fact a bulk removal can give, because messages and reports cannot be attached in bulk. Never name or describe other people.",
|
||||
))
|
||||
(internal_note_field("bulk-delete-messages", "Staff only. Saved as the first note on each decision and in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Each account whose messages are removed for a broken rule gets its own decision and notice."
|
||||
}
|
||||
(form_actions(danger_button("Delete all messages")))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,791 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
admin_flags::{self, EnforcementHandling},
|
||||
api::{
|
||||
client::ApiResult,
|
||||
generated::types::{
|
||||
EnforcementDecisionListResponse, EnforcementDecisionListResponseDecisionsItem,
|
||||
EnforcementDecisionResponse, EnforcementDecisionResponseEvidenceItem,
|
||||
EnforcementDecisionResponseNotice, EnforcementKindType,
|
||||
},
|
||||
types::AdminUser,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
FIELD_HELP_CLASS, FIELD_INPUT_CLASS, FIELD_LABEL_CLASS, field_label,
|
||||
internal_note_field, statement_field,
|
||||
},
|
||||
form::{csrf_input, form_actions, submit_button},
|
||||
page_container::{page_header, page_header_with_back},
|
||||
section_card::section_card_simple,
|
||||
user_display::format_user_display,
|
||||
},
|
||||
layout::admin_layout,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const SYSTEM_USER_ID: &str = "0";
|
||||
const NOTE_MAX_CHARS: usize = 2000;
|
||||
const CORRECTION_LINE: &str = "If this decision was reported to an authority, file a correction.";
|
||||
const DECISIONS_PAGE_KEY: &str = "decisions";
|
||||
const PERMANENT_SUSPENSION_THRESHOLD_YEARS: i32 = 50;
|
||||
|
||||
pub fn kind_label(kind: EnforcementKindType) -> &'static str {
|
||||
match kind {
|
||||
EnforcementKindType::Warning => "Warning",
|
||||
EnforcementKindType::Suspension => "Suspension",
|
||||
EnforcementKindType::Closure => "Closure",
|
||||
EnforcementKindType::FeatureLimit => "Feature limit",
|
||||
EnforcementKindType::ContentRemoval => "Content removal",
|
||||
EnforcementKindType::ProfileReset => "Profile reset",
|
||||
EnforcementKindType::CommunityRemoval => "Community removal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state_label(
|
||||
status: &str,
|
||||
end_outcome: Option<&str>,
|
||||
replaced: bool,
|
||||
expired: bool,
|
||||
) -> &'static str {
|
||||
match (status, end_outcome) {
|
||||
("ended", Some("lifted")) => "Lifted",
|
||||
("ended", Some("reversed")) => "Reversed",
|
||||
("ended", Some("expired")) => "Expired",
|
||||
("ended", _) => "Ended",
|
||||
_ if replaced => "Replaced",
|
||||
_ if expired => "Expired",
|
||||
_ => "Active",
|
||||
}
|
||||
}
|
||||
|
||||
fn source_label(source: &str) -> &'static str {
|
||||
match source {
|
||||
"report" => "A report",
|
||||
_ => "Our own review",
|
||||
}
|
||||
}
|
||||
|
||||
fn automation_label(automation: &str) -> &'static str {
|
||||
match automation {
|
||||
"flagged" => "Flagged this for review",
|
||||
"automated" => "Decided by automated tools, waiting for a person",
|
||||
_ => "Not involved",
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_status_label(status: &str) -> &'static str {
|
||||
match status {
|
||||
"sent" => "Sent",
|
||||
"undelivered" => "Not delivered. The user sees it when they sign in",
|
||||
_ => "Pending",
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_channel_label(channel: &str) -> &'static str {
|
||||
match channel {
|
||||
"email" => "Email",
|
||||
"system_dm" => "System DM",
|
||||
_ => "Sign-in screen",
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_label(limit: &str) -> &'static str {
|
||||
match limit {
|
||||
"direct_messages" => "Direct messages",
|
||||
"reports" => "Reports",
|
||||
_ => "Purchases",
|
||||
}
|
||||
}
|
||||
|
||||
fn end_acl(kind: EnforcementKindType, removed_all_messages: bool) -> &'static str {
|
||||
match kind {
|
||||
EnforcementKindType::Suspension => acl::USER_TEMP_BAN,
|
||||
EnforcementKindType::Closure => acl::USER_DELETE,
|
||||
EnforcementKindType::Warning | EnforcementKindType::FeatureLimit => acl::USER_UPDATE_FLAGS,
|
||||
EnforcementKindType::ContentRemoval if removed_all_messages => acl::MESSAGE_DELETE_ALL,
|
||||
EnforcementKindType::ContentRemoval => acl::MESSAGE_SHRED,
|
||||
EnforcementKindType::ProfileReset => acl::USER_UPDATE_PROFILE,
|
||||
EnforcementKindType::CommunityRemoval => acl::GUILD_DELETE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_end(
|
||||
admin_acls: &[String],
|
||||
kind: EnforcementKindType,
|
||||
removed_all_messages: bool,
|
||||
) -> bool {
|
||||
acl::has_permission(admin_acls, acl::ENFORCEMENT_VIEW)
|
||||
&& acl::has_permission(admin_acls, end_acl(kind, removed_all_messages))
|
||||
}
|
||||
|
||||
pub fn can_lift(kind: EnforcementKindType) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
EnforcementKindType::Suspension
|
||||
| EnforcementKindType::Closure
|
||||
| EnforcementKindType::FeatureLimit
|
||||
)
|
||||
}
|
||||
|
||||
fn is_restricted(reason_code: i32) -> bool {
|
||||
admin_flags::enforcement_reason(reason_code)
|
||||
.is_some_and(|reason| reason.handling == EnforcementHandling::Restricted)
|
||||
}
|
||||
|
||||
pub fn ends_label(
|
||||
kind: EnforcementKindType,
|
||||
ends_at: Option<&str>,
|
||||
deletion_at: Option<&str>,
|
||||
) -> String {
|
||||
match (kind, ends_at, deletion_at) {
|
||||
(EnforcementKindType::Closure, _, Some(at)) => {
|
||||
format!("Deleted on {}", format_admin_timestamp(at))
|
||||
}
|
||||
(_, Some(at), _) => format!("Until {}", format_admin_timestamp(at)),
|
||||
(EnforcementKindType::Suspension, None, _) => "Permanent".to_owned(),
|
||||
(EnforcementKindType::FeatureLimit, None, _) => "No end date".to_owned(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn suspension_label(until: &str) -> String {
|
||||
let permanent =
|
||||
time::OffsetDateTime::parse(until, &time::format_description::well_known::Rfc3339)
|
||||
.is_ok_and(|until| {
|
||||
until.year() - time::OffsetDateTime::now_utc().year()
|
||||
>= PERMANENT_SUSPENSION_THRESHOLD_YEARS
|
||||
});
|
||||
if permanent {
|
||||
"Banned permanently".to_owned()
|
||||
} else {
|
||||
format!("Suspended until {}", format_admin_timestamp(until))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EndDecisionForm<'a> {
|
||||
pub base: &'a str,
|
||||
pub decision_id: &'a str,
|
||||
pub can_lift: bool,
|
||||
pub reason_code: i32,
|
||||
pub csrf_token: &'a str,
|
||||
pub refresh_target: &'a str,
|
||||
}
|
||||
|
||||
pub fn end_decision_form(form: &EndDecisionForm<'_>) -> Markup {
|
||||
let prefix = format!("end-{}", form.decision_id);
|
||||
let restricted = is_restricted(form.reason_code);
|
||||
let lifted_id = format!("{prefix}-outcome-lifted");
|
||||
let reversed_id = format!("{prefix}-outcome-reversed");
|
||||
html! {
|
||||
form method="post"
|
||||
action={(form.base) "/decisions/" (form.decision_id) "?action=end"}
|
||||
data-admin-refresh-on-success=(form.refresh_target)
|
||||
data-end-decision-form="" {
|
||||
(csrf_input(form.csrf_token))
|
||||
div class="space-y-4" {
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Outcome" }
|
||||
@if form.can_lift {
|
||||
div class="flex items-center gap-2" {
|
||||
input type="radio" id=(lifted_id) name="outcome" value="lifted" required
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(lifted_id) class="text-sm text-neutral-900" { "Lifted" }
|
||||
}
|
||||
}
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
input type="radio" id=(reversed_id) name="outcome" value="reversed" required
|
||||
class="peer h-4 w-4 accent-neutral-900";
|
||||
label for=(reversed_id) class="text-sm text-neutral-900" { "Reversed" }
|
||||
@if restricted {
|
||||
p class="hidden w-full text-sm text-amber-800 peer-checked:block" data-correction-line="" {
|
||||
(CORRECTION_LINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
@if form.can_lift { "Lifted ends a correct decision early. " }
|
||||
"Reversed says the decision was wrong."
|
||||
}
|
||||
}
|
||||
(statement_field(&prefix, "Shown to the user in the email. Never name or describe other people."))
|
||||
(internal_note_field(&prefix, "Staff only. Saved in the audit log."))
|
||||
(form_actions(submit_button("End decision")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_name(users: &[AdminUser], id: &str) -> String {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return "System".to_owned();
|
||||
}
|
||||
users.iter().find(|user| user.id == id).map_or_else(
|
||||
|| id.to_owned(),
|
||||
|user| {
|
||||
format_user_display(
|
||||
user.global_name.as_deref(),
|
||||
Some(&user.username),
|
||||
Some(&user.discriminator),
|
||||
user.bot,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn user_link(base: &str, users: &[AdminUser], id: &str) -> Markup {
|
||||
if id == SYSTEM_USER_ID {
|
||||
return html! { "System" };
|
||||
}
|
||||
html! {
|
||||
a href={(base) "/users/" (id)} class="text-blue-600 hover:underline" { (user_name(users, id)) }
|
||||
}
|
||||
}
|
||||
|
||||
fn field(label: &str, value: Markup) -> Markup {
|
||||
html! {
|
||||
div class="flex min-w-0 flex-col gap-1" {
|
||||
dt class="text-xs text-neutral-500" { (label) }
|
||||
dd class="break-words text-sm text-neutral-900" { (value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subject_link(base: &str, decision: &EnforcementDecisionResponse, users: &[AdminUser]) -> Markup {
|
||||
let id = decision.subject_id.as_str();
|
||||
if decision.subject_type.to_string() == "guild" {
|
||||
return html! {
|
||||
a href={(base) "/guilds/" (id)} class="text-blue-600 hover:underline" {
|
||||
(decision.subject_name.as_deref().unwrap_or(id))
|
||||
}
|
||||
};
|
||||
}
|
||||
user_link(base, users, id)
|
||||
}
|
||||
|
||||
fn summary_section(
|
||||
base: &str,
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
) -> Markup {
|
||||
let ends = ends_label(
|
||||
decision.kind,
|
||||
decision.ends_at.as_deref(),
|
||||
decision.deletion_at.as_deref(),
|
||||
);
|
||||
let end_outcome = decision.end_outcome.map(|outcome| outcome.to_string());
|
||||
html! {
|
||||
(section_card_simple("Decision", html! {
|
||||
dl class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(field("Status", html! {
|
||||
(state_label(&decision.status.to_string(), end_outcome.as_deref(), decision.replaced, decision.expired))
|
||||
}))
|
||||
(field("Kind", html! { (kind_label(decision.kind)) }))
|
||||
(field("Subject", subject_link(base, decision, users)))
|
||||
@if let Some(recipient) = decision.recipient_user_id.as_deref().filter(|id| *id != decision.subject_id.as_str()) {
|
||||
(field("Notice goes to", user_link(base, users, recipient)))
|
||||
}
|
||||
(field("Reason", html! {
|
||||
(admin_flags::deletion_reason_label(*decision.reason_code))
|
||||
@if let Some(rule) = &decision.notice.rule {
|
||||
" ("
|
||||
@if let Some(url) = &rule.url {
|
||||
a href=(url) target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline" { (rule.title) }
|
||||
} @else {
|
||||
(rule.title)
|
||||
}
|
||||
")"
|
||||
}
|
||||
}))
|
||||
(field("Decided", html! { (format_admin_timestamp(&decision.created_at)) }))
|
||||
(field("Decided by", user_link(base, users, &decision.decided_by)))
|
||||
@if !ends.is_empty() {
|
||||
(field("Ends", html! { (ends) }))
|
||||
}
|
||||
(field("How we found this", html! { (source_label(&decision.source.to_string())) }))
|
||||
(field("Automated tools", html! { (automation_label(&decision.automation.to_string())) }))
|
||||
@if let Some(limit) = decision.limit {
|
||||
(field("Limit", html! { (limit_label(&limit.to_string())) }))
|
||||
}
|
||||
@if !decision.profile_fields.is_empty() {
|
||||
(field("Profile fields", html! { (decision.profile_fields.join(", ")) }))
|
||||
}
|
||||
@if decision.removed_all_messages {
|
||||
(field("Removed messages", html! { "All messages" }))
|
||||
} @else if let Some(count) = &decision.removed_message_count {
|
||||
(field("Removed messages", html! { (**count) }))
|
||||
}
|
||||
@if let Some(at) = &decision.payment_at {
|
||||
(field("Charge date", html! { (format_admin_timestamp(at)) }))
|
||||
}
|
||||
@if let Some(rights_holder) = &decision.rights_holder {
|
||||
(field("Rights holder or right concerned", html! { (rights_holder) }))
|
||||
}
|
||||
@if let Some(statement) = &decision.statement {
|
||||
(field("Note to the user", html! { span class="whitespace-pre-wrap" { (statement) } }))
|
||||
}
|
||||
@if let Some(at) = &decision.ended_at {
|
||||
(field("Ended", html! {
|
||||
(format_admin_timestamp(at))
|
||||
@if let Some(by) = &decision.ended_by {
|
||||
" by " (user_link(base, users, by))
|
||||
}
|
||||
}))
|
||||
}
|
||||
@if let Some(statement) = &decision.end_statement {
|
||||
(field("Note to the user on ending", html! { span class="whitespace-pre-wrap" { (statement) } }))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_list(items: &[String]) -> Markup {
|
||||
html! {
|
||||
@if !items.is_empty() {
|
||||
ul class="list-disc space-y-1 pl-5" {
|
||||
@for item in items { li { (item) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_notice(notice: &EnforcementDecisionResponseNotice) -> Markup {
|
||||
html! {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-neutral-50 p-4 text-sm text-neutral-800" data-current-notice="" {
|
||||
p class="font-medium" { (notice.summary) }
|
||||
(text_list(¬ice.effects))
|
||||
@if let Some(rule) = ¬ice.rule {
|
||||
p { "Rule: " (rule.title) }
|
||||
}
|
||||
(text_list(¬ice.facts))
|
||||
@if let Some(statement) = ¬ice.statement {
|
||||
p class="whitespace-pre-wrap" { (statement) }
|
||||
}
|
||||
(text_list(¬ice.how_we_decided))
|
||||
@if let Some(appeal) = ¬ice.appeal {
|
||||
p {
|
||||
"Appeals: " (appeal.email)
|
||||
@if let Some(deadline) = &appeal.deadline {
|
||||
" by " (deadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
(text_list(¬ice.redress))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_section(decision: &EnforcementDecisionResponse) -> Markup {
|
||||
let channels = decision
|
||||
.notice_channels
|
||||
.iter()
|
||||
.map(|channel| notice_channel_label(&channel.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
html! {
|
||||
(section_card_simple("Notice", html! {
|
||||
div class="space-y-4" {
|
||||
dl class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(field("Status", html! { (notice_status_label(&decision.notice_status.to_string())) }))
|
||||
(field("Channels", html! {
|
||||
@if channels.is_empty() { "None" } @else { (channels.join(", ")) }
|
||||
}))
|
||||
@if let Some(locale) = &decision.notice_locale {
|
||||
(field("Locale", html! { (locale) }))
|
||||
}
|
||||
@if let Some(at) = &decision.notice_sent_at {
|
||||
(field("Received", html! { (format_admin_timestamp(at)) }))
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
p class=(FIELD_LABEL_CLASS) { "Text as sent" }
|
||||
@if let Some(text) = &decision.notice_text {
|
||||
pre class="whitespace-pre-wrap break-words rounded-lg bg-neutral-50 p-4 font-sans text-sm text-neutral-800" data-notice-text="" { (text) }
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "Nothing was sent yet." }
|
||||
}
|
||||
}
|
||||
div class="space-y-1" {
|
||||
p class=(FIELD_LABEL_CLASS) { "What the user sees now" }
|
||||
(current_notice(&decision.notice))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_row(
|
||||
base: &str,
|
||||
item: &EnforcementDecisionResponseEvidenceItem,
|
||||
restricted: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
li class="space-y-1 py-3" data-evidence-message=(item.message_id.as_str()) {
|
||||
div class="flex flex-wrap items-baseline gap-x-3 text-xs text-neutral-500" {
|
||||
span { (format_admin_timestamp(&item.timestamp)) }
|
||||
span { "Message " (item.message_id.as_str()) }
|
||||
@if let Some(channel_id) = &item.channel_id {
|
||||
a href={(base) "/messages?channel_id=" (channel_id.as_str()) "&message_id=" (item.message_id.as_str()) "&context_limit=50"}
|
||||
class="text-blue-600 hover:underline" { "Open in message tools" }
|
||||
}
|
||||
}
|
||||
@if let Some(content) = &item.content {
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (content) }
|
||||
} @else if restricted {
|
||||
p class="text-sm text-neutral-500" { "Content is not stored for this reason." }
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "Content is hidden. Needs the message:lookup permission." }
|
||||
}
|
||||
@if let Some(attachments) = &item.attachments {
|
||||
@for attachment in attachments {
|
||||
p class="text-xs text-neutral-600" { "Attachment: " (attachment.filename) }
|
||||
}
|
||||
} @else if *item.attachment_count > 0 {
|
||||
p class="text-xs text-neutral-600" { "Attachments: " (*item.attachment_count) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evidence_section(base: &str, decision: &EnforcementDecisionResponse) -> Markup {
|
||||
let restricted = is_restricted(*decision.reason_code);
|
||||
html! {
|
||||
(section_card_simple("Evidence", html! {
|
||||
@if decision.evidence.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No messages were recorded." }
|
||||
} @else {
|
||||
ul class="divide-y divide-neutral-100" {
|
||||
@for item in &decision.evidence {
|
||||
(evidence_row(base, item, restricted))
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(values) = &decision.profile_values {
|
||||
@if !values.is_empty() {
|
||||
dl class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
@for (name, value) in values {
|
||||
(field(&format!("Previous {name}"), html! { (value) }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn reports_section(base: &str, decision: &EnforcementDecisionResponse) -> Markup {
|
||||
html! {
|
||||
@if !decision.report_ids.is_empty() {
|
||||
(section_card_simple("Reports", html! {
|
||||
ul class="space-y-1 text-sm" {
|
||||
@for report_id in &decision.report_ids {
|
||||
li {
|
||||
a href={(base) "/reports/" (report_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(report_id.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn network_blocks_section(decision: &EnforcementDecisionResponse) -> Markup {
|
||||
html! {
|
||||
@if !decision.network_blocks.is_empty() {
|
||||
(section_card_simple("Network blocks", html! {
|
||||
ul class="space-y-1 text-sm text-neutral-900" {
|
||||
@for block in &decision.network_blocks {
|
||||
li {
|
||||
span class="font-mono" { (block.ip) }
|
||||
@if let Some(expires_at) = &block.expires_at {
|
||||
span class="text-neutral-500" { ", until " (format_admin_timestamp(expires_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p class="mt-2 text-xs text-neutral-500" {
|
||||
"Ending the decision removes these blocks. Deleting the account removes them too."
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notes_section(
|
||||
base: &str,
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
can_annotate: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let note_id = format!("note-{}", decision.decision_id.as_str());
|
||||
html! {
|
||||
(section_card_simple("Notes", html! {
|
||||
div class="space-y-4" {
|
||||
@if decision.notes.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No notes." }
|
||||
} @else {
|
||||
ol class="space-y-3" {
|
||||
@for note in &decision.notes {
|
||||
li class="space-y-1" data-note=(note.note_id.as_str()) {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(user_link(base, users, ¬e.author_id))
|
||||
" on " (format_admin_timestamp(¬e.created_at))
|
||||
}
|
||||
p class="whitespace-pre-wrap break-words text-sm text-neutral-900" { (note.body) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_annotate {
|
||||
form method="post"
|
||||
action={(base) "/decisions/" (decision.decision_id.as_str()) "?action=add_note"}
|
||||
data-admin-refresh-on-success="#decision-detail" {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label(¬e_id, "Add a note"))
|
||||
textarea id=(note_id) name="body" rows="2" required maxlength=(NOTE_MAX_CHARS)
|
||||
class=(FIELD_INPUT_CLASS) {}
|
||||
p class=(FIELD_HELP_CLASS) { "Staff only. Notes cannot be changed and expire after a year." }
|
||||
}
|
||||
(form_actions(submit_button("Add note")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision_user_ids(decision: &EnforcementDecisionResponse) -> Vec<String> {
|
||||
let mut ids: Vec<String> = [
|
||||
Some(&decision.decided_by),
|
||||
decision.recipient_user_id.as_ref(),
|
||||
decision.ended_by.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(decision.notes.iter().map(|note| ¬e.author_id))
|
||||
.map(|id| id.as_str().to_owned())
|
||||
.collect();
|
||||
if decision.subject_type.to_string() == "user" {
|
||||
ids.push(decision.subject_id.as_str().to_owned());
|
||||
}
|
||||
ids.retain(|id| id != SYSTEM_USER_ID);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
pub fn decision_detail(
|
||||
config: &AdminConfig,
|
||||
admin_acls: &[String],
|
||||
decision: &EnforcementDecisionResponse,
|
||||
users: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let decision_id = decision.decision_id.as_str();
|
||||
let expired_end = decision
|
||||
.end_outcome
|
||||
.map(|outcome| outcome.to_string())
|
||||
.as_deref()
|
||||
== Some("expired");
|
||||
let open = (decision.status.to_string() == "active" && !decision.replaced) || expired_end;
|
||||
html! {
|
||||
div id="decision-detail" class="space-y-6" {
|
||||
(summary_section(base, decision, users))
|
||||
@if open
|
||||
&& can_end(admin_acls, decision.kind, decision.removed_all_messages)
|
||||
&& (decision.network_blocks.is_empty() || acl::has_permission(admin_acls, acl::BAN_IP_REMOVE))
|
||||
{
|
||||
(section_card_simple("End decision", end_decision_form(&EndDecisionForm {
|
||||
base,
|
||||
decision_id,
|
||||
can_lift: can_lift(decision.kind) && !expired_end,
|
||||
reason_code: *decision.reason_code,
|
||||
csrf_token,
|
||||
refresh_target: "#decision-detail",
|
||||
})))
|
||||
}
|
||||
(notice_section(decision))
|
||||
(evidence_section(base, decision))
|
||||
(reports_section(base, decision))
|
||||
(network_blocks_section(decision))
|
||||
(notes_section(
|
||||
base,
|
||||
decision,
|
||||
users,
|
||||
acl::has_permission(admin_acls, acl::ENFORCEMENT_ANNOTATE),
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enforcement_decision_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
decision_id: &str,
|
||||
decision: Option<&EnforcementDecisionResponse>,
|
||||
users: &[AdminUser],
|
||||
csrf_token: &str,
|
||||
is_htmx: bool,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let title = format!("Decision {decision_id}");
|
||||
let admin_acls = auth
|
||||
.admin_user
|
||||
.as_ref()
|
||||
.map(|user| user.acls.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let content = html! {
|
||||
(page_header_with_back(&title, None, &format!("{base}/decisions"), Some("Back to decisions")))
|
||||
@match decision {
|
||||
Some(decision) => { (decision_detail(config, admin_acls, decision, users, csrf_token)) }
|
||||
None => {
|
||||
(section_card_simple("Decision", html! {
|
||||
p class="text-sm text-neutral-500" { "This decision was not found or could not be loaded." }
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
if is_htmx {
|
||||
content
|
||||
} else {
|
||||
admin_layout(config, auth, &title, DECISIONS_PAGE_KEY, None, content)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decisions_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
lookup_error: Option<&str>,
|
||||
review_queue: Option<&ApiResult<EnforcementDecisionListResponse>>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("Decisions", Some("Look up a decision by its ID, or work through the review queue.")))
|
||||
(section_card_simple("Find a decision", html! {
|
||||
form method="get" action={(base) "/decisions"} class="space-y-3" {
|
||||
div class="space-y-1" {
|
||||
(field_label("decision-lookup", "Decision ID"))
|
||||
input type="text" id="decision-lookup" name="id" required inputmode="numeric"
|
||||
class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "The ID from the notice or the appeal email." }
|
||||
@if let Some(error) = lookup_error {
|
||||
p class="text-sm text-red-700" { (error) }
|
||||
}
|
||||
}
|
||||
(form_actions(submit_button("Open decision")))
|
||||
}
|
||||
}))
|
||||
@if let Some(queue) = review_queue {
|
||||
(section_card_simple("Review queue", html! {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"Automated limits waiting for a person, such as purchase limits after a chargeback. End the limit, or record a warning, suspension or closure on the account, to take it out of the queue."
|
||||
}
|
||||
@match queue {
|
||||
Ok(response) => { (review_queue_table(base, &response.decisions)) }
|
||||
Err(_) => { p class="text-sm text-red-700" { "Failed to load the review queue." } }
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
admin_layout(config, auth, "Decisions", DECISIONS_PAGE_KEY, None, content)
|
||||
}
|
||||
|
||||
fn review_queue_table(
|
||||
base: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
@if decisions.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "Nothing is waiting for review." }
|
||||
} @else {
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full divide-y divide-neutral-200 text-sm" data-review-queue="" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
@for header in ["ID", "Account", "Reason", "Payment", "Date"] {
|
||||
th class="px-3 py-2 font-medium" { (header) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-100" {
|
||||
@for decision in decisions {
|
||||
tr {
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/decisions/" (decision.decision_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(decision.decision_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/users/" (decision.subject_id.as_str()) "?tab=moderation"} class="text-blue-600 hover:underline" {
|
||||
(decision.subject_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" { (admin_flags::deletion_reason_label(*decision.reason_code)) }
|
||||
td class="whitespace-nowrap px-3 py-2" {
|
||||
(decision.payment_at.as_deref().map(format_admin_timestamp).unwrap_or_default())
|
||||
}
|
||||
td class="whitespace-nowrap px-3 py-2" { (format_admin_timestamp(&decision.created_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decisions_table(
|
||||
base: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
@if decisions.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No decisions." }
|
||||
} @else {
|
||||
div class="overflow-x-auto" {
|
||||
table class="min-w-full divide-y divide-neutral-200 text-sm" data-decisions-table="" {
|
||||
thead {
|
||||
tr class="text-left text-xs text-neutral-500" {
|
||||
@for header in ["ID", "Kind", "Reason", "Status", "Date"] {
|
||||
th class="px-3 py-2 font-medium" { (header) }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody class="divide-y divide-neutral-100" {
|
||||
@for decision in decisions {
|
||||
@let end_outcome = decision.end_outcome.map(|outcome| outcome.to_string());
|
||||
tr {
|
||||
td class="px-3 py-2" {
|
||||
a href={(base) "/decisions/" (decision.decision_id.as_str())} class="text-blue-600 hover:underline" {
|
||||
(decision.decision_id.as_str())
|
||||
}
|
||||
}
|
||||
td class="px-3 py-2" { (kind_label(decision.kind)) }
|
||||
td class="px-3 py-2" { (admin_flags::deletion_reason_label(*decision.reason_code)) }
|
||||
td class="px-3 py-2" {
|
||||
(state_label(&decision.status.to_string(), end_outcome.as_deref(), decision.replaced, decision.expired))
|
||||
}
|
||||
td class="whitespace-nowrap px-3 py-2" { (format_admin_timestamp(&decision.created_at)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::GuildInfo,
|
||||
api::{generated::types::EnforcementKindType, types::GuildInfo},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
enforcement_fields::{BasisFields, DecisionFields, basis_fields},
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
@@ -144,17 +145,32 @@ pub fn moderation_tab(
|
||||
}
|
||||
|
||||
@if can_delete {
|
||||
(card_with_header("Delete Guild", html! {
|
||||
p class="mb-3 text-sm text-neutral-500" {
|
||||
"Deleting a guild permanently removes it and all associated data. \
|
||||
This action cannot be undone."
|
||||
}
|
||||
(card_with_header("Remove community", html! {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild.id) "?action=delete_guild&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete Guild"))
|
||||
}))
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Removes the community with its channels, messages and settings. It can't be undone. Child safety and violent extremism reasons can't be used, because the removal deletes the content."
|
||||
}
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "remove-community",
|
||||
kind: EnforcementKindType::CommunityRemoval,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: None,
|
||||
allow_restricted: false,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The owner asked"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {},
|
||||
}))
|
||||
(form_actions(danger_button("Remove community")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{INTERNAL_NOTE_MAX_CHARS, TakeAction},
|
||||
error_display::error_alert,
|
||||
form::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||
submit_button,
|
||||
},
|
||||
message_data::{Message, ordered_messages, value_id},
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::{message_delete_prefill_script, message_list},
|
||||
page_container::{card, page_header},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -193,6 +194,7 @@ pub struct MessagesPageParams<'a> {
|
||||
pub prefill_channel_id: Option<&'a str>,
|
||||
pub can_lookup: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_take_action: bool,
|
||||
pub lookup_result: Option<&'a Value>,
|
||||
pub browse_result: Option<&'a Value>,
|
||||
pub search_result: Option<&'a Value>,
|
||||
@@ -202,11 +204,32 @@ pub struct MessagesPageParams<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MessageActions {
|
||||
pub delete: bool,
|
||||
pub take_action: bool,
|
||||
}
|
||||
|
||||
impl MessageActions {
|
||||
fn take_action(self) -> Option<&'static TakeAction<'static>> {
|
||||
self.take_action.then_some(&ANY_AUTHOR)
|
||||
}
|
||||
}
|
||||
|
||||
const ANY_AUTHOR: TakeAction<'static> = TakeAction {
|
||||
author_id: None,
|
||||
report_id: None,
|
||||
};
|
||||
|
||||
pub fn messages_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
params: &MessagesPageParams<'_>,
|
||||
) -> Markup {
|
||||
let actions = MessageActions {
|
||||
delete: params.can_delete,
|
||||
take_action: params.can_take_action,
|
||||
};
|
||||
let content = html! {
|
||||
(page_header("Message Tools", None))
|
||||
|
||||
@@ -217,19 +240,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))
|
||||
@@ -238,8 +261,8 @@ pub fn messages_page(
|
||||
}
|
||||
@if params.can_delete {
|
||||
(delete_message_form(config, params.csrf_token))
|
||||
(message_delete_prefill_script())
|
||||
}
|
||||
(message_deletion_script(params.csrf_token))
|
||||
}
|
||||
};
|
||||
admin_layout_ext(
|
||||
@@ -260,7 +283,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,8 +303,9 @@ pub fn browse_messages_fragment(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions.delete,
|
||||
highlight_message_id,
|
||||
actions.take_action(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -291,7 +315,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 +352,7 @@ fn browse_result_card(
|
||||
config,
|
||||
channel_id,
|
||||
messages: &messages,
|
||||
show_delete,
|
||||
actions,
|
||||
has_more,
|
||||
has_newer: false,
|
||||
focus_message_id: None,
|
||||
@@ -343,7 +367,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,8 +393,9 @@ fn search_result_card(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
show_delete,
|
||||
actions.delete,
|
||||
None,
|
||||
actions.take_action(),
|
||||
))
|
||||
}
|
||||
})
|
||||
@@ -379,7 +404,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 +426,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 +440,7 @@ struct MessageScrollPane<'a> {
|
||||
config: &'a AdminConfig,
|
||||
channel_id: &'a str,
|
||||
messages: &'a [Message],
|
||||
show_delete: bool,
|
||||
actions: MessageActions,
|
||||
has_more: bool,
|
||||
has_newer: bool,
|
||||
focus_message_id: Option<&'a str>,
|
||||
@@ -454,8 +479,9 @@ fn message_scroll_pane(pane: MessageScrollPane<'_>) -> Markup {
|
||||
pane.config,
|
||||
&pane.config.base_path,
|
||||
pane.messages,
|
||||
pane.show_delete,
|
||||
pane.actions.delete,
|
||||
pane.focus_message_id,
|
||||
pane.actions.take_action(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -639,12 +665,13 @@ fn lookup_by_attachment_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
fn delete_message_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
let base = &config.base_path;
|
||||
card(html! {
|
||||
h3 class="mb-4 text-base font-semibold text-neutral-900" {
|
||||
"Delete Message"
|
||||
}
|
||||
form method="post" action={(base) "/messages?action=delete"} {
|
||||
h3 class="mb-4 text-base font-semibold text-neutral-900" { "Delete message" }
|
||||
form id="delete-message-form" method="post" action={(base) "/messages?action=delete"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="flex flex-col gap-4" {
|
||||
p class="text-neutral-600 text-sm" {
|
||||
"For a message that broke a rule, use Take action instead, so a decision records the removal and the user is told."
|
||||
}
|
||||
(form_field_group(
|
||||
"Channel ID", "delete-message-channel-id", true, None, None,
|
||||
html! {
|
||||
@@ -662,16 +689,26 @@ fn delete_message_form(config: &AdminConfig, csrf_token: &str) -> Markup {
|
||||
},
|
||||
))
|
||||
(form_field_group(
|
||||
"Audit Log Reason (optional)",
|
||||
"delete-message-audit-log-reason", false, None, None,
|
||||
"Basis", "delete-message-basis", true, None, None,
|
||||
html! {
|
||||
input type="text" id="delete-message-audit-log-reason"
|
||||
name="audit_log_reason" placeholder="Reason for deletion"
|
||||
select id="delete-message-basis" name="basis" required class=(FORM_INPUT_CLASS) {
|
||||
option value="" disabled selected { "Choose a basis" }
|
||||
option value="holder_request" { "The account holder asked" }
|
||||
option value="housekeeping" { "Housekeeping" }
|
||||
}
|
||||
},
|
||||
))
|
||||
(form_field_group(
|
||||
"Internal note (optional)",
|
||||
"delete-message-internal-note", false, None, None,
|
||||
html! {
|
||||
input type="text" id="delete-message-internal-note"
|
||||
name="internal_note" maxlength=(INTERNAL_NOTE_MAX_CHARS)
|
||||
class=(FORM_INPUT_CLASS);
|
||||
},
|
||||
))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete Message"))
|
||||
(danger_button("Delete message"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod bans;
|
||||
pub(crate) mod blocklist_helpers;
|
||||
pub mod bulk_actions;
|
||||
pub mod discovery;
|
||||
pub mod enforcement_decision;
|
||||
pub mod gateway;
|
||||
pub mod gateway_leaderboard;
|
||||
pub mod gateway_nodes;
|
||||
|
||||
@@ -22,13 +22,14 @@ use crate::{
|
||||
data_field, data_field_link_mono, data_field_mono, data_field_muted,
|
||||
data_field_text,
|
||||
},
|
||||
enforcement_fields::{TakeAction, can_take_action, take_action_href},
|
||||
form::{
|
||||
FORM_CONTROL_CLASS, FORM_LABEL_CLASS, checkbox, csrf_input, danger_button,
|
||||
opt_out_checkbox, select_chevron,
|
||||
},
|
||||
media::user_avatar_url,
|
||||
message_data::ordered_messages,
|
||||
message_list::{message_deletion_script, message_list},
|
||||
message_list::message_list,
|
||||
nsfw_indicators::{
|
||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||
},
|
||||
@@ -93,10 +94,15 @@ fn reported_user_label(report: &ReportEntry) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn has_acl(auth: &AuthContext, permission: &str) -> bool {
|
||||
fn admin_acls(auth: &AuthContext) -> &[String] {
|
||||
auth.admin_user
|
||||
.as_ref()
|
||||
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
|
||||
.map(|admin| admin.acls.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn has_acl(auth: &AuthContext, permission: &str) -> bool {
|
||||
acl::has_permission(admin_acls(auth), permission)
|
||||
}
|
||||
|
||||
fn basic_info_section(config: &AdminConfig, auth: &AuthContext, report: &ReportEntry) -> Markup {
|
||||
@@ -506,18 +512,38 @@ fn status_card(config: &AdminConfig, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) -> Markup {
|
||||
fn take_action_subject(report: &ReportEntry) -> Option<&str> {
|
||||
match report.report_type {
|
||||
0 | 1 => report.reported_user_id.as_deref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn actions_card(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
report: &ReportEntry,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let reported_message = report
|
||||
.reported_channel_id
|
||||
.as_deref()
|
||||
.zip(report.reported_message_id.as_deref());
|
||||
html! {
|
||||
(section_card(Some("Actions"), None, None, html! {
|
||||
div class="flex flex-col gap-3" {
|
||||
@if report.status == 0 {
|
||||
(resolve_report_form(base, &report.report_id, csrf_token))
|
||||
}
|
||||
@if report.report_type == 0 || report.report_type == 1 {
|
||||
@if let Some(ref reported_id) = report.reported_user_id {
|
||||
(nav_link(&format!("{base}/users/{reported_id}"), "View Reported User"))
|
||||
@if let Some(reported_id) = take_action_subject(report) {
|
||||
@if can_take_action(admin_acls(auth)) {
|
||||
(nav_link(
|
||||
&take_action_href(base, reported_id, Some(&report.report_id), reported_message),
|
||||
"Take action",
|
||||
))
|
||||
}
|
||||
(nav_link(&format!("{base}/users/{reported_id}"), "View Reported User"))
|
||||
}
|
||||
@if report.report_type == 2 {
|
||||
@if let Some(ref guild_id) = report.reported_guild_id {
|
||||
@@ -568,6 +594,30 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
fieldset class="flex flex-col gap-3" data-show-for-resolution="actioned" hidden disabled {
|
||||
label for="outcome" class=(FORM_LABEL_CLASS) { "Outcome" }
|
||||
div class="relative" {
|
||||
select id="outcome" name="outcome" required
|
||||
class={(FORM_CONTROL_CLASS) " h-8 appearance-none px-3 py-1.5 pr-10"} {
|
||||
option value="" selected disabled { "Choose one" }
|
||||
option value="removed" { "Content removed" }
|
||||
option value="restricted" { "Account restricted" }
|
||||
option value="action_taken" { "Other action taken" }
|
||||
}
|
||||
(select_chevron())
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"What happened to the reported content or account. The reporter is told it. Other action taken, such as a warning, tells them we acted but not how."
|
||||
}
|
||||
}
|
||||
fieldset class="flex flex-col gap-3" data-show-for-resolution="duplicate" hidden disabled {
|
||||
label for="duplicate_of" class=(FORM_LABEL_CLASS) { "Duplicate of" }
|
||||
input type="text" id="duplicate_of" name="duplicate_of" required inputmode="numeric"
|
||||
placeholder="Report ID" class={(FORM_CONTROL_CLASS) " h-8 px-3 py-1.5"};
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"The report this one repeats. The reporter is told its outcome, or nothing while it is still open."
|
||||
}
|
||||
}
|
||||
label for="public_comment" class=(FORM_LABEL_CLASS) {
|
||||
"Public comment to the reporter (optional)"
|
||||
}
|
||||
@@ -575,14 +625,14 @@ fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup
|
||||
class={(FORM_CONTROL_CLASS) " px-3 py-2"} {}
|
||||
(opt_out_checkbox("notify_reporter", "Include the public comment in the reporter notice"))
|
||||
p class="text-neutral-500 text-xs" data-resolve-notice-help="" {
|
||||
"The reporter is told the report was resolved either way, unless their account is gone or barred from reporting. \
|
||||
"The reporter is told the outcome, unless their account is gone or was barred from reporting when they filed it. \
|
||||
When unticked, the comment is not sent or shown on the report. It is only kept in the audit log."
|
||||
}
|
||||
button type="submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 \
|
||||
font-medium rounded-lg bg-neutral-900 text-white \
|
||||
px-4 py-2 text-sm" {
|
||||
"Resolve Report"
|
||||
"Resolve report"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1035,8 +1085,7 @@ fn nav_link(href: &str, label: &str) -> Markup {
|
||||
fn message_context_section(
|
||||
config: &AdminConfig,
|
||||
report: &ReportEntry,
|
||||
include_delete: bool,
|
||||
csrf_token: Option<&str>,
|
||||
take_action: bool,
|
||||
) -> Markup {
|
||||
let Some(values) = &report.message_context else {
|
||||
return html! {};
|
||||
@@ -1058,14 +1107,19 @@ fn message_context_section(
|
||||
config,
|
||||
&config.base_path,
|
||||
&messages,
|
||||
include_delete,
|
||||
false,
|
||||
report.reported_message_id.as_deref(),
|
||||
take_action
|
||||
.then_some(report.reported_user_id.as_deref())
|
||||
.flatten()
|
||||
.map(|user_id| TakeAction {
|
||||
author_id: Some(user_id),
|
||||
report_id: Some(&report.report_id),
|
||||
})
|
||||
.as_ref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@if let Some(csrf_token) = csrf_token {
|
||||
(message_deletion_script(csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,12 +1146,12 @@ pub fn report_detail_page(
|
||||
(report_answers_section(report))
|
||||
(reported_entity_section(config, report))
|
||||
(profile_snapshot_section(config, report, live))
|
||||
(message_context_section(config, report, has_acl(auth, acl::MESSAGE_DELETE), Some(csrf_token)))
|
||||
(message_context_section(config, report, can_take_action(admin_acls(auth))))
|
||||
(additional_info_section(report))
|
||||
}
|
||||
div class="space-y-6" {
|
||||
(status_card(config, report))
|
||||
(actions_card(config, report, csrf_token))
|
||||
(actions_card(config, auth, report, csrf_token))
|
||||
(legal_hold_card(config, auth, report, csrf_token))
|
||||
(delete_report_card(config, auth, report, csrf_token))
|
||||
}
|
||||
@@ -1118,7 +1172,7 @@ pub fn report_detail_fragment(config: &AdminConfig, report: &ReportEntry) -> Mar
|
||||
(basic_info_section_fragment(config, report))
|
||||
(report_answers_section(report))
|
||||
(reported_entity_section(config, report))
|
||||
(message_context_section(config, report, false, None))
|
||||
(message_context_section(config, report, false))
|
||||
(additional_info_section(report))
|
||||
a href={(base) "/reports/" (&report.report_id)}
|
||||
class="inline-flex min-h-[44px] w-full items-center justify-center \
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::{
|
||||
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
|
||||
api::{
|
||||
generated::types::EnforcementKindType,
|
||||
types::{
|
||||
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession,
|
||||
WebAuthnCredential,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
alert::{AlertVariant, alert},
|
||||
enforcement_fields::{BasisFields, DecisionFields, FIELD_HELP_CLASS, basis_fields},
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
@@ -286,7 +291,7 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
let action_url = format!("{base}/users/{}?action=clear_fields&tab=account", user.id);
|
||||
html! {
|
||||
(card_with_header("Clear Profile Fields", html! {
|
||||
(card_with_header("Clear profile fields", html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
@@ -300,10 +305,31 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
@if user.banner.is_some() { (checkbox("fields[]", "banner", "Banner", false, true)) }
|
||||
@if user.bio.is_some() { (checkbox("fields[]", "bio", "Bio", false, true)) }
|
||||
@if user.pronouns.is_some() { (checkbox("fields[]", "pronouns", "Pronouns", false, true)) }
|
||||
@if user.global_name.is_some() { (checkbox("fields[]", "global_name", "Display Name", false, true)) }
|
||||
@if user.global_name.is_some() { (checkbox("fields[]", "global_name", "Display name", false, true)) }
|
||||
}
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "clear-fields",
|
||||
kind: EnforcementKindType::ProfileReset,
|
||||
report_ids: &[],
|
||||
messages: Some(&[]),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {
|
||||
p class=(FIELD_HELP_CLASS) {
|
||||
"The current values are saved as evidence before they are cleared. The cleared fields are the facts, so evidence and a note to the user are optional."
|
||||
}
|
||||
},
|
||||
}))
|
||||
(form_actions(html! {
|
||||
(submit_button("Clear Selected Fields"))
|
||||
(submit_button("Clear selected fields"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::DELETION_REASONS;
|
||||
use crate::admin_flags::{ENFORCEMENT_LIMITS, deletion_reason_label};
|
||||
use crate::utils::plural::{count_noun, noun_for};
|
||||
use crate::{
|
||||
acl,
|
||||
api::{
|
||||
client::{ApiError, ApiResult},
|
||||
generated::types::{
|
||||
EnforcementDecisionListResponse, EnforcementDecisionListResponseDecisionsItem,
|
||||
EnforcementKindType, NetworkBlockCandidatesResponse,
|
||||
},
|
||||
types::{AdminUser, AuditLogEntry, MessageShredStatusResponse},
|
||||
},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
form::{
|
||||
checkbox, csrf_input, danger_button, form_actions, opt_out_checkbox, submit_button,
|
||||
templates::{
|
||||
components::{
|
||||
enforcement_fields::{
|
||||
BasisFields, DecisionFields, FIELD_HELP_CLASS, FIELD_INPUT_CLASS,
|
||||
FIELD_LABEL_CLASS, basis_fields, deletion_days_field, enforcement_fields,
|
||||
field_label, internal_note_field, network_block_fields,
|
||||
},
|
||||
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
pages::enforcement_decision::{
|
||||
EndDecisionForm, can_end, can_lift, decisions_table, end_decision_form, ends_label,
|
||||
kind_label, suspension_label,
|
||||
},
|
||||
page_container::card_with_header,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
const REFRESH_TARGET: &str = "#user-tab-content";
|
||||
const REPORTS_LIMIT_DEFAULT_DAYS: i64 = 90;
|
||||
|
||||
const CONTENT_BASIS_CHOICES: &[(&str, &str)] = &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Housekeeping"),
|
||||
];
|
||||
|
||||
const SUSPENSION_DURATIONS: &[(&str, &str)] = &[
|
||||
("1", "1 hour"),
|
||||
("12", "12 hours"),
|
||||
("24", "1 day"),
|
||||
@@ -31,45 +53,36 @@ const TEMP_BAN_DURATIONS: &[(&str, &str)] = &[
|
||||
("0", "Permanent"),
|
||||
];
|
||||
|
||||
pub struct CurrentBan<'a> {
|
||||
pub struct LegacySuspension<'a> {
|
||||
pub entry: &'a AuditLogEntry,
|
||||
pub notes: Vec<&'a AuditLogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DecisionPrefill {
|
||||
pub report_ids: Vec<String>,
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ModerationContext<'a> {
|
||||
pub deletion_scheduler: Option<&'a AdminUser>,
|
||||
pub current_ban: Option<CurrentBan<'a>>,
|
||||
pub username_sign_in: bool,
|
||||
pub legacy_suspension: Option<LegacySuspension<'a>>,
|
||||
pub decisions: Option<&'a ApiResult<EnforcementDecisionListResponse>>,
|
||||
pub network_candidates: Option<&'a ApiResult<NetworkBlockCandidatesResponse>>,
|
||||
pub prefill: DecisionPrefill,
|
||||
}
|
||||
|
||||
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
|
||||
pub fn find_legacy_suspension<'a>(
|
||||
user: &AdminUser,
|
||||
logs: &'a [AuditLogEntry],
|
||||
) -> Option<LegacySuspension<'a>> {
|
||||
let banned_until = user.temp_banned_until.as_deref()?;
|
||||
let entry = logs.iter().find(|log| {
|
||||
log.action == "temp_ban"
|
||||
&& log.target_id == user.id
|
||||
&& log.metadata.get("banned_until").map(String::as_str) == Some(banned_until)
|
||||
})?;
|
||||
let mut notes: Vec<&AuditLogEntry> = logs
|
||||
.iter()
|
||||
.filter(|log| {
|
||||
log.action == "annotate_ban"
|
||||
&& log.metadata.get("ban_audit_log_id") == Some(&entry.log_id)
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
Some(CurrentBan { entry, notes })
|
||||
}
|
||||
|
||||
fn deletion_reason_label(code: i32) -> String {
|
||||
let value = code.to_string();
|
||||
DELETION_REASONS
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == value)
|
||||
.map_or_else(
|
||||
|| format!("Reason {code}"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
Some(LegacySuspension { entry })
|
||||
}
|
||||
|
||||
fn admin_name(entry: &AuditLogEntry) -> String {
|
||||
@@ -91,169 +104,256 @@ pub fn moderation_tab(
|
||||
delete_all_messages_dry_run: Option<(u64, u64)>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let can_limit = !user.bot && acl::has_permission(admin_acls, acl::USER_UPDATE_FLAGS);
|
||||
let can_delete_all_messages = acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL);
|
||||
let can_shred_messages = acl::has_permission(admin_acls, acl::MESSAGE_SHRED);
|
||||
let decisions = context
|
||||
.decisions
|
||||
.and_then(|result| result.as_ref().ok())
|
||||
.map(|response| response.decisions.as_slice())
|
||||
.unwrap_or_default();
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
(current_restriction_card(base, user, csrf_token, admin_acls, context, decisions))
|
||||
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
|
||||
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
|
||||
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
|
||||
@if acl::has_permission(admin_acls, acl::USER_TEMP_BAN) {
|
||||
(suspend_card(base, user, csrf_token, context))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::USER_DELETE) {
|
||||
(close_card(base, config.self_hosted, user, csrf_token, context))
|
||||
}
|
||||
@if can_limit {
|
||||
(limit_card(base, user, csrf_token, &context.prefill))
|
||||
}
|
||||
@if can_limit {
|
||||
(warn_card(base, user, csrf_token, &context.prefill))
|
||||
}
|
||||
}
|
||||
@if let Some(result) = context.decisions {
|
||||
(card_with_header("Decisions", html! {
|
||||
@match result {
|
||||
Ok(response) => { (decisions_table(base, &response.decisions)) }
|
||||
Err(_) => { p class="text-sm text-red-700" { "Failed to load decisions." } }
|
||||
}
|
||||
}))
|
||||
}
|
||||
@if can_delete_all_messages {
|
||||
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
|
||||
(delete_all_messages_card(base, user, csrf_token, &context.prefill, delete_all_messages_dry_run))
|
||||
}
|
||||
@if can_shred_messages {
|
||||
(message_shred_card(base, user, csrf_token, message_shred_job_id, message_shred_status))
|
||||
(message_shred_card(base, user, csrf_token, &context.prefill, message_shred_job_id, message_shred_status))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
|
||||
if username_sign_in {
|
||||
html! { input type="hidden" name="notify_user_present" value="1"; }
|
||||
} else {
|
||||
opt_out_checkbox("notify_user", label)
|
||||
}
|
||||
}
|
||||
|
||||
fn ban_actions_card(
|
||||
fn current_restriction_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
username_sign_in: bool,
|
||||
admin_acls: &[String],
|
||||
context: &ModerationContext<'_>,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Ban Actions", html! {
|
||||
@if user.temp_banned_until.is_some() {
|
||||
(current_ban_details(base, user, csrf_token, current_ban))
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Public Reason (optional, shown to the user)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public unban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional, audit log)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private unban reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Unban User"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=temp_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Duration"))
|
||||
select name="duration"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
@for &(value, label) in TEMP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="reason"
|
||||
placeholder="Enter public ban reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private ban reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Ban/Suspend User"))
|
||||
}))
|
||||
}
|
||||
(card_with_header("Current restriction", html! {
|
||||
div data-current-restriction="" {
|
||||
@if let Some(decision_id) = &user.enforcement_decision_id {
|
||||
(decision_restriction(base, user, csrf_token, admin_acls, decision_id, decisions))
|
||||
} @else if let Some(until) = &user.temp_banned_until {
|
||||
(legacy_suspension_restriction(base, user, csrf_token, admin_acls, until, context.legacy_suspension.as_ref()))
|
||||
} @else if let Some(pending) = &user.pending_deletion_at {
|
||||
(legacy_deletion_restriction(base, user, csrf_token, admin_acls, pending, context.deletion_scheduler))
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" { "No current restriction." }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_ban_details(
|
||||
fn decision_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
current_ban: Option<&CurrentBan<'_>>,
|
||||
admin_acls: &[String],
|
||||
decision_id: &str,
|
||||
decisions: &[EnforcementDecisionListResponseDecisionsItem],
|
||||
) -> Markup {
|
||||
let Some(ban) = current_ban else {
|
||||
let decision = decisions
|
||||
.iter()
|
||||
.find(|decision| decision.decision_id.as_str() == decision_id);
|
||||
let state = match (decision, user.temp_banned_until.as_deref()) {
|
||||
(Some(decision), _) => {
|
||||
let ends = ends_label(
|
||||
decision.kind,
|
||||
decision.ends_at.as_deref(),
|
||||
decision.deletion_at.as_deref(),
|
||||
);
|
||||
format!(
|
||||
"{} for {}. {ends}",
|
||||
kind_label(decision.kind),
|
||||
deletion_reason_label(*decision.reason_code)
|
||||
)
|
||||
}
|
||||
(None, Some(until)) => suspension_label(until),
|
||||
(None, None) => "Restricted by a decision.".to_owned(),
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
(state.trim_end())
|
||||
" "
|
||||
a href={(base) "/decisions/" (decision_id)} class="text-blue-600 hover:underline" {
|
||||
"Decision " (decision_id)
|
||||
}
|
||||
}
|
||||
@match decision {
|
||||
Some(decision) if can_end(admin_acls, decision.kind, decision.removed_all_messages) => {
|
||||
(end_decision_form(&EndDecisionForm {
|
||||
base,
|
||||
decision_id,
|
||||
can_lift: can_lift(decision.kind),
|
||||
reason_code: *decision.reason_code,
|
||||
csrf_token,
|
||||
refresh_target: REFRESH_TARGET,
|
||||
}))
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
p class="text-sm text-neutral-500" { "Open the decision to see or end it." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_suspension_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
until: &str,
|
||||
legacy_suspension: Option<&LegacySuspension<'_>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
(suspension_label(until)) ". Recorded before decisions, so it has no decision."
|
||||
}
|
||||
(legacy_suspension_details(base, legacy_suspension))
|
||||
@if acl::has_permission(admin_acls, acl::USER_TEMP_BAN) {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(internal_note_field("lift", "Staff only. Saved in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) { "The user is told by email that the suspension was lifted." }
|
||||
(form_actions(submit_button("Lift suspension")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_suspension_details(
|
||||
base: &str,
|
||||
legacy_suspension: Option<&LegacySuspension<'_>>,
|
||||
) -> Markup {
|
||||
let Some(suspension) = legacy_suspension else {
|
||||
return html! {
|
||||
p class="mb-4 text-sm text-neutral-500" {
|
||||
"The audit log entry of this ban could not be loaded, so notes cannot be added here."
|
||||
p class="text-sm text-neutral-500" {
|
||||
"The audit log entry of this suspension could not be loaded."
|
||||
}
|
||||
};
|
||||
};
|
||||
html! {
|
||||
div class="mb-4 space-y-3" {
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Banned by: " }
|
||||
dd class="inline" {
|
||||
a href={(base) "/users/" (ban.entry.admin_user_id)} class="underline" {
|
||||
(admin_name(ban.entry))
|
||||
}
|
||||
" on " (format_admin_timestamp(&ban.entry.created_at))
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Suspended by: " }
|
||||
dd class="inline" {
|
||||
a href={(base) "/users/" (suspension.entry.admin_user_id)} class="underline" {
|
||||
(admin_name(suspension.entry))
|
||||
}
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Reason: " }
|
||||
dd class="inline" { (ban.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
|
||||
" on " (format_admin_timestamp(&suspension.entry.created_at))
|
||||
}
|
||||
}
|
||||
@if !ban.notes.is_empty() {
|
||||
ul class="space-y-1 text-sm text-neutral-700" {
|
||||
@for note in &ban.notes {
|
||||
li {
|
||||
span class="font-medium" { (admin_name(note)) }
|
||||
" (" (format_admin_timestamp(¬e.created_at)) "): "
|
||||
(note.audit_log_reason.as_deref().unwrap_or(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=annotate_ban&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="ban_audit_log_id" value=(ban.entry.log_id);
|
||||
div class="space-y-3" {
|
||||
(form_label("Add a note to this ban"))
|
||||
textarea name="note" rows="2" required maxlength="512"
|
||||
placeholder="Appended to the ban. The original reason is kept."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {}
|
||||
(form_actions(html! {
|
||||
(submit_button("Add Note"))
|
||||
}))
|
||||
}
|
||||
div {
|
||||
dt class="inline font-medium" { "Internal note: " }
|
||||
dd class="inline" { (suspension.entry.audit_log_reason.as_deref().unwrap_or("None recorded")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
context: &ModerationContext<'_>,
|
||||
) -> Markup {
|
||||
let prefill = &context.prefill;
|
||||
html! {
|
||||
(card_with_header("Suspend account", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=suspend&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
div class="space-y-1" {
|
||||
(field_label("suspend-duration", "Duration"))
|
||||
select id="suspend-duration" name="duration_hours" required class=(FIELD_INPUT_CLASS) {
|
||||
@for &(value, label) in SUSPENSION_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "suspend",
|
||||
kind: EnforcementKindType::Suspension,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
(remove_evidence_field())
|
||||
@if let Some(candidates) = context.network_candidates {
|
||||
fieldset data-show-for-duration_hours="0" hidden disabled {
|
||||
(network_block_fields("suspend", candidates))
|
||||
}
|
||||
}
|
||||
(form_actions(submit_button("Suspend account")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &DecisionPrefill) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Warn", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=warn&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "warn",
|
||||
kind: EnforcementKindType::Warning,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
(form_actions(submit_button("Send warning")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_deletion_summary(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
@@ -274,7 +374,7 @@ fn pending_deletion_summary(
|
||||
.map(format_admin_timestamp)
|
||||
.unwrap_or_default();
|
||||
let markup = html! {
|
||||
dl class="mb-4 space-y-1 text-sm text-neutral-700" {
|
||||
dl class="space-y-1 text-sm text-neutral-700" {
|
||||
div {
|
||||
dt class="inline font-medium" { "Scheduled by: " }
|
||||
dd class="inline" {
|
||||
@@ -317,81 +417,159 @@ fn pending_deletion_summary(
|
||||
)
|
||||
}
|
||||
|
||||
fn deletion_card(
|
||||
fn legacy_deletion_restriction(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
admin_acls: &[String],
|
||||
pending: &str,
|
||||
scheduler: Option<&AdminUser>,
|
||||
username_sign_in: bool,
|
||||
) -> Markup {
|
||||
let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
html! {
|
||||
(card_with_header("Account Deletion", html! {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
@let (confirmation, summary) = pending_deletion_summary(base, user, scheduler);
|
||||
(summary)
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-900" {
|
||||
"Scheduled for deletion. No decision owns it, so it is cancelled here."
|
||||
}
|
||||
(summary)
|
||||
@if acl::has_permission(admin_acls, acl::USER_DELETE) {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"} {
|
||||
action={(base) "/users/" (user.id) "?action=cancel_deletion&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="expected_pending_deletion_at" value=(pending);
|
||||
div class="space-y-3" {
|
||||
(form_label("Private Reason"))
|
||||
input type="text" name="private_reason" required
|
||||
placeholder="Why this deletion is being canceled (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
@if !username_sign_in {
|
||||
(checkbox("notify_user", "true", "Email the user that the deletion was canceled", false, true))
|
||||
}
|
||||
(internal_note_field("cancel-deletion", "Staff only. Saved in the audit log."))
|
||||
p class=(FIELD_HELP_CLASS) { "The user is told by email that the deletion was cancelled." }
|
||||
(checkbox("confirm", "true", &confirmation, false, true))
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Deletion"))
|
||||
}))
|
||||
(form_actions(danger_button("Cancel deletion")))
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=schedule_deletion&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
(form_label("Days until deletion"))
|
||||
input type="number" name="days_until_deletion" value="60"
|
||||
min="60" max="365"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Reason"))
|
||||
select name="reason_code" required
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary" {
|
||||
option value="" disabled selected { "Choose a reason" }
|
||||
@for &(value, label) in DELETION_REASONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_card(
|
||||
base: &str,
|
||||
self_hosted: bool,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
context: &ModerationContext<'_>,
|
||||
) -> Markup {
|
||||
let prefill = &context.prefill;
|
||||
html! {
|
||||
(card_with_header("Close account", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=close&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(basis_fields(&BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix: "close",
|
||||
kind: EnforcementKindType::Closure,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
},
|
||||
choices: &[
|
||||
("enforcement", "A rule was broken"),
|
||||
("holder_request", "The account holder asked"),
|
||||
("housekeeping", "Inactivity"),
|
||||
],
|
||||
plain_note_help: "Staff only. Saved in the audit log. The holder can cancel the deletion by signing in.",
|
||||
enforcement_extra: html! {
|
||||
(remove_evidence_field())
|
||||
@if let Some(candidates) = context.network_candidates {
|
||||
(network_block_fields("close", candidates))
|
||||
}
|
||||
},
|
||||
}))
|
||||
(deletion_days_field("close-days", self_hosted))
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
input type="hidden" name="replace_pending_deletion_at" value=(pending);
|
||||
p class=(FIELD_HELP_CLASS) { "This replaces the deletion already scheduled." }
|
||||
}
|
||||
(form_actions(danger_button("Close account")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_evidence_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
(checkbox("remove_evidence", "true", "Remove these messages", false, true))
|
||||
p class=(FIELD_HELP_CLASS) { "Removes the messages listed above after they are saved as evidence." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn content_basis_fields<'a>(
|
||||
id_prefix: &'a str,
|
||||
user: &'a AdminUser,
|
||||
prefill: &'a DecisionPrefill,
|
||||
snapshot_entries: bool,
|
||||
) -> BasisFields<'a> {
|
||||
BasisFields {
|
||||
decision: DecisionFields {
|
||||
id_prefix,
|
||||
kind: EnforcementKindType::ContentRemoval,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: (!snapshot_entries).then_some(prefill.messages.as_slice()),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: snapshot_entries,
|
||||
},
|
||||
choices: CONTENT_BASIS_CHOICES,
|
||||
plain_note_help: "Staff only. Saved in the audit log.",
|
||||
enforcement_extra: html! {},
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_card(base: &str, user: &AdminUser, csrf_token: &str, prefill: &DecisionPrefill) -> Markup {
|
||||
let reports_end = (time::OffsetDateTime::now_utc()
|
||||
+ time::Duration::days(REPORTS_LIMIT_DEFAULT_DAYS))
|
||||
.date()
|
||||
.to_string();
|
||||
html! {
|
||||
(card_with_header("Limit features", html! {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=limit_features&tab=moderation"}
|
||||
data-admin-refresh-on-success=(REFRESH_TARGET) {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(enforcement_fields(&DecisionFields {
|
||||
id_prefix: "limit",
|
||||
kind: EnforcementKindType::FeatureLimit,
|
||||
report_ids: &prefill.report_ids,
|
||||
messages: Some(&prefill.messages),
|
||||
user_flags: Some(user.flags),
|
||||
allow_restricted: true,
|
||||
}))
|
||||
fieldset class="space-y-1" {
|
||||
legend class=(FIELD_LABEL_CLASS) { "Limit" }
|
||||
div class="flex flex-wrap gap-x-4 gap-y-1" {
|
||||
@for limit in ENFORCEMENT_LIMITS {
|
||||
@let id = format!("limit-{}", limit.value);
|
||||
@let codes = limit.codes.iter().map(ToString::to_string).collect::<Vec<_>>().join(" ");
|
||||
div class="flex items-center gap-2" data-show-for-codes=(codes) hidden {
|
||||
input type="radio" id=(id) name="limit" value=(limit.value) required disabled
|
||||
class="h-4 w-4 accent-neutral-900";
|
||||
label for=(id) class="text-sm text-neutral-900" { (limit.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
(form_label("Public Reason (optional)"))
|
||||
input type="text" name="public_reason"
|
||||
placeholder="Enter public reason..." maxlength="512"
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(form_label("Private Reason (optional)"))
|
||||
input type="text" name="private_reason"
|
||||
placeholder="Enter private reason (audit log)..."
|
||||
class="block w-full rounded-md border border-neutral-300 \
|
||||
px-3 py-2 text-sm shadow-sm \
|
||||
focus:border-brand-primary focus:outline-none \
|
||||
focus:ring-1 focus:ring-brand-primary";
|
||||
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
|
||||
(form_actions(html! {
|
||||
(submit_button("Schedule Deletion"))
|
||||
}))
|
||||
p class=(FIELD_HELP_CLASS) { "Each reason offers only the limits it allows." }
|
||||
}
|
||||
fieldset class="space-y-1" data-show-for-limit="reports" hidden disabled {
|
||||
(field_label("limit-ends-on", "Ends on"))
|
||||
input type="date" id="limit-ends-on" name="ends_on" value=(reports_end) required class=(FIELD_INPUT_CLASS);
|
||||
p class=(FIELD_HELP_CLASS) { "At most 365 days from now." }
|
||||
}
|
||||
(form_actions(submit_button("Limit features")))
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -402,36 +580,34 @@ fn delete_all_messages_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
prefill: &DecisionPrefill,
|
||||
dry_run: Option<(u64, u64)>,
|
||||
) -> Markup {
|
||||
let action = format!(
|
||||
"{base}/users/{}?action=delete_all_messages&tab=moderation",
|
||||
user.id
|
||||
);
|
||||
html! {
|
||||
(card_with_header("Delete All Messages", html! {
|
||||
(card_with_header("Delete all messages", html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Locate every message this user has ever sent and permanently remove them. First run a dry run to see how many channels and messages will be affected."
|
||||
"Removes every message this user has sent. Preview the count first. Child safety and violent extremism reasons can't be used here, because the messages can't all be saved as evidence first. Use the message shredder with the messages instead."
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=delete_all_messages&tab=moderation"}
|
||||
data-admin-result-form="true" {
|
||||
form method="post" action=(action) data-admin-result-form="true" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="true";
|
||||
(form_actions(html! {
|
||||
(submit_button("Preview Deletion"))
|
||||
}))
|
||||
(form_actions(submit_button("Preview deletion")))
|
||||
}
|
||||
@if let Some((channel_count, message_count)) = dry_run {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
form method="post" action=(action)
|
||||
class="space-y-4 rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="false";
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Channels: " (channel_count) " · Messages: " (message_count)
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=delete_all_messages&tab=moderation"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="dry_run" value="false";
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
(count_noun(message_count, "message", "messages")) " in " (count_noun(channel_count, "channel", "channels")) "."
|
||||
}
|
||||
(basis_fields(&content_basis_fields("delete-all", user, prefill, false)))
|
||||
(form_actions(danger_button("Delete all messages")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,23 +619,24 @@ fn message_shred_card(
|
||||
base: &str,
|
||||
user: &AdminUser,
|
||||
csrf_token: &str,
|
||||
prefill: &DecisionPrefill,
|
||||
job_id: Option<&str>,
|
||||
status: Option<&ApiResult<MessageShredStatusResponse>>,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Message Shredder", html! {
|
||||
(card_with_header("Message shredder", html! {
|
||||
div class="space-y-4" {
|
||||
p class="text-sm text-neutral-600" {
|
||||
"Upload a CSV file where each row includes the channel_id and message_id separated by a comma. Large files are chunked server-side automatically."
|
||||
"Upload a CSV file with one channel_id,message_id pair per row. When a rule was broken, the first 50 messages are saved as evidence before they are removed, and every message for child safety and violent extremism reasons."
|
||||
}
|
||||
form method="post"
|
||||
action={(base) "/users/" (user.id) "?action=message_shred&tab=moderation"}
|
||||
id="message-shred-form" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="csv_data" id="message-shred-csv-data";
|
||||
div class="space-y-3" {
|
||||
div class="space-y-4" {
|
||||
div class="space-y-2" {
|
||||
(form_label("CSV File"))
|
||||
(field_label("message-shred-file", "CSV file"))
|
||||
input id="message-shred-file" type="file" accept=".csv" class="hidden";
|
||||
div class="flex items-center gap-2" {
|
||||
button type="button"
|
||||
@@ -472,10 +649,11 @@ fn message_shred_card(
|
||||
}
|
||||
}
|
||||
}
|
||||
(basis_fields(&content_basis_fields("shred", user, prefill, true)))
|
||||
button type="submit"
|
||||
id="message-shred-submit"
|
||||
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-neutral-900 px-4 py-2 text-base font-medium text-white transition-all duration-150 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50" {
|
||||
span { "Shred Messages" }
|
||||
span { "Shred messages" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,7 +676,7 @@ fn message_shred_status_section(
|
||||
html! {
|
||||
div class="space-y-3 rounded-lg border border-neutral-200 bg-white p-4" {
|
||||
div class="flex items-center justify-between gap-3" {
|
||||
h3 class="text-sm font-medium text-neutral-900" { "Message Shred Status" }
|
||||
h3 class="text-sm font-medium text-neutral-900" { "Message shred status" }
|
||||
a href={(base) "/users/" (user.id) "?tab=moderation"}
|
||||
class="text-sm text-neutral-500 hover:text-neutral-900" {
|
||||
"Clear"
|
||||
@@ -623,12 +801,6 @@ fn message_shred_form_script() -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn form_label(text: &str) -> Markup {
|
||||
maud::html! {
|
||||
label class="block text-sm font-medium text-neutral-700" { (text) }
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var form = document.getElementById('message-shred-form');
|
||||
@@ -662,7 +834,7 @@ const MESSAGE_SHRED_FORM_SCRIPT: &str = r#"
|
||||
function resetSubmit(label) {
|
||||
processing = false;
|
||||
submitButton.disabled = false;
|
||||
submitButton.querySelector('span').textContent = label || 'Shred Messages';
|
||||
submitButton.querySelector('span').textContent = label || 'Shred messages';
|
||||
}
|
||||
file.addEventListener('change', function () {
|
||||
if (fileName) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
form::{checkbox, csrf_input, form_actions, submit_button},
|
||||
page_container::{card_with_header, detail_row},
|
||||
},
|
||||
templates::pages::enforcement_decision::suspension_label,
|
||||
utils::{
|
||||
bigint::format_discriminator,
|
||||
timestamps::{format_admin_timestamp, snowflake_creation_date},
|
||||
@@ -64,12 +65,8 @@ fn render_overview_tab(
|
||||
) -> Markup {
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
@if let Some(until) = &user.temp_banned_until {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" {
|
||||
"Temporarily banned until " (format_admin_timestamp(until))
|
||||
}
|
||||
}
|
||||
@if user.temp_banned_until.is_none() {
|
||||
(restriction_banner(config, user))
|
||||
@if user.temp_banned_until.is_none() && user.enforcement_decision_id.is_none() {
|
||||
@if let Some(pending) = &user.pending_deletion_at {
|
||||
div class="rounded-lg border border-orange-200 bg-orange-50 p-4 text-sm text-orange-900" {
|
||||
div class="font-medium" { "Scheduled for deletion: " (format_admin_timestamp(pending)) }
|
||||
@@ -262,6 +259,30 @@ fn render_overview_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn restriction_banner(config: &AdminConfig, user: &AdminUser) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@match (&user.enforcement_decision_id, &user.temp_banned_until) {
|
||||
(Some(decision_id), until) => {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" data-restriction-banner="" {
|
||||
@match (until, &user.pending_deletion_at) {
|
||||
(Some(until), _) => { (suspension_label(until)) ". " }
|
||||
(None, Some(pending)) => { "Scheduled for deletion on " (format_admin_timestamp(pending)) ". " }
|
||||
(None, None) => { "Restricted by a decision. " }
|
||||
}
|
||||
a href={(base) "/decisions/" (decision_id)} class="underline" { "Decision " (decision_id) }
|
||||
}
|
||||
}
|
||||
(None, Some(until)) => {
|
||||
div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm font-medium text-red-900" data-restriction-banner="" {
|
||||
(suspension_label(until)) ". Recorded before decisions, so it has no decision."
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_card(
|
||||
config: &AdminConfig,
|
||||
user: &AdminUser,
|
||||
@@ -269,118 +290,58 @@ fn flags_card(
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let can_update_flags = acl::has_permission(admin_acls, acl::USER_UPDATE_FLAGS);
|
||||
let suspended = user.temp_banned_until.is_some();
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
(u64_flag_form(
|
||||
(flag_form_shell(
|
||||
config,
|
||||
&user.id,
|
||||
"User Flags",
|
||||
"User flags",
|
||||
"update_flags",
|
||||
"flags[]",
|
||||
user.flags,
|
||||
admin_flags::USER_FLAGS,
|
||||
csrf_token,
|
||||
can_update_flags,
|
||||
Some(acl::USER_UPDATE_FLAGS),
|
||||
html! {
|
||||
@for flag in admin_flags::USER_FLAGS {
|
||||
(checkbox(
|
||||
"flags[]",
|
||||
&flag.value.to_string(),
|
||||
flag.name,
|
||||
user.flags & flag.value != 0,
|
||||
can_update_flags && admin_flags::user_flag_is_editable(flag.value, user.flags, suspended),
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
(i32_flag_form(
|
||||
(flag_form_shell(
|
||||
config,
|
||||
&user.id,
|
||||
"Premium Flags",
|
||||
"Premium flags",
|
||||
"update_premium_flags",
|
||||
"flags[]",
|
||||
user.premium_flags,
|
||||
admin_flags::PREMIUM_FLAGS,
|
||||
csrf_token,
|
||||
can_update_flags,
|
||||
Some(acl::USER_UPDATE_FLAGS),
|
||||
html! {
|
||||
@for flag in admin_flags::PREMIUM_FLAGS {
|
||||
(checkbox(
|
||||
"flags[]",
|
||||
&flag.value.to_string(),
|
||||
flag.name,
|
||||
user.premium_flags & flag.value != 0,
|
||||
can_update_flags && admin_flags::premium_flag_is_editable(flag.value),
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn u64_flag_form(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
input_name: &str,
|
||||
value: u64,
|
||||
flags: &[admin_flags::U64Flag],
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
) -> Markup {
|
||||
flag_form_shell(
|
||||
config,
|
||||
user_id,
|
||||
title,
|
||||
action,
|
||||
value.to_string(),
|
||||
csrf_token,
|
||||
can_edit,
|
||||
required_acl,
|
||||
html! {
|
||||
@for flag in flags {
|
||||
(flag_checkbox(
|
||||
input_name,
|
||||
flag.value.to_string(),
|
||||
flag.name,
|
||||
value & flag.value != 0,
|
||||
can_edit,
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn i32_flag_form(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
input_name: &str,
|
||||
value: i32,
|
||||
flags: &[admin_flags::I32Flag],
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
) -> Markup {
|
||||
flag_form_shell(
|
||||
config,
|
||||
user_id,
|
||||
title,
|
||||
action,
|
||||
value.to_string(),
|
||||
csrf_token,
|
||||
can_edit,
|
||||
required_acl,
|
||||
html! {
|
||||
@for flag in flags {
|
||||
(flag_checkbox(
|
||||
input_name,
|
||||
flag.value.to_string(),
|
||||
flag.name,
|
||||
value & flag.value != 0,
|
||||
can_edit,
|
||||
))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flag_form_shell(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
title: &str,
|
||||
action: &str,
|
||||
_raw_value: String,
|
||||
csrf_token: &str,
|
||||
can_edit: bool,
|
||||
required_acl: Option<&str>,
|
||||
checkboxes: Markup,
|
||||
) -> Markup {
|
||||
card_with_header(
|
||||
@@ -392,14 +353,15 @@ fn flag_form_shell(
|
||||
(checkboxes)
|
||||
}
|
||||
@if can_edit {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Restriction flags are read-only. Use the moderation tab."
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save"))
|
||||
}))
|
||||
} @else {
|
||||
@if let Some(required) = required_acl {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Read-only \u{2014} " (required) " permission required."
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Read-only. Needs the " (acl::USER_UPDATE_FLAGS) " permission."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ impl MultiValueForm {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn opt_out_value(&self, key: &str) -> bool {
|
||||
!self.contains_key(&format!("{key}_present")) || self.bool_value(key)
|
||||
}
|
||||
|
||||
pub fn list_values(&self, key: &str) -> Vec<String> {
|
||||
self.fields
|
||||
.get(key)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -43,87 +43,119 @@ async fn submit(uri: &str, fields: &str) -> CapturedRequest {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn temp_ban_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=temp_ban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"duration=24&reason=Spam¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PUT /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(&uri, "duration=24&reason=Spam¬ify_user_present=1").await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "duration=24&reason=Spam").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unban_sends_the_public_reason_in_the_body_and_the_private_reason_as_a_header() {
|
||||
async fn unban_sends_the_internal_note_as_a_header_and_no_body() {
|
||||
let uri = format!("/users/{USER_ID}?action=unban&tab=moderation");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"public_reason=Appeal%20accepted&private_reason=Private%20staff%20note¬ify_user_present=1¬ify_user=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("DELETE /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
assert_eq!(checked.body["public_reason"], json!("Appeal accepted"));
|
||||
let captured = submit(&uri, "internal_note=Private%20staff%20note").await;
|
||||
assert_eq!(captured.route, format!("DELETE /admin/users/{USER_ID}/ban"));
|
||||
assert_eq!(
|
||||
checked.audit_log_reason.as_deref(),
|
||||
captured.audit_log_reason.as_deref(),
|
||||
Some("Private staff note")
|
||||
);
|
||||
assert!(!checked.body.to_string().contains("Private staff note"));
|
||||
let unchecked = submit(
|
||||
assert!(!captured.body.to_string().contains("Private staff note"));
|
||||
}
|
||||
|
||||
const DECISION_FIELDS: &str = "reason_code=3&source=own_review&automation=none&statement=You%20sent%20the%20same%20link%20to%20many%20people.";
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_sends_the_basis_and_never_a_notify_flag() {
|
||||
let uri = format!("/users/{USER_ID}?action=close&tab=moderation");
|
||||
let holder = submit(
|
||||
&uri,
|
||||
"private_reason=Private%20staff%20note¬ify_user_present=1",
|
||||
"basis=holder_request&days_until_deletion=30&internal_note=Asked%20by%20email",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
assert!(!unchecked.body.to_string().contains("Private staff note"));
|
||||
let stale_form = submit(&uri, "").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
assert_eq!(holder.route, format!("PUT /admin/users/{USER_ID}/deletion"));
|
||||
assert_eq!(
|
||||
holder.body,
|
||||
json!({"basis": {"type": "holder_request"}, "days_until_deletion": 30})
|
||||
);
|
||||
assert_eq!(holder.audit_log_reason.as_deref(), Some("Asked by email"));
|
||||
let enforcement = submit(
|
||||
&uri,
|
||||
&format!(
|
||||
"basis=enforcement&days_until_deletion=74&{DECISION_FIELDS}&internal_note=Spam%20review&remove_evidence=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(enforcement.body["basis"]["type"], json!("enforcement"));
|
||||
assert_eq!(
|
||||
enforcement.body["basis"]["decision"]["reason_code"],
|
||||
json!(3)
|
||||
);
|
||||
assert_eq!(enforcement.body["remove_evidence"], json!(true));
|
||||
assert_eq!(enforcement.audit_log_reason.as_deref(), Some("Spam review"));
|
||||
assert!(enforcement.body.get("notify_user").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = format!("/users/{USER_ID}?action=schedule_deletion&tab=moderation");
|
||||
let checked = submit(
|
||||
async fn cancel_deletion_sends_only_the_pending_deletion() {
|
||||
let uri = format!("/users/{USER_ID}?action=cancel_deletion&tab=moderation");
|
||||
let captured = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1¬ify_user=true",
|
||||
"expected_pending_deletion_at=2026-12-01T00%3A00%3A00.000Z&confirm=true&internal_note=Asked%20to%20keep%20it",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
checked.route,
|
||||
format!("PUT /admin/users/{USER_ID}/deletion")
|
||||
captured.route,
|
||||
format!("DELETE /admin/users/{USER_ID}/deletion")
|
||||
);
|
||||
assert_eq!(
|
||||
captured.body,
|
||||
json!({"expected_pending_deletion_at": "2026-12-01T00:00:00.000Z"})
|
||||
);
|
||||
assert_eq!(
|
||||
captured.audit_log_reason.as_deref(),
|
||||
Some("Asked to keep it")
|
||||
);
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
"reason_code=3&days_until_deletion=60¬ify_user_present=1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(&uri, "reason_code=3&days_until_deletion=60").await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_schedule_deletion_sends_notify_user_from_the_checkbox() {
|
||||
let uri = "/bulk-actions?action=bulk-schedule-user-deletion";
|
||||
let fields = format!("user_ids={USER_ID}&reason_code=3&days_until_deletion=60");
|
||||
let checked = submit(
|
||||
uri,
|
||||
&format!("{fields}¬ify_user_present=1¬ify_user=true"),
|
||||
async fn limit_features_sends_one_limit_with_its_end() {
|
||||
let uri = format!("/users/{USER_ID}?action=limit_features&tab=moderation");
|
||||
let captured = submit(
|
||||
&uri,
|
||||
"limit=reports&ends_on=2026-12-01&reason_code=29&source=own_review&automation=none&statement=False%20reports&internal_note=Report%20review",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, "POST /admin/bulk-jobs");
|
||||
assert_eq!(checked.body["task"], json!("schedule_user_deletion"));
|
||||
assert_eq!(checked.body["notify_user"], json!(true));
|
||||
let unchecked = submit(uri, &format!("{fields}¬ify_user_present=1")).await;
|
||||
assert_eq!(unchecked.body["notify_user"], json!(false));
|
||||
let stale_form = submit(uri, &fields).await;
|
||||
assert_eq!(stale_form.body["notify_user"], json!(true));
|
||||
assert_eq!(
|
||||
captured.route,
|
||||
format!("PUT /admin/users/{USER_ID}/feature-limits")
|
||||
);
|
||||
assert_eq!(captured.body["limit"], json!("reports"));
|
||||
assert_eq!(captured.body["ends_at"], json!("2026-12-01T00:00:00Z"));
|
||||
assert_eq!(captured.body["decision"]["reason_code"], json!(29));
|
||||
let purchases = submit(
|
||||
&uri,
|
||||
"limit=purchases&ends_on=2026-12-01&reason_code=14&source=own_review&automation=none&statement=Disputes&internal_note=Billing",
|
||||
)
|
||||
.await;
|
||||
assert!(purchases.body.get("ends_at").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bulk_close_sends_one_bulk_decision() {
|
||||
let captured = submit(
|
||||
"/bulk-actions?action=bulk-schedule-user-deletion",
|
||||
&format!(
|
||||
"user_ids={USER_ID}&basis=enforcement&days_until_deletion=74&reason_code=3&automation=none&statement=Same%20invite%20link&internal_note=Spam%20sweep"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(captured.route, "POST /admin/bulk-jobs");
|
||||
assert_eq!(captured.body["task"], json!("schedule_user_deletion"));
|
||||
assert_eq!(
|
||||
captured.body["basis"],
|
||||
json!({
|
||||
"type": "enforcement",
|
||||
"decision": {
|
||||
"reason_code": 3,
|
||||
"source": "own_review",
|
||||
"automation": "none",
|
||||
"statement": "Same invite link"
|
||||
}
|
||||
})
|
||||
);
|
||||
assert_eq!(captured.audit_log_reason.as_deref(), Some("Spam sweep"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -131,12 +163,13 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
let uri = format!("/reports/{REPORT_ID}/resolve");
|
||||
let checked = submit(
|
||||
&uri,
|
||||
"resolution=actioned&public_comment=Handled¬ify_reporter_present=1¬ify_reporter=true",
|
||||
"resolution=actioned&outcome=removed&public_comment=Handled¬ify_reporter_present=1¬ify_reporter=true",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(checked.route, format!("PATCH /admin/reports/{REPORT_ID}"));
|
||||
assert_eq!(checked.body["public_comment"], json!("Handled"));
|
||||
assert_eq!(checked.body["resolution"], json!("actioned"));
|
||||
assert_eq!(checked.body["outcome"], json!("removed"));
|
||||
assert_eq!(checked.body["notify_reporter"], json!(true));
|
||||
let unchecked = submit(
|
||||
&uri,
|
||||
@@ -144,8 +177,17 @@ async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchecked.body["notify_reporter"], json!(false));
|
||||
let stale_form = submit(&uri, "resolution=duplicate&public_comment=Handled").await;
|
||||
assert_eq!(stale_form.body["notify_reporter"], json!(true));
|
||||
assert_eq!(unchecked.body["outcome"], json!("no_violation"));
|
||||
let duplicate = submit(
|
||||
&uri,
|
||||
"resolution=duplicate&outcome=restricted&duplicate_of=1400000000000000001&public_comment=Handled",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(duplicate.body["notify_reporter"], json!(true));
|
||||
assert!(duplicate.body.get("outcome").is_none());
|
||||
assert_eq!(duplicate.body["duplicate_of"], json!("1400000000000000001"));
|
||||
let other_action = submit(&uri, "resolution=actioned&outcome=action_taken").await;
|
||||
assert_eq!(other_action.body["outcome"], json!("action_taken"));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
|
||||
Reference in new issue
Block a user