handlers.rs
411 lignes · 13638 octets
//! Five Askama-rendered handlers — dashboard / events / bans / //! manual_ban (GET + POST) / thresholds — plus a few view DTOs. //! //! All routes are mounted under `/admin/fediverse-guard/*`, where the //! standard `admin_required` middleware shipped by `rustwarden-core` //! gates access on every request. use std::sync::Arc; use askama::Template; use axum::extract::{Extension, Form}; use axum::response::{Html, IntoResponse, Redirect}; use chrono::{Duration as ChronoDuration, Utc}; use sea_orm::{ ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, }; use serde::Deserialize; use tower_sessions::Session; use uuid::Uuid; use rustwarden_core::error::AppError; use rustwarden_core::middleware::csrf; use crate::config::{DetectorThreshold, GuardConfig}; use crate::store::entities::event; use crate::store::{BanEntry, GuardStore}; const EVENT_LIST_LIMIT: u64 = 50; const DASHBOARD_EVENT_WINDOW_HOURS: i64 = 24; // ─── DTOs ──────────────────────────────────────────────────────── #[derive(Debug, Clone)] struct EventRowVM { observed_at: String, kind: String, domain: String, actor_uri: String, severity: String, } #[derive(Debug, Clone)] struct BanRowVM { domain: String, actor_uri: String, severity: String, reason: String, triggered_by: String, expires_at: String, } #[derive(Debug, Clone)] struct ThresholdRowVM { name: String, count: u32, window_secs: u64, ttl_secs: u64, } // ─── Templates ─────────────────────────────────────────────────── #[derive(Template)] #[template(path = "guard_dashboard.html")] struct DashboardTemplate { page_title: String, heading: String, subtitle: String, back_link: String, stat_bans_label: String, stat_active_bans: u64, stat_acl_label: String, stat_acl_count: u64, stat_events_label: String, stat_recent_events: u64, stat_events_window: String, navigation_heading: String, nav_events: String, nav_bans: String, nav_manual_ban: String, nav_thresholds: String, } #[derive(Template)] #[template(path = "guard_events.html")] struct EventsTemplate { page_title: String, heading: String, subtitle: String, back_link: String, empty_state: String, col_when: String, col_kind: String, col_domain: String, col_actor: String, col_severity: String, events: Vec<EventRowVM>, } #[derive(Template)] #[template(path = "guard_bans.html")] struct BansTemplate { page_title: String, heading: String, subtitle: String, back_link: String, add_button: String, empty_state: String, col_domain: String, col_actor: String, col_severity: String, col_reason: String, col_triggered_by: String, col_expires: String, bans: Vec<BanRowVM>, } #[derive(Template)] #[template(path = "guard_manual_ban.html")] struct ManualBanTemplate { page_title: String, heading: String, subtitle: String, back_link: String, csrf_token: String, field_domain_label: String, field_domain_help: String, field_severity_label: String, field_reason_label: String, field_reason_placeholder: String, field_ttl_label: String, field_ttl_help: String, submit_button: String, flash_success: bool, flash_success_text: String, flash_error: bool, flash_error_text: String, } #[derive(Template)] #[template(path = "guard_thresholds.html")] struct ThresholdsTemplate { page_title: String, heading: String, subtitle: String, back_link: String, env_note: String, col_detector: String, col_count: String, col_window: String, col_ttl: String, thresholds: Vec<ThresholdRowVM>, } // ─── Helpers ───────────────────────────────────────────────────── fn render<T: Template>(t: T) -> Result<Html<String>, AppError> { let body = t .render() .map_err(|e| AppError::Internal(format!("template render: {e}")))?; Ok(Html(body)) } fn ban_row_vm(b: &BanEntry) -> BanRowVM { BanRowVM { domain: b.domain.clone(), actor_uri: b.actor_uri.clone().unwrap_or_default(), severity: b.severity.clone(), reason: b.reason.clone(), triggered_by: b.triggered_by.clone(), expires_at: b .expires_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "permanent".into()), } } fn event_row_vm(m: &event::Model) -> EventRowVM { EventRowVM { observed_at: m.created_at.to_rfc3339(), kind: m.event_type.clone(), domain: m.domain.clone(), actor_uri: m.actor_uri.clone().unwrap_or_default(), severity: m.severity.clone(), } } fn threshold_row( name: &str, t: DetectorThreshold, ttl: Option<std::time::Duration>, ) -> ThresholdRowVM { ThresholdRowVM { name: name.to_string(), count: t.count, window_secs: t.window.as_secs(), ttl_secs: ttl.map(|d| d.as_secs()).unwrap_or(0), } } // ─── Handlers ──────────────────────────────────────────────────── /// `GET /admin/fediverse-guard` — landing page with summary counters. pub async fn dashboard( Extension(store): Extension<Arc<dyn GuardStore>>, Extension(db): Extension<DatabaseConnection>, ) -> Result<impl IntoResponse, AppError> { let now = Utc::now(); let bans = store.all_active_bans(now).await?; let acl = store.list_acl().await?; let since = now - ChronoDuration::hours(DASHBOARD_EVENT_WINDOW_HOURS); let recent_events: u64 = event::Entity::find() .filter(event::Column::CreatedAt.gte(since)) .count(&db) .await .map_err(AppError::from)?; render(DashboardTemplate { page_title: "Fediverse guard".into(), heading: "Fediverse guard".into(), subtitle: "Real-time abuse detection and ban management.".into(), back_link: "← Admin".into(), stat_bans_label: "Active bans".into(), stat_active_bans: bans.len() as u64, stat_acl_label: "ACL entries".into(), stat_acl_count: acl.len() as u64, stat_events_label: "Events".into(), stat_recent_events: recent_events, stat_events_window: format!("last {DASHBOARD_EVENT_WINDOW_HOURS}h"), navigation_heading: "Sections".into(), nav_events: "Events log".into(), nav_bans: "Active bans".into(), nav_manual_ban: "Manual ban".into(), nav_thresholds: "Detector thresholds".into(), }) } /// `GET /admin/fediverse-guard/events` — most-recent events. pub async fn events( Extension(db): Extension<DatabaseConnection>, ) -> Result<impl IntoResponse, AppError> { let rows = event::Entity::find() .order_by_desc(event::Column::CreatedAt) .limit(EVENT_LIST_LIMIT) .all(&db) .await .map_err(AppError::from)?; let events: Vec<EventRowVM> = rows.iter().map(event_row_vm).collect(); render(EventsTemplate { page_title: "Guard events".into(), heading: "Guard events".into(), subtitle: format!("Last {EVENT_LIST_LIMIT} recorded events."), back_link: "← Dashboard".into(), empty_state: "No events recorded yet.".into(), col_when: "When".into(), col_kind: "Kind".into(), col_domain: "Domain".into(), col_actor: "Actor".into(), col_severity: "Severity".into(), events, }) } /// `GET /admin/fediverse-guard/bans` — every active ban. pub async fn bans( Extension(store): Extension<Arc<dyn GuardStore>>, ) -> Result<impl IntoResponse, AppError> { let now = Utc::now(); let rows = store.all_active_bans(now).await?; let bans: Vec<BanRowVM> = rows.iter().map(ban_row_vm).collect(); render(BansTemplate { page_title: "Active bans".into(), heading: "Active bans".into(), subtitle: "Bans currently in effect, auto and manual.".into(), back_link: "← Dashboard".into(), add_button: "+ Manual ban".into(), empty_state: "No active bans.".into(), col_domain: "Domain".into(), col_actor: "Actor".into(), col_severity: "Severity".into(), col_reason: "Reason".into(), col_triggered_by: "Triggered by".into(), col_expires: "Expires".into(), bans, }) } /// `GET /admin/fediverse-guard/manual_ban` — empty form. pub async fn manual_ban_form(session: Session) -> Result<impl IntoResponse, AppError> { let csrf_token = csrf::get_or_create_csrf_token(&session).await?; render(manual_ban_template(&csrf_token, false, "", false, "")) } /// Form payload for `POST /admin/fediverse-guard/manual_ban`. #[derive(Debug, Deserialize)] pub struct ManualBanForm { /// CSRF token validated against the session before the ban is /// persisted. pub csrf_token: String, /// Lower-cased domain to ban. pub domain: String, /// `"suspend"` / `"silence"` / `"rate_limit"`. pub severity: String, /// Human-readable reason recorded for audit. pub reason: String, /// `0` (or empty) means permanent. #[serde(default)] pub ttl_seconds: Option<u64>, } /// `POST /admin/fediverse-guard/manual_ban` — insert one ban row. pub async fn manual_ban_submit( Extension(store): Extension<Arc<dyn GuardStore>>, session: Session, Form(form): Form<ManualBanForm>, ) -> Result<Redirect, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let domain = form.domain.trim().to_ascii_lowercase(); if domain.is_empty() { return Err(AppError::Validation("domain is required".into())); } if !matches!(form.severity.as_str(), "suspend" | "silence" | "rate_limit") { return Err(AppError::Validation(format!( "unsupported severity `{}` (expected suspend|silence|rate_limit)", form.severity, ))); } let reason = form.reason.trim(); if reason.is_empty() { return Err(AppError::Validation("reason is required".into())); } let now = Utc::now(); let expires_at = match form.ttl_seconds.unwrap_or(0) { 0 => None, n => Some(now + ChronoDuration::seconds(n as i64)), }; let ban = BanEntry { id: Uuid::new_v4(), domain, actor_uri: None, reason: reason.to_string(), severity: form.severity, // Manual provenance — `manual:<admin_uuid>` would be richer // once we extract Claims from the request, but the admin layer // already logs the acting user via `admin_required`. Keeping // the marker generic here avoids a hard dep on `Claims` plumb. triggered_by: "manual:admin".into(), banned_at: now, expires_at, lifted_at: None, lifted_by: None, }; store.insert_ban(&ban).await?; Ok(Redirect::to("/admin/fediverse-guard/bans")) } /// Convenience builder for the manual-ban template with optional flash. fn manual_ban_template( csrf_token: &str, flash_success: bool, flash_success_text: &str, flash_error: bool, flash_error_text: &str, ) -> ManualBanTemplate { ManualBanTemplate { page_title: "Manual ban".into(), heading: "Manual ban".into(), subtitle: "Insert an explicit ban row. Skips the detector chain.".into(), back_link: "← Dashboard".into(), csrf_token: csrf_token.to_string(), field_domain_label: "Domain".into(), field_domain_help: "Lower-cased, no scheme. Example: evil.tld".into(), field_severity_label: "Severity".into(), field_reason_label: "Reason".into(), field_reason_placeholder: "Why is this domain being banned?".into(), field_ttl_label: "TTL (seconds)".into(), field_ttl_help: "Leave 0 for a permanent ban.".into(), submit_button: "Apply ban".into(), flash_success, flash_success_text: flash_success_text.to_string(), flash_error, flash_error_text: flash_error_text.to_string(), } } /// `GET /admin/fediverse-guard/thresholds` — read-only view of the /// currently loaded `GuardConfig` thresholds. pub async fn thresholds( Extension(config): Extension<GuardConfig>, ) -> Result<impl IntoResponse, AppError> { let ttl = config.auto_ban_duration; let rows = vec![ threshold_row("signature_failures", config.signature_failures, ttl), threshold_row("replay_attempts", config.replay_attempts, ttl), threshold_row("fetch_ssrf", config.fetch_ssrf, ttl), threshold_row("inbox_abuse", config.inbox_abuse, ttl), threshold_row("auth_spam", config.auth_spam, ttl), ]; render(ThresholdsTemplate { page_title: "Detector thresholds".into(), heading: "Detector thresholds".into(), subtitle: "Read-only view of the active GuardConfig.".into(), back_link: "← Dashboard".into(), env_note: "Override via FEDIVERSE_GUARD_* env vars; restart required.".into(), col_detector: "Detector".into(), col_count: "Count".into(), col_window: "Window".into(), col_ttl: "Auto-ban TTL".into(), thresholds: rows, }) }
GitRust