mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(admin): add optional expiry to admin IP bans (#3163)
This commit is contained in:
@@ -12181,8 +12181,15 @@
|
||||
},
|
||||
"BanCheckResponseSchema": {
|
||||
"type": "object",
|
||||
"properties": {"banned": {"type": "boolean"}},
|
||||
"required": ["banned"],
|
||||
"properties": {
|
||||
"banned": {"type": "boolean"},
|
||||
"expires_at": {
|
||||
"nullable": true,
|
||||
"description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["banned", "expires_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminBlocklistBulkDeleteRequest": {
|
||||
@@ -13017,7 +13024,15 @@
|
||||
},
|
||||
"BanIpRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
|
||||
"properties": {
|
||||
"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"},
|
||||
"duration_hours": {
|
||||
"description": "Hours until the ban expires and its entry is removed. Omit it or use 0 for a permanent ban.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 8760
|
||||
}
|
||||
},
|
||||
"required": ["ip"]
|
||||
},
|
||||
"EmailBlocklistEntryType": {"type": "string"},
|
||||
|
||||
@@ -28,11 +28,23 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
pub async fn ban_ip(
|
||||
&self,
|
||||
ip: &str,
|
||||
duration_hours: u32,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() },
|
||||
generated_types::BanIpRequest {
|
||||
duration_hours: Some(
|
||||
i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
),
|
||||
ip: ip.to_owned(),
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
|
||||
@@ -255,6 +255,8 @@ pub enum FlashLevel {
|
||||
pub struct BanCheckResult {
|
||||
pub banned: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
||||
@@ -90,16 +90,7 @@ async fn generic_ban_post(
|
||||
};
|
||||
let value = extract_value(form, ban_cfg.input_name);
|
||||
let is_htmx = htmx::is_htmx_request(headers);
|
||||
let (level, msg) = execute_ban(
|
||||
&client,
|
||||
ban_key,
|
||||
action,
|
||||
&value,
|
||||
form.hashes.as_deref(),
|
||||
form.sha256_list.as_deref(),
|
||||
form.audit_log_reason.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct BanFormData {
|
||||
#[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>,
|
||||
@@ -58,10 +60,12 @@ pub async fn execute_ban(
|
||||
ban_type: &str,
|
||||
action: &str,
|
||||
value: &str,
|
||||
bulk_hashes: Option<&str>,
|
||||
bulk_sha256_list: Option<&str>,
|
||||
audit_log_reason: Option<&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
|
||||
@@ -74,6 +78,9 @@ pub async fn execute_ban(
|
||||
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,
|
||||
@@ -107,6 +114,34 @@ async fn execute_bulk_ban(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -114,7 +149,6 @@ async fn execute_single_ban(
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"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,
|
||||
@@ -166,7 +200,10 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {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");
|
||||
|
||||
@@ -243,8 +243,11 @@ pub async fn dispatch(
|
||||
let Some(ip) = get("ip") else {
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
};
|
||||
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_ip(&ip, None).await,
|
||||
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
|
||||
"IP banned successfully",
|
||||
"Failed to ban IP",
|
||||
)
|
||||
|
||||
@@ -20,6 +20,24 @@ pub struct BanConfig {
|
||||
pub entity_name: &'static str,
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
(24, "1 day"),
|
||||
(168, "7 days"),
|
||||
(720, "30 days"),
|
||||
(0, "Permanent"),
|
||||
];
|
||||
|
||||
pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
IP_BAN_DURATIONS
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
@@ -33,6 +51,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "IP/CIDR",
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -44,6 +63,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Email",
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -55,6 +75,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Phrase",
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -66,6 +87,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "URL",
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -77,6 +99,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "SHA-256",
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -88,6 +111,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Avatar Hash",
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -99,6 +123,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Domain",
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -110,6 +135,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Substring",
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,6 +189,9 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(form_field(cfg.input_name, cfg.input_label, cfg.input_type, cfg.placeholder, true))
|
||||
@if cfg.show_duration {
|
||||
(duration_field())
|
||||
}
|
||||
(form_field("audit_log_reason", "Private reason (audit log, optional)", "text", "Why is this ban being applied?", false))
|
||||
(submit_btn("Ban", cfg.entity_name, false))
|
||||
}
|
||||
@@ -394,6 +423,24 @@ fn form_field(
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
label for="duration_hours" class="block text-sm font-medium text-neutral-700" {
|
||||
"Duration"
|
||||
}
|
||||
select id="duration_hours" name="duration_hours"
|
||||
class="block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
|
||||
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
|
||||
focus:ring-brand-primary" {
|
||||
@for &(value, label) in IP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn textarea_field(name: &str, label: &str, required: bool) -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
|
||||
@@ -859,6 +859,14 @@ fn deserialize_ban_check_response() {
|
||||
assert!(resp.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response_with_expiry() {
|
||||
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_codes_response() {
|
||||
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
|
||||
|
||||
Reference in New Issue
Block a user