mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user