feat(admin): add optional expiry to admin IP bans (#3163)

This commit is contained in:
Hampus
2026-10-03 14:41:00 +02:00
committed by GitHub
parent 1664050ef7
commit e9167d96ec
88 changed files with 462 additions and 139 deletions
+18 -3
View File
@@ -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"},
+14 -2
View File
@@ -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,
)
+2
View File
@@ -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>,
}
+1 -10
View File
@@ -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)
}
+42 -5
View File
@@ -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");
+4 -1
View File
@@ -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",
)
+47
View File
@@ -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"]}"#;