feat(enforcement): record decisions and send notices (#3492)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 07:12:46 +02:00
1 parent da1842ecab
commit f5c30fdf8f
383 files changed
+29692 -4139

No files matched your search

+17 -1
View File
@@ -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)
+196
View File
@@ -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"),
}
}
+41 -7
View File
@@ -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,
+76 -35
View File
@@ -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>,
+3 -9
View File
@@ -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()
}
+2
View File
@@ -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())
+39 -2
View File
@@ -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>,
+287 -166
View File
@@ -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, &note).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]
+32 -4
View File
@@ -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,
+6
View File
@@ -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(),
}
}