mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(blocklist): add url-domain host patterns (#3164)
This commit is contained in:
@@ -1451,7 +1451,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.",
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -12968,10 +12968,13 @@
|
||||
"BanUrlDomainRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"description": "Domain to ban (e.g. example.com)", "type": "string"},
|
||||
"domain": {
|
||||
"description": "Domain to ban (e.g. example.com), or a pattern whose leftmost label contains * under a registrable domain (e.g. *shop*.example.com). Internationalized names are stored in ASCII form.",
|
||||
"type": "string"
|
||||
},
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13075,7 +13078,7 @@
|
||||
"properties": {
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -148,6 +148,19 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn list_url_domain_entries(
|
||||
&self,
|
||||
after: Option<&str>,
|
||||
) -> ApiResult<BlocklistEntryPage> {
|
||||
let list_type = blocklist_list_type("url-domain")?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
&self,
|
||||
sha256_hex: &str,
|
||||
@@ -335,6 +348,8 @@ impl AdminApiClient {
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
const BLOCKLIST_PAGE_SIZE: &str = "200";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
|
||||
@@ -260,6 +260,25 @@ pub struct BanCheckResult {
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub match_subdomains: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntryPage {
|
||||
pub items: Vec<BlocklistEntry>,
|
||||
pub has_more: bool,
|
||||
#[serde(default)]
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
types::FlashMessage,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf, htmx},
|
||||
state::AppState,
|
||||
templates,
|
||||
@@ -13,10 +16,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ActionQuery;
|
||||
use super::bans_actions::{
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||
to_flash,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -132,16 +137,56 @@ ban_post!(url_bans_post, "url-bans");
|
||||
ban_post!(file_sha_bans_post, "file-sha-bans");
|
||||
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlDomainListQuery {
|
||||
after: Option<String>,
|
||||
}
|
||||
|
||||
async fn render_url_domain_page(
|
||||
state: &AppState,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&FlashMessage>,
|
||||
csrf_token: &str,
|
||||
after: Option<&str>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
|
||||
let entries = match client.list_url_domain_entries(after).await {
|
||||
Ok(page) => Some(page),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
|
||||
None
|
||||
}
|
||||
};
|
||||
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
flash,
|
||||
csrf_token,
|
||||
entries.as_ref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
|
||||
match error {
|
||||
ApiError::Http { status: 400, .. } => {
|
||||
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
|
||||
}
|
||||
_ => format!("Failed to ban {domain}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn url_domain_bans(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let csrf_token = csrf::get_csrf_token(&request);
|
||||
let markup =
|
||||
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
|
||||
Html(markup.into_string()).into_response()
|
||||
let Query(query): Query<UrlDomainListQuery> =
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
|
||||
let after = query.after.as_deref().filter(|value| !value.is_empty());
|
||||
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
|
||||
}
|
||||
|
||||
async fn url_domain_bans_post(
|
||||
@@ -172,10 +217,10 @@ async fn url_domain_bans_post(
|
||||
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||
Ok(()) => ("success", format!("{domain} banned successfully")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||
("error", format!("Failed to ban domain {domain}"))
|
||||
("error", ban_url_domain_error(&domain, &error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,15 +228,15 @@ async fn url_domain_bans_post(
|
||||
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||
Ok(()) => ("success", format!("{domain} unbanned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||
("error", format!("Failed to unban domain {domain}"))
|
||||
("error", format!("Failed to unban {domain}"))
|
||||
}
|
||||
},
|
||||
"check" => match client.check_url_domain_ban(&domain).await {
|
||||
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
|
||||
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
|
||||
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
|
||||
Ok(_) => ("info", format!("{domain} is NOT blocked")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
|
||||
("error", "Error checking ban status".into())
|
||||
@@ -199,15 +244,11 @@ async fn url_domain_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"url-domain",
|
||||
)
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, &msg);
|
||||
}
|
||||
let flash = to_flash(level, &msg);
|
||||
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
|
||||
}
|
||||
|
||||
async fn profile_substring_bans(
|
||||
@@ -279,13 +320,5 @@ async fn profile_substring_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"profile-substring",
|
||||
)
|
||||
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
|
||||
}
|
||||
|
||||
@@ -280,25 +280,16 @@ pub fn custom_flash(
|
||||
level: &str,
|
||||
message: &str,
|
||||
csrf_token: &str,
|
||||
page_type: &str,
|
||||
) -> Response {
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, message);
|
||||
}
|
||||
let flash = to_flash(level, message);
|
||||
let markup = match page_type {
|
||||
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
};
|
||||
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{BlocklistEntry, BlocklistEntryPage},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{form::checkbox, page_container::page_header},
|
||||
components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{checkbox, csrf_input},
|
||||
page_container::page_header,
|
||||
table::{data_table, empty_state, table_cell, table_row},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::blocklist_helpers::{
|
||||
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
|
||||
@@ -13,15 +19,21 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PAGE_DESCRIPTION: &str = "A domain entry blocks that host and, when it matches subdomains, every host under it. \
|
||||
A pattern such as *shop*.example.com matches the one label left of a registrable domain, so it blocks \
|
||||
shop.example.com and my-shop-2.example.com but never example.com itself. Patterns are matched against the \
|
||||
ASCII form of a host.";
|
||||
|
||||
pub fn url_domain_bans_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&crate::api::types::FlashMessage>,
|
||||
csrf_token: &str,
|
||||
entries: Option<&BlocklistEntryPage>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("URL Domain Blocklist", None))
|
||||
(page_header("URL Domain Blocklist", Some(PAGE_DESCRIPTION)))
|
||||
div class="grid gap-6 lg:grid-cols-2" {
|
||||
(ban_card(base, csrf_token))
|
||||
(check_card(base, csrf_token))
|
||||
@@ -29,6 +41,9 @@ pub fn url_domain_bans_page(
|
||||
div class="mt-6" {
|
||||
(unban_card(base, csrf_token))
|
||||
}
|
||||
div class="mt-6" {
|
||||
(entries_card(base, csrf_token, entries))
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
@@ -43,15 +58,15 @@ pub fn url_domain_bans_page(
|
||||
fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=ban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Ban URL Domain",
|
||||
"Ban URL Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(checkbox("match_subdomains", "true", "Match subdomains (e.g. sub.example.com)", true, true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being applied?", false))
|
||||
},
|
||||
"Ban Domain",
|
||||
"Ban",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -59,13 +74,13 @@ fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=check&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Check Domain Ban Status",
|
||||
"Test a Host or URL",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Host or URL", "shop-2.example.com or https://shop.example.com/x", true))
|
||||
},
|
||||
"Check Status",
|
||||
"Test",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -73,14 +88,131 @@ fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn unban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Remove Domain Ban",
|
||||
"Remove Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being removed?", false))
|
||||
},
|
||||
"Unban Domain",
|
||||
"Unban",
|
||||
BlocklistActionVariant::Danger,
|
||||
)
|
||||
}
|
||||
|
||||
fn entries_card(base: &str, csrf_token: &str, entries: Option<&BlocklistEntryPage>) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="mb-4 flex items-center justify-between gap-4" {
|
||||
h3 class="text-base font-medium text-neutral-900" { "Blocked Domains and Patterns" }
|
||||
a href={(base) "/url-domain-bans"} class="text-sm text-brand-primary hover:underline" { "Refresh" }
|
||||
}
|
||||
@match entries {
|
||||
None => {
|
||||
p class="text-sm text-red-700" { "Failed to load the blocklist entries" }
|
||||
}
|
||||
Some(page) => {
|
||||
(entries_table(base, csrf_token, page))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_table(base: &str, csrf_token: &str, page: &BlocklistEntryPage) -> Markup {
|
||||
if page.items.is_empty() {
|
||||
return empty_state("No domains or patterns are blocked");
|
||||
}
|
||||
let next_after = page.next_after.as_deref().filter(|_| page.has_more);
|
||||
html! {
|
||||
(data_table(
|
||||
&["Value", "Kind", "Subdomains", "Category", "Added", ""],
|
||||
html! {
|
||||
@for entry in &page.items {
|
||||
(entry_row(base, csrf_token, entry))
|
||||
}
|
||||
},
|
||||
))
|
||||
@if let Some(next) = next_after {
|
||||
div class="mt-4" {
|
||||
a href={(base) "/url-domain-bans?after=" (urlencoding::encode(next))}
|
||||
class="text-sm text-brand-primary hover:underline" {
|
||||
"Next page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
let is_pattern = entry.value.contains('*');
|
||||
table_row(html! {
|
||||
(table_cell(false, html! { code class="break-all" { (entry.value) } }))
|
||||
(table_cell(false, html! {
|
||||
@if is_pattern {
|
||||
(badge("Pattern", BadgeVariant::Info))
|
||||
} @else {
|
||||
(badge("Domain", BadgeVariant::Default))
|
||||
}
|
||||
}))
|
||||
(table_cell(true, html! {
|
||||
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
|
||||
}))
|
||||
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, html! {
|
||||
form method="post" action=(action_url) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="domain" value=(entry.value);
|
||||
button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700" {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user