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,
    })
}