mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
311 lines
9.8 KiB
Rust
311 lines
9.8 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::api::client::{AdminApiClient, ApiResult};
|
|
use crate::api::types::{FlashLevel, FlashMessage};
|
|
use crate::middleware::auth::AuthContext;
|
|
use crate::templates;
|
|
use crate::utils::timestamps::format_admin_timestamp;
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct BanFormData {
|
|
#[serde(default)]
|
|
pub ip: Option<String>,
|
|
#[serde(default)]
|
|
pub email: Option<String>,
|
|
#[serde(default)]
|
|
pub domain: Option<String>,
|
|
#[serde(default)]
|
|
pub phrase: Option<String>,
|
|
#[serde(default)]
|
|
pub url: Option<String>,
|
|
#[serde(default)]
|
|
pub sha256_hex: Option<String>,
|
|
#[serde(default)]
|
|
pub hash_short: Option<String>,
|
|
#[serde(default)]
|
|
pub hashes: Option<String>,
|
|
#[serde(default)]
|
|
pub sha256_list: Option<String>,
|
|
#[serde(default)]
|
|
pub match_subdomains: Option<String>,
|
|
#[serde(default)]
|
|
pub scope: Option<String>,
|
|
#[serde(default)]
|
|
pub substring: Option<String>,
|
|
#[serde(default)]
|
|
pub duration_hours: Option<String>,
|
|
#[serde(default)]
|
|
pub audit_log_reason: Option<String>,
|
|
#[serde(default)]
|
|
pub _csrf: Option<String>,
|
|
}
|
|
|
|
pub fn extract_value(form: &BanFormData, field: &str) -> String {
|
|
let v = match field {
|
|
"ip" => form.ip.as_deref(),
|
|
"email" => form.email.as_deref(),
|
|
"domain" => form.domain.as_deref(),
|
|
"phrase" => form.phrase.as_deref(),
|
|
"url" => form.url.as_deref(),
|
|
"sha256_hex" => form.sha256_hex.as_deref(),
|
|
"hash_short" => form.hash_short.as_deref(),
|
|
_ => None,
|
|
};
|
|
v.unwrap_or("").trim().to_owned()
|
|
}
|
|
|
|
pub async fn execute_ban(
|
|
client: &AdminApiClient,
|
|
ban_type: &str,
|
|
action: &str,
|
|
value: &str,
|
|
form: &BanFormData,
|
|
) -> (&'static str, String) {
|
|
let bulk_hashes = form.hashes.as_deref();
|
|
let bulk_sha256_list = form.sha256_list.as_deref();
|
|
let duration_hours = form.duration_hours.as_deref();
|
|
let audit_log_reason = form.audit_log_reason.as_deref();
|
|
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
|
let raw_hashes = if action == "bulk-ban-files" {
|
|
bulk_sha256_list
|
|
} else {
|
|
bulk_hashes
|
|
};
|
|
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
|
|
}
|
|
if value.is_empty() {
|
|
return ("error", "Value is required".into());
|
|
}
|
|
match action {
|
|
"ban" if ban_type == "ip-bans" => {
|
|
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
|
|
}
|
|
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
|
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
|
"check" => execute_check(client, ban_type, value).await,
|
|
_ => ("error", "Unknown action".into()),
|
|
}
|
|
}
|
|
|
|
async fn execute_bulk_ban(
|
|
client: &AdminApiClient,
|
|
bulk_hashes: Option<&str>,
|
|
audit_log_reason: Option<&str>,
|
|
) -> (&'static str, String) {
|
|
let hashes: Vec<String> = bulk_hashes
|
|
.unwrap_or("")
|
|
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
|
|
.map(|l| l.trim().to_lowercase())
|
|
.filter(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit()))
|
|
.collect();
|
|
if hashes.is_empty() {
|
|
return (
|
|
"error",
|
|
"No valid 64-character hex hashes found in input".into(),
|
|
);
|
|
}
|
|
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
|
|
Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
|
|
("error", "Failed to enqueue bulk ban job".into())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn execute_ip_ban(
|
|
client: &AdminApiClient,
|
|
value: &str,
|
|
duration_hours: Option<&str>,
|
|
audit_log_reason: Option<&str>,
|
|
) -> (&'static str, String) {
|
|
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
|
|
None => 0,
|
|
Some(raw) => match raw.parse::<u32>() {
|
|
Ok(hours) => hours,
|
|
Err(_) => return ("error", "Invalid ban duration".into()),
|
|
},
|
|
};
|
|
let success_message = if duration_hours == 0 {
|
|
format!("{value} banned permanently")
|
|
} else {
|
|
format!(
|
|
"{value} banned for {}",
|
|
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
|
|
)
|
|
};
|
|
ban_action_result(
|
|
client.ban_ip(value, duration_hours, audit_log_reason).await,
|
|
success_message,
|
|
format!("Failed to ban {value}"),
|
|
)
|
|
}
|
|
|
|
async fn execute_single_ban(
|
|
client: &AdminApiClient,
|
|
ban_type: &str,
|
|
value: &str,
|
|
audit_log_reason: Option<&str>,
|
|
) -> (&'static str, String) {
|
|
let result = match ban_type {
|
|
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
|
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
|
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
|
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
|
|
"avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await,
|
|
_ => return ("error", "Unknown ban type".into()),
|
|
};
|
|
ban_action_result(
|
|
result,
|
|
format!("{value} banned successfully"),
|
|
format!("Failed to ban {value}"),
|
|
)
|
|
}
|
|
|
|
async fn execute_single_unban(
|
|
client: &AdminApiClient,
|
|
ban_type: &str,
|
|
value: &str,
|
|
audit_log_reason: Option<&str>,
|
|
) -> (&'static str, String) {
|
|
let result = match ban_type {
|
|
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
|
|
"email-bans" => client.unban_email(value, audit_log_reason).await,
|
|
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
|
|
"url-bans" => client.unban_url(value, audit_log_reason).await,
|
|
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
|
|
"avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await,
|
|
_ => return ("error", "Unknown ban type".into()),
|
|
};
|
|
ban_action_result(
|
|
result,
|
|
format!("{value} unbanned successfully"),
|
|
format!("Failed to unban {value}"),
|
|
)
|
|
}
|
|
|
|
async fn execute_check(
|
|
client: &AdminApiClient,
|
|
ban_type: &str,
|
|
value: &str,
|
|
) -> (&'static str, String) {
|
|
let result = match ban_type {
|
|
"ip-bans" => client.check_ip_ban(value).await,
|
|
"email-bans" => client.check_email_ban(value).await,
|
|
"phrase-bans" => client.check_phrase_ban(value).await,
|
|
"url-bans" => client.check_url_ban(value).await,
|
|
"file-sha-bans" => client.check_file_sha_ban(value).await,
|
|
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
|
|
_ => return ("error", "Unknown ban type".into()),
|
|
};
|
|
match result {
|
|
Ok(r) if r.banned => match r.expires_at {
|
|
Some(expires_at) => (
|
|
"info",
|
|
format!(
|
|
"{value} is banned until {}",
|
|
format_admin_timestamp(&expires_at)
|
|
),
|
|
),
|
|
None => ("info", format!("{value} is banned")),
|
|
},
|
|
Ok(_) => ("info", format!("{value} is NOT banned")),
|
|
Err(error) => {
|
|
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
|
("error", "Error checking ban status".into())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn ban_action_result(
|
|
result: ApiResult<()>,
|
|
success_message: String,
|
|
error_message: String,
|
|
) -> (&'static str, String) {
|
|
match result {
|
|
Ok(()) => ("success", success_message),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "admin API request failed: ban action");
|
|
("error", error_message)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn flash_response(
|
|
config: &crate::config::AdminConfig,
|
|
auth: &AuthContext,
|
|
is_htmx: bool,
|
|
level: &str,
|
|
message: &str,
|
|
ban_cfg: &templates::pages::bans::BanConfig,
|
|
csrf_token: &str,
|
|
username_sign_in: bool,
|
|
) -> Response {
|
|
if is_htmx {
|
|
render_inline_flash(level, message)
|
|
} else {
|
|
let flash = to_flash(level, message);
|
|
let markup = templates::pages::bans::bans_page(
|
|
config,
|
|
auth,
|
|
ban_cfg,
|
|
Some(&flash),
|
|
csrf_token,
|
|
username_sign_in,
|
|
);
|
|
Html(markup.into_string()).into_response()
|
|
}
|
|
}
|
|
|
|
pub fn render_inline_flash(level: &str, message: &str) -> Response {
|
|
let (border, bg, text) = match level {
|
|
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
|
|
"error" => ("border-red-300", "bg-red-50", "text-red-800"),
|
|
_ => ("border-blue-300", "bg-blue-50", "text-blue-800"),
|
|
};
|
|
let markup = maud::html! {
|
|
div class="mb-4 sm:mb-6" {
|
|
div class={"rounded-lg border px-4 py-3 text-sm " (border) " " (bg) " " (text)}
|
|
role="alert" {
|
|
(message)
|
|
}
|
|
}
|
|
};
|
|
Html(markup.into_string()).into_response()
|
|
}
|
|
|
|
pub fn to_flash(level: &str, message: &str) -> FlashMessage {
|
|
FlashMessage {
|
|
level: match level {
|
|
"success" => FlashLevel::Success,
|
|
"error" => FlashLevel::Error,
|
|
_ => FlashLevel::Info,
|
|
},
|
|
message: message.to_owned(),
|
|
}
|
|
}
|
|
|
|
pub fn custom_flash(
|
|
config: &crate::config::AdminConfig,
|
|
auth: &AuthContext,
|
|
is_htmx: bool,
|
|
level: &str,
|
|
message: &str,
|
|
csrf_token: &str,
|
|
) -> Response {
|
|
if is_htmx {
|
|
return render_inline_flash(level, message);
|
|
}
|
|
let flash = to_flash(level, message);
|
|
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
|
|
config,
|
|
auth,
|
|
Some(&flash),
|
|
csrf_token,
|
|
);
|
|
Html(markup.into_string()).into_response()
|
|
}
|