admin.rs
2152 lignes · 84810 octets
//! Admin handlers for the Fediverse UI foundations slice (RFC 0021). //! //! Ships two endpoints behind the standard `admin_required` middleware: //! //! - `GET /admin/fediverse/settings` renders a form pre-filled with the //! four runtime toggles persisted in `app_settings`: //! `fediverse_enabled`, `fediverse_trust_mode`, //! `fediverse_interop_scope`, `fediverse_authorized_fetch`. //! - `POST /admin/fediverse/settings` applies the form (CSRF-checked) //! and redirects back to the GET handler with a flash-style toast //! parameter. //! //! All labels are pulled from `fediverse.*` keys in `locales/*.yml`. //! The trust-list screen is delivered in a follow-up patch within the //! same RFC 0021 slice. use std::sync::Arc; use askama::Template; use axum::extract::{Extension, Form, State}; use axum::response::{IntoResponse, Redirect}; use sea_orm::DatabaseConnection; use serde::Deserialize; use tower_sessions::Session; use rustwarden_core::auth::jwt::Claims; use rustwarden_core::error::AppError; use rustwarden_core::i18n::extractor::Locale; use rustwarden_core::i18n::service::I18nService; use rustwarden_core::middleware::csrf; use rustwarden_core::services::app_settings_service::AppSettingsService; use rustwarden_fediverse::services::fediverse_trust_service::{ BlockSeverity, FediverseTrustService, TrustMode, }; use rustwarden_core::models::team; use rustwarden_core::models::user as user_model; use rustwarden_core::services::team_service::TeamRole; use rustwarden_fediverse::models::fediverse_outbound_queue; use rustwarden_fediverse::models::fediverse_pending_invite; use rustwarden_fediverse::models::remote_identity; use rustwarden_fediverse::services::fediverse_invite_service::{ FediverseInviteService, InviteStatus, }; use rustwarden_fediverse::services::fediverse_outbound_service::OutboundStatus; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; use rustwarden_ui::dto::NavigationDTO; use rustwarden_ui::template_helpers::build_base_context_with_claims; use crate::dto::{AdminTabDTO, admin_tabs}; /// Navbar entry consumed by `components/navigation.html` (admin pages). /// /// Mirrors the private struct used in `admin_handler` so this file /// stays standalone — duplication is intentional until a shared /// `AdminLayoutContext` lands (deferred to a generic refactor). #[derive(Debug, Clone)] #[allow(dead_code)] pub struct NavItem { /// Stable identifier; matched against the active page id. pub id: String, /// Visible label (already i18n-translated). pub label: String, /// CSS / heroicon name. pub icon: String, /// Absolute URL the entry points to. pub url: String, /// `true` when this entry corresponds to the current page. pub active: bool, /// `true` when the entry is clickable. pub enabled: bool, /// Badge text (empty string for none). pub badge: String, /// Whether to render the badge. pub has_badge: bool, /// Tooltip shown when `enabled` is false. pub disabled_message: String, /// Whether to render the disabled-tooltip. pub has_disabled_message: bool, } async fn build_fediverse_admin_nav( roles: &[String], i18n_service: &Arc<I18nService>, locale: &Locale, db: &DatabaseConnection, ) -> Vec<NavItem> { let navigation = NavigationDTO::for_user(roles, i18n_service, locale, Some(db)).await; navigation .items .iter() .map(|item| NavItem { id: item.id.clone(), label: item.label.clone(), icon: item.icon.clone(), url: item.url.clone(), active: item.id == "admin", enabled: item.enabled, badge: item.badge.clone(), has_badge: item.has_badge, disabled_message: item.disabled_message.clone(), has_disabled_message: item.has_disabled_message, }) .collect() } /// Form payload for `POST /admin/fediverse/settings`. /// /// Checkboxes are absent from the body when unchecked, hence the /// `Option<String>` pattern + presence test rather than `bool`. #[derive(Debug, Deserialize)] pub struct UpdateFediverseSettingsForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// Present only when the toggle is checked. #[serde(default)] pub fediverse_enabled: Option<String>, /// Selector value: `allowlist` or `open_with_blocklist`. pub fediverse_trust_mode: String, /// Selector value: `rustwarden_only`, `rustwarden_plus_universal_read`, /// or `open`. pub fediverse_interop_scope: String, /// Present only when the toggle is checked. #[serde(default)] pub fediverse_authorized_fetch: Option<String>, } /// Subset of `app_settings` rendered into the settings template. #[derive(Debug, Clone)] pub struct FediverseSettingsCtx { /// `"true"` when ActivityPub federation is active runtime-wide. pub fediverse_enabled: String, /// `allowlist` (default) or `open_with_blocklist`. pub fediverse_trust_mode: String, /// Interop scope selector value. pub fediverse_interop_scope: String, /// `"true"` when authorized-fetch (RFC 0017) is required. pub fediverse_authorized_fetch: String, } /// Askama template for `GET /admin/fediverse/settings`. #[derive(Template)] #[template(path = "admin/settings.html")] #[allow(dead_code)] pub struct FediverseSettingsTemplate { /// Browser tab + page title. pub page_title: String, /// CSRF token rendered as hidden input. pub csrf_token: String, /// Application display name. pub app_name: String, /// Application version (footer). pub app_version: String, /// Application domain (header). pub app_domain: String, /// Short app description (header). pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, /// Username for the navbar avatar. pub username: String, /// Email for the navbar tooltip. pub email: String, /// Two-letter avatar initials. pub initials: String, /// CSS class for avatar background. pub avatar_color: String, /// Comma-joined roles for the navbar tooltip. pub roles_text: String, /// Whether the user has a pending password change banner. pub has_pending_password_change: bool, /// Navigation items for the navbar (built from `NavigationDTO`). pub nav_items: Vec<NavItem>, /// User menu — profile entry label. pub user_menu_profile: String, /// User menu — logout entry label. pub user_menu_logout: String, /// Banner displayed when `?saved=1` is in the URL. pub saved_flash: bool, /// Pre-filled form values. pub settings: FediverseSettingsCtx, /// Heading "Réglages fédération". pub heading: String, /// Subtitle explaining the page. pub subtitle: String, /// Card title "Activation". pub card_activation: String, /// Card title "Confiance". pub card_trust: String, /// Card title "Interopérabilité". pub card_interop: String, /// Card title "Sécurité avancée". pub card_security: String, /// Toggle label. pub enabled_label: String, /// Toggle help text. pub enabled_desc: String, /// trust_mode label. pub trust_mode_label: String, /// trust_mode option: allowlist. pub trust_mode_allowlist: String, /// trust_mode option: open_with_blocklist. pub trust_mode_open: String, /// interop_scope label. pub interop_scope_label: String, /// interop_scope option: rustwarden_only. pub interop_scope_rustwarden_only: String, /// interop_scope option: rustwarden_plus_universal_read. pub interop_scope_rustwarden_plus: String, /// interop_scope option: open. pub interop_scope_open: String, /// authorized_fetch label. pub authorized_fetch_label: String, /// authorized_fetch help text. pub authorized_fetch_desc: String, /// Submit button label. pub save_button: String, /// Toast text shown after a successful save. pub saved_toast: String, /// Breadcrumb segment: home. pub breadcrumb_home: String, /// Breadcrumb segment: admin parent. pub breadcrumb_admin: String, /// Breadcrumb segment: federation hub (parent of every admin tab). pub breadcrumb_federation: String, /// Breadcrumb segment: current page (Federation). pub breadcrumb_self: String, /// Federation admin tab bar entries. pub tabs: Vec<AdminTabDTO>, /// Slug of the currently displayed tab; matches `tabs[i].slug`. pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseSettingsTemplate); /// Allowed values for `fediverse_trust_mode` (RFC 0004). const TRUST_MODES: &[&str] = &["allowlist", "open_with_blocklist"]; /// Allowed values for `fediverse_interop_scope` (master plan §2.1). const INTEROP_SCOPES: &[&str] = &["rustwarden_only", "rustwarden_plus_universal_read", "open"]; /// Render `GET /admin/fediverse/settings`. Pre-fills the form with the /// current `app_settings` rows and shows a one-shot toast when the /// referrer included `?saved=1`. pub async fn get_settings( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, axum::extract::Query(query): axum::extract::Query<SavedQuery>, ) -> Result<impl IntoResponse, AppError> { let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let all_settings = AppSettingsService::get_all_settings(&db).await?; let get_val = |key: &str, default: &str| -> String { all_settings .iter() .find(|s| s.key == key) .map(|s| s.value.clone()) .unwrap_or_else(|| default.to_string()) }; let settings_ctx = FediverseSettingsCtx { fediverse_enabled: get_val("fediverse_enabled", "false"), fediverse_trust_mode: get_val("fediverse_trust_mode", "allowlist"), fediverse_interop_scope: get_val("fediverse_interop_scope", "rustwarden_only"), fediverse_authorized_fetch: get_val("fediverse_authorized_fetch", "false"), }; let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseSettingsTemplate { page_title: t("fediverse.settings.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, saved_flash: query.saved.unwrap_or(0) == 1, settings: settings_ctx, heading: t("fediverse.settings.heading").await, subtitle: t("fediverse.settings.subtitle").await, card_activation: t("fediverse.settings.card_activation").await, card_trust: t("fediverse.settings.card_trust").await, card_interop: t("fediverse.settings.card_interop").await, card_security: t("fediverse.settings.card_security").await, enabled_label: t("fediverse.settings.enabled_label").await, enabled_desc: t("fediverse.settings.enabled_desc").await, trust_mode_label: t("fediverse.settings.trust_mode_label").await, trust_mode_allowlist: t("fediverse.settings.trust_mode_allowlist").await, trust_mode_open: t("fediverse.settings.trust_mode_open").await, interop_scope_label: t("fediverse.settings.interop_scope_label").await, interop_scope_rustwarden_only: t("fediverse.settings.interop_scope_rustwarden_only").await, interop_scope_rustwarden_plus: t("fediverse.settings.interop_scope_rustwarden_plus").await, interop_scope_open: t("fediverse.settings.interop_scope_open").await, authorized_fetch_label: t("fediverse.settings.authorized_fetch_label").await, authorized_fetch_desc: t("fediverse.settings.authorized_fetch_desc").await, save_button: t("fediverse.settings.save_button").await, saved_toast: t("fediverse.settings.saved_toast").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse.settings.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "settings".to_string(), }; Ok(template) } /// Optional `?saved=1` query parameter. #[derive(Debug, Deserialize)] pub struct SavedQuery { /// `1` after a successful POST → triggers the success toast. pub saved: Option<u8>, } /// Apply `POST /admin/fediverse/settings`. Validates CSRF + selector /// values, persists 4 keys via [`AppSettingsService`], and redirects /// back to the GET handler with `?saved=1`. pub async fn update_settings( Extension(claims): Extension<Claims>, session: Session, State(db): State<DatabaseConnection>, Form(form): Form<UpdateFediverseSettingsForm>, ) -> Result<impl IntoResponse, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; if !TRUST_MODES.contains(&form.fediverse_trust_mode.as_str()) { return Err(AppError::Validation(format!( "invalid trust_mode: {}", form.fediverse_trust_mode ))); } if !INTEROP_SCOPES.contains(&form.fediverse_interop_scope.as_str()) { return Err(AppError::Validation(format!( "invalid interop_scope: {}", form.fediverse_interop_scope ))); } let bool_str = |opt: &Option<String>| { if opt.is_some() { "true".to_string() } else { "false".to_string() } }; let updated_by = Some(claims.sub); AppSettingsService::set_setting( &db, "fediverse_enabled".to_string(), bool_str(&form.fediverse_enabled), updated_by, ) .await?; AppSettingsService::set_setting( &db, "fediverse_trust_mode".to_string(), form.fediverse_trust_mode, updated_by, ) .await?; AppSettingsService::set_setting( &db, "fediverse_interop_scope".to_string(), form.fediverse_interop_scope, updated_by, ) .await?; AppSettingsService::set_setting( &db, "fediverse_authorized_fetch".to_string(), bool_str(&form.fediverse_authorized_fetch), updated_by, ) .await?; Ok(Redirect::to("/admin/fediverse/settings?saved=1")) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/trust_list (RFC 0021 Slice A iter. 2) // ───────────────────────────────────────────────────────────────────── /// One row rendered into the trust-list table. Whether it represents a /// trusted or a blocked domain is conveyed by the surrounding template /// state (`is_blocklist`). #[derive(Debug, Clone)] pub struct TrustEntryCtx { /// Normalised domain. pub domain: String, /// Optional admin note (allowlist) or blocking reason (blocklist). pub note_or_reason: String, /// `"true"` when [`Self::note_or_reason`] is non-empty (Askama lacks /// a clean `Option` ergonomic for templates). pub has_note_or_reason: bool, /// Severity (blocklist only): `suspend` / `silence` / `reject_auth`. /// Empty string for trusted entries. pub severity: String, /// `created_at` formatted as `YYYY-MM-DD`. pub created_at: String, } /// Form payload for `POST /admin/fediverse/trust_list/add`. #[derive(Debug, Deserialize)] pub struct AddTrustEntryForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// Domain submitted by the admin (free-form, normalised on insert). pub domain: String, /// Optional note (allowlist) or reason (blocklist). #[serde(default)] pub note_or_reason: String, /// Blocklist severity: `suspend` / `silence` / `reject_auth`. Ignored /// when the active mode is `allowlist`. #[serde(default)] pub severity: String, } /// Form payload for `POST /admin/fediverse/trust_list/delete`. #[derive(Debug, Deserialize)] pub struct DeleteTrustEntryForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// Domain to remove from the active list. pub domain: String, } /// Askama template for `GET /admin/fediverse/trust_list`. The fields /// below carry pre-translated UI strings whose intent is conveyed by /// their names; verbose per-field rustdoc would be noise on a 38-key /// table. #[derive(Template)] #[template(path = "admin/trust_list.html")] #[allow(dead_code, missing_docs)] pub struct FediverseTrustListTemplate { /// Browser tab + page title. pub page_title: String, /// CSRF token for the add + delete forms. pub csrf_token: String, /// App display fields shared with `settings.html`. pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, /// Toast banner shown when `?saved=1`. pub saved_flash: bool, /// `true` when the active mode is `open_with_blocklist` — drives /// table headers and form fields (severity selector vs note). pub is_blocklist: bool, /// Pre-loaded entries. pub entries: Vec<TrustEntryCtx>, /// Whether [`Self::entries`] is empty (Askama lacks `is_empty()` /// in stable expressions across versions). pub has_entries: bool, // i18n pub heading: String, pub subtitle: String, pub mode_indicator: String, pub add_form_legend: String, pub domain_label: String, pub domain_placeholder: String, pub note_label: String, pub reason_label: String, pub severity_label: String, pub severity_suspend: String, pub severity_silence: String, pub severity_reject_auth: String, pub add_button: String, pub table_domain: String, pub table_note_or_reason: String, pub table_severity: String, pub table_created_at: String, pub table_actions: String, pub delete_button: String, pub delete_confirm_title: String, pub delete_confirm_body: String, pub modal_cancel: String, pub modal_confirm: String, pub empty_title: String, pub empty_body: String, pub saved_toast: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseTrustListTemplate); /// Allowed `severity` form values (RFC 0004). const SEVERITIES: &[&str] = &["suspend", "silence", "reject_auth"]; /// Render `GET /admin/fediverse/trust_list`. /// /// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false` so the /// page is invisible while the feature is off. The active list (allow /// vs block) is selected from `app_settings.fediverse_trust_mode`. pub async fn get_trust_list( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, axum::extract::Query(query): axum::extract::Query<SavedQuery>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let mode = trust.trust_mode().await?; let is_blocklist = matches!(mode, TrustMode::OpenWithBlocklist); let entries: Vec<TrustEntryCtx> = if is_blocklist { trust .list_blocked_domains() .await? .into_iter() .map(|m| TrustEntryCtx { domain: m.domain, has_note_or_reason: m.reason.as_deref().is_some_and(|s| !s.is_empty()), note_or_reason: m.reason.unwrap_or_default(), severity: m.severity, created_at: m.created_at.format("%Y-%m-%d").to_string(), }) .collect() } else { trust .list_trusted_domains() .await? .into_iter() .map(|m| TrustEntryCtx { domain: m.domain, has_note_or_reason: m.note.as_deref().is_some_and(|s| !s.is_empty()), note_or_reason: m.note.unwrap_or_default(), severity: String::new(), created_at: m.created_at.format("%Y-%m-%d").to_string(), }) .collect() }; let has_entries = !entries.is_empty(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let mode_indicator = if is_blocklist { t("fediverse.trust.mode_blocklist").await } else { t("fediverse.trust.mode_allowlist").await }; let template = FediverseTrustListTemplate { page_title: t("fediverse.trust.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, saved_flash: query.saved.unwrap_or(0) == 1, is_blocklist, entries, has_entries, heading: t("fediverse.trust.heading").await, subtitle: t("fediverse.trust.subtitle").await, mode_indicator, add_form_legend: t("fediverse.trust.add_legend").await, domain_label: t("fediverse.trust.domain_label").await, domain_placeholder: t("fediverse.trust.domain_placeholder").await, note_label: t("fediverse.trust.note_label").await, reason_label: t("fediverse.trust.reason_label").await, severity_label: t("fediverse.trust.severity_label").await, severity_suspend: t("fediverse.trust.severity_suspend").await, severity_silence: t("fediverse.trust.severity_silence").await, severity_reject_auth: t("fediverse.trust.severity_reject_auth").await, add_button: t("fediverse.trust.add_button").await, table_domain: t("fediverse.trust.table_domain").await, table_note_or_reason: t("fediverse.trust.table_note_or_reason").await, table_severity: t("fediverse.trust.table_severity").await, table_created_at: t("fediverse.trust.table_created_at").await, table_actions: t("fediverse.trust.table_actions").await, delete_button: t("fediverse.trust.delete_button").await, delete_confirm_title: t("fediverse.trust.delete_confirm_title").await, delete_confirm_body: t("fediverse.trust.delete_confirm_body").await, modal_cancel: t("fediverse.trust.modal_cancel").await, modal_confirm: t("fediverse.trust.modal_confirm").await, empty_title: t("fediverse.trust.empty_title").await, empty_body: t("fediverse.trust.empty_body").await, saved_toast: t("fediverse.trust.saved_toast").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse.trust.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "trust_list".to_string(), }; Ok(template.into_response()) } /// Apply `POST /admin/fediverse/trust_list/add`. Adds an entry to the /// allowlist or the blocklist depending on the current `trust_mode`. pub async fn add_trust_entry( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Form(form): Form<AddTrustEntryForm>, ) -> Result<impl IntoResponse, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let domain = form.domain.trim(); if domain.is_empty() { return Err(AppError::Validation("domain must not be empty".into())); } let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); } let mode = trust.trust_mode().await?; let added_by = Some(claims.sub); let note_opt = if form.note_or_reason.trim().is_empty() { None } else { Some(form.note_or_reason.trim()) }; match mode { TrustMode::Allowlist => { trust.add_trusted_domain(domain, note_opt, added_by).await?; } TrustMode::OpenWithBlocklist => { if !SEVERITIES.contains(&form.severity.as_str()) { return Err(AppError::Validation(format!( "invalid severity: {}", form.severity ))); } let severity = BlockSeverity::parse(&form.severity); trust .add_blocked_domain(domain, severity, note_opt, added_by) .await?; // F9 (iter 4 / S4) — auto-kill: blocking a domain must // immediately disable any active `remote_identities` for // that domain. Without this, the admin has to perform two // separate actions for a single intent (« cut this hostile // instance »). The middleware F10 check then rejects all // future protected requests carrying a JWT bound to the // disabled identity. use rustwarden_fediverse::services::fediverse_trust_service::normalize_domain; use sea_orm::sea_query::Expr; let now = chrono::Utc::now(); let normalized = normalize_domain(domain); remote_identity::Entity::update_many() .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) .filter(remote_identity::Column::Domain.eq(normalized)) .filter(remote_identity::Column::DisabledAt.is_null()) .exec(&db) .await?; } } Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) } /// Apply `POST /admin/fediverse/trust_list/delete`. Removes an entry by /// canonical domain (URL-safe form input) from the active list. pub async fn delete_trust_entry( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Form(form): Form<DeleteTrustEntryForm>, ) -> Result<impl IntoResponse, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; // ANSSI FUI-AUTH-1 — audit trail for destructive trust mutation. tracing::info!( admin_id = %claims.sub, domain = %form.domain, action = "delete_trust_entry", "admin destructive fediverse action", ); let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok(axum::http::StatusCode::NOT_FOUND.into_response()); } let mode = trust.trust_mode().await?; match mode { TrustMode::Allowlist => { trust.remove_trusted_domain(&form.domain).await?; } TrustMode::OpenWithBlocklist => { trust.remove_blocked_domain(&form.domain).await?; } } Ok(Redirect::to("/admin/fediverse/trust_list?saved=1").into_response()) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/queue (RFC 0024 Slice C iter. 1) // ───────────────────────────────────────────────────────────────────── /// One row rendered into the outbound-queue table. #[derive(Debug, Clone)] pub struct OutboundQueueRowCtx { /// Activity ID (truncated for display). pub activity_id: String, /// Target inbox URL. pub target_inbox: String, /// Target domain extracted by `FediverseOutboundService::enqueue`. pub target_domain: String, /// `pending` / `delivered` / `failed` / `dead`. pub status: String, /// Number of attempts already performed. pub attempts: i32, /// Maximum attempts before terminal `dead`. pub max_attempts: i32, /// Last delivery error truncated. pub last_error: String, /// `true` when [`Self::last_error`] is non-empty. pub has_last_error: bool, /// `next_retry_at` formatted RFC3339, empty when `None`. pub next_retry_at: String, /// Insertion timestamp formatted `YYYY-MM-DD HH:MM:SS`. pub created_at: String, } /// Askama template for `GET /admin/fediverse/queue`. #[derive(Template)] #[template(path = "admin/queue.html")] #[allow(dead_code, missing_docs)] pub struct FediverseQueueTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, /// Pre-loaded last 100 rows ordered by `created_at desc`. pub rows: Vec<OutboundQueueRowCtx>, /// `true` when [`Self::rows`] is empty. pub has_rows: bool, /// Per-status counts (sums all rows, not just the 100 visible). pub count_pending: u64, pub count_delivered: u64, pub count_failed: u64, pub count_dead: u64, pub heading: String, pub subtitle: String, pub stats_pending: String, pub stats_delivered: String, pub stats_failed: String, pub stats_dead: String, pub table_activity: String, pub table_target: String, pub table_status: String, pub table_attempts: String, pub table_last_error: String, pub table_next_retry: String, pub table_created_at: String, pub empty_title: String, pub empty_body: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseQueueTemplate); /// Truncate a string to `max` chars, appending `…` when cut. fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_string() } else { let head: String = s.chars().take(max.saturating_sub(1)).collect(); format!("{head}…") } } /// Render `GET /admin/fediverse/queue` — operator dashboard for the /// outbound delivery queue (RFC 0011 + RFC 0019). Shows the last 100 /// rows + per-status counts so an admin can spot a backlog at a glance. /// /// Returns `404` when `fediverse_enabled = false` (Plan UI §3 P9). pub async fn get_queue( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let count_pending = fediverse_outbound_queue::Entity::find() .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str())) .count(&db) .await .unwrap_or(0); let count_delivered = fediverse_outbound_queue::Entity::find() .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Delivered.as_str())) .count(&db) .await .unwrap_or(0); let count_failed = fediverse_outbound_queue::Entity::find() .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Failed.as_str())) .count(&db) .await .unwrap_or(0); let count_dead = fediverse_outbound_queue::Entity::find() .filter(fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Dead.as_str())) .count(&db) .await .unwrap_or(0); let rows_raw = fediverse_outbound_queue::Entity::find() .order_by_desc(fediverse_outbound_queue::Column::CreatedAt) .limit(100) .all(&db) .await?; let rows: Vec<OutboundQueueRowCtx> = rows_raw .into_iter() .map(|m| OutboundQueueRowCtx { activity_id: truncate(&m.activity_id, 60), target_inbox: truncate(&m.target_inbox, 60), target_domain: m.target_domain, status: m.status, attempts: m.attempts, max_attempts: m.max_attempts, has_last_error: m.last_error.as_deref().is_some_and(|s| !s.is_empty()), last_error: m.last_error.map(|e| truncate(&e, 80)).unwrap_or_default(), next_retry_at: m.next_retry_at.map(|t| t.to_rfc3339()).unwrap_or_default(), created_at: m.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), }) .collect(); let has_rows = !rows.is_empty(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseQueueTemplate { page_title: t("fediverse.queue.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, rows, has_rows, count_pending, count_delivered, count_failed, count_dead, heading: t("fediverse.queue.heading").await, subtitle: t("fediverse.queue.subtitle").await, stats_pending: t("fediverse.queue.stats_pending").await, stats_delivered: t("fediverse.queue.stats_delivered").await, stats_failed: t("fediverse.queue.stats_failed").await, stats_dead: t("fediverse.queue.stats_dead").await, table_activity: t("fediverse.queue.table_activity").await, table_target: t("fediverse.queue.table_target").await, table_status: t("fediverse.queue.table_status").await, table_attempts: t("fediverse.queue.table_attempts").await, table_last_error: t("fediverse.queue.table_last_error").await, table_next_retry: t("fediverse.queue.table_next_retry").await, table_created_at: t("fediverse.queue.table_created_at").await, empty_title: t("fediverse.queue.empty_title").await, empty_body: t("fediverse.queue.empty_body").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse.queue.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "queue".to_string(), }; Ok(template.into_response()) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/sessions_remote (RFC 0024 Slice C iter. 2) // ───────────────────────────────────────────────────────────────────── /// One row rendered into the sessions-remote table. #[derive(Debug, Clone)] pub struct RemoteSessionRowCtx { /// PK of the remote_identities row. pub id: String, /// Canonical AP id (`https://b.tld/users/alice`), truncated. pub ap_id: String, /// `@user@domain` recomposed for human display. pub handle: String, /// Display name advertised by the actor (or empty). pub display_name: String, /// Whether [`Self::display_name`] is non-empty. pub has_display_name: bool, /// Domain of the federated instance. pub domain: String, /// `last_fetched_at` formatted `YYYY-MM-DD HH:MM`. pub last_seen: String, } /// Form payload for `POST /admin/fediverse/sessions_remote/revoke`. #[derive(Debug, Deserialize)] pub struct RevokeRemoteForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// PK of the remote_identities row to disable. pub id: String, } /// Form payload for `POST /admin/fediverse/sessions_remote/kill_domain`. #[derive(Debug, Deserialize)] pub struct KillDomainForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// Domain whose remote_identities rows should be disabled in bulk. pub domain: String, } /// Askama template for `GET /admin/fediverse/sessions_remote`. #[derive(Template)] #[template(path = "admin/sessions_remote.html")] #[allow(dead_code, missing_docs)] pub struct FediverseSessionsRemoteTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, pub saved_flash: bool, pub rows: Vec<RemoteSessionRowCtx>, pub has_rows: bool, /// Distinct domains present in [`Self::rows`] for the kill-switch /// `<select>`. pub distinct_domains: Vec<String>, pub heading: String, pub subtitle: String, pub kill_switch_legend: String, pub kill_switch_label: String, pub kill_switch_button: String, pub kill_switch_confirm_title: String, pub kill_switch_confirm_body: String, pub table_handle: String, pub table_display_name: String, pub table_domain: String, pub table_last_seen: String, pub table_actions: String, pub revoke_button: String, pub revoke_confirm_title: String, pub revoke_confirm_body: String, pub modal_cancel: String, pub modal_confirm: String, pub empty_title: String, pub empty_body: String, pub saved_toast: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseSessionsRemoteTemplate); /// Render `GET /admin/fediverse/sessions_remote` — list active federated /// identities (rows with `disabled_at IS NULL`). pub async fn get_sessions_remote( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, axum::extract::Query(query): axum::extract::Query<SavedQuery>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let active_rows = remote_identity::Entity::find() .filter(remote_identity::Column::DisabledAt.is_null()) .order_by_desc(remote_identity::Column::LastFetchedAt) .limit(200) .all(&db) .await?; let rows: Vec<RemoteSessionRowCtx> = active_rows .iter() .map(|m| RemoteSessionRowCtx { id: m.id.to_string(), ap_id: truncate(&m.ap_id, 60), handle: format!("@{}@{}", m.username, m.domain), has_display_name: m.display_name.as_deref().is_some_and(|s| !s.is_empty()), display_name: m.display_name.clone().unwrap_or_default(), domain: m.domain.clone(), last_seen: m.last_fetched_at.format("%Y-%m-%d %H:%M").to_string(), }) .collect(); let has_rows = !rows.is_empty(); let mut distinct_domains: Vec<String> = active_rows .iter() .map(|m| m.domain.clone()) .collect::<std::collections::BTreeSet<_>>() .into_iter() .collect(); distinct_domains.sort(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseSessionsRemoteTemplate { page_title: t("fediverse.sessions_remote.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, saved_flash: query.saved.unwrap_or(0) == 1, rows, has_rows, distinct_domains, heading: t("fediverse.sessions_remote.heading").await, subtitle: t("fediverse.sessions_remote.subtitle").await, kill_switch_legend: t("fediverse.sessions_remote.kill_switch_legend").await, kill_switch_label: t("fediverse.sessions_remote.kill_switch_label").await, kill_switch_button: t("fediverse.sessions_remote.kill_switch_button").await, kill_switch_confirm_title: t("fediverse.sessions_remote.kill_switch_confirm_title").await, kill_switch_confirm_body: t("fediverse.sessions_remote.kill_switch_confirm_body").await, table_handle: t("fediverse.sessions_remote.table_handle").await, table_display_name: t("fediverse.sessions_remote.table_display_name").await, table_domain: t("fediverse.sessions_remote.table_domain").await, table_last_seen: t("fediverse.sessions_remote.table_last_seen").await, table_actions: t("fediverse.sessions_remote.table_actions").await, revoke_button: t("fediverse.sessions_remote.revoke_button").await, revoke_confirm_title: t("fediverse.sessions_remote.revoke_confirm_title").await, revoke_confirm_body: t("fediverse.sessions_remote.revoke_confirm_body").await, modal_cancel: t("fediverse.sessions_remote.modal_cancel").await, modal_confirm: t("fediverse.sessions_remote.modal_confirm").await, empty_title: t("fediverse.sessions_remote.empty_title").await, empty_body: t("fediverse.sessions_remote.empty_body").await, saved_toast: t("fediverse.sessions_remote.saved_toast").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse.sessions_remote.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "sessions_remote".to_string(), }; Ok(template.into_response()) } /// `POST /admin/fediverse/sessions_remote/revoke` — set `disabled_at` /// on a single remote_identities row. pub async fn revoke_remote_session( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Form(form): Form<RevokeRemoteForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; // ANSSI FUI-AUTH-1 — audit trail for remote session revocation. tracing::info!( admin_id = %claims.sub, remote_identity_id = %form.id, action = "revoke_remote_session", "admin destructive fediverse action", ); let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let id = uuid::Uuid::parse_str(&form.id) .map_err(|_| AppError::Validation("invalid remote identity id".into()))?; let now = chrono::Utc::now(); use sea_orm::sea_query::Expr; remote_identity::Entity::update_many() .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) .filter(remote_identity::Column::Id.eq(id)) .exec(&db) .await?; Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) } /// `POST /admin/fediverse/sessions_remote/kill_domain` — bulk disable /// every remote_identities row from a given domain. Used as a panic /// button when an entire federated instance is compromised. pub async fn kill_domain( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, Form(form): Form<KillDomainForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; // ANSSI FUI-AUTH-1 — audit trail for whole-domain disablement. tracing::info!( admin_id = %claims.sub, domain = %form.domain, action = "kill_domain", "admin destructive fediverse action", ); let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let domain = form.domain.trim().to_ascii_lowercase(); if domain.is_empty() { return Err(AppError::Validation("domain must not be empty".into())); } let now = chrono::Utc::now(); use sea_orm::sea_query::Expr; remote_identity::Entity::update_many() .col_expr(remote_identity::Column::DisabledAt, Expr::value(now)) .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now)) .filter(remote_identity::Column::Domain.eq(domain)) .filter(remote_identity::Column::DisabledAt.is_null()) .exec(&db) .await?; Ok(Redirect::to("/admin/fediverse/sessions_remote?saved=1").into_response()) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/nodeinfo (RFC 0024 Slice C iter. 3) // ───────────────────────────────────────────────────────────────────── /// Askama template for `GET /admin/fediverse/nodeinfo`. #[derive(Template)] #[template(path = "admin/nodeinfo.html")] #[allow(dead_code, missing_docs)] pub struct FediverseNodeInfoTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, // Software pub software_name: String, pub software_version: String, pub fediverse_domain: String, // Federation runtime config pub fediverse_enabled_label: String, pub trust_mode: String, pub interop_scope: String, pub authorized_fetch: bool, // User counts pub users_total: u64, pub users_active_month: u64, pub users_active_halfyear: u64, pub remote_identities_active: u64, pub remote_distinct_domains: u64, // i18n pub heading: String, pub subtitle: String, pub card_software: String, pub card_runtime: String, pub card_users: String, pub card_remotes: String, pub label_name: String, pub label_version: String, pub label_domain: String, pub label_enabled: String, pub label_trust_mode: String, pub label_interop_scope: String, pub label_authorized_fetch: String, pub label_users_total: String, pub label_users_active_month: String, pub label_users_active_halfyear: String, pub label_remote_identities_active: String, pub label_remote_distinct_domains: String, pub yes_label: String, pub no_label: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseNodeInfoTemplate); /// Render `GET /admin/fediverse/nodeinfo` — humanised stats panel. /// /// Returns `404` (Plan UI §3 P9) when `fediverse_enabled = false`. pub async fn get_nodeinfo( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; // Federation runtime config (read straight from app_settings). let trust_mode = match trust.trust_mode().await? { TrustMode::Allowlist => "allowlist".to_string(), TrustMode::OpenWithBlocklist => "open_with_blocklist".to_string(), }; let interop_scope = AppSettingsService::get_setting(&db, "fediverse_interop_scope") .await? .map(|s| s.value) .unwrap_or_else(|| "rustwarden_only".to_string()); let authorized_fetch = AppSettingsService::get_bool(&db, "fediverse_authorized_fetch", false).await?; // User counts (local users). let users_total = user_model::Entity::find().count(&db).await.unwrap_or(0); let now = chrono::Utc::now(); let users_active_month = user_model::Entity::find() .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(30))) .count(&db) .await .unwrap_or(0); let users_active_halfyear = user_model::Entity::find() .filter(user_model::Column::LastLoginAt.gte(now - chrono::Duration::days(180))) .count(&db) .await .unwrap_or(0); // Remote identities (active = disabled_at IS NULL). let remote_identities_active = remote_identity::Entity::find() .filter(remote_identity::Column::DisabledAt.is_null()) .count(&db) .await .unwrap_or(0); let remote_rows = remote_identity::Entity::find() .filter(remote_identity::Column::DisabledAt.is_null()) .all(&db) .await .unwrap_or_default(); let remote_distinct_domains = remote_rows .iter() .map(|m| m.domain.clone()) .collect::<std::collections::BTreeSet<_>>() .len() as u64; let fediverse_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_else(|_| "—".to_string()); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseNodeInfoTemplate { page_title: t("fediverse_nodeinfo.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, software_name: "rustwarden".to_string(), software_version: env!("CARGO_PKG_VERSION").to_string(), fediverse_domain, fediverse_enabled_label: t("common.yes").await, trust_mode, interop_scope, authorized_fetch, users_total, users_active_month, users_active_halfyear, remote_identities_active, remote_distinct_domains, heading: t("fediverse_nodeinfo.heading").await, subtitle: t("fediverse_nodeinfo.subtitle").await, card_software: t("fediverse_nodeinfo.card_software").await, card_runtime: t("fediverse_nodeinfo.card_runtime").await, card_users: t("fediverse_nodeinfo.card_users").await, card_remotes: t("fediverse_nodeinfo.card_remotes").await, label_name: t("fediverse_nodeinfo.label_name").await, label_version: t("fediverse_nodeinfo.label_version").await, label_domain: t("fediverse_nodeinfo.label_domain").await, label_enabled: t("fediverse_nodeinfo.label_enabled").await, label_trust_mode: t("fediverse_nodeinfo.label_trust_mode").await, label_interop_scope: t("fediverse_nodeinfo.label_interop_scope").await, label_authorized_fetch: t("fediverse_nodeinfo.label_authorized_fetch").await, label_users_total: t("fediverse_nodeinfo.label_users_total").await, label_users_active_month: t("fediverse_nodeinfo.label_users_active_month").await, label_users_active_halfyear: t("fediverse_nodeinfo.label_users_active_halfyear").await, label_remote_identities_active: t("fediverse_nodeinfo.label_remote_identities_active") .await, label_remote_distinct_domains: t("fediverse_nodeinfo.label_remote_distinct_domains").await, yes_label: t("common.yes").await, no_label: t("common.no").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse_nodeinfo.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "nodeinfo".to_string(), }; Ok(template.into_response()) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/invites/{team_id} (RFC 0024 Slice C iter. 4) // ───────────────────────────────────────────────────────────────────── /// One row rendered into the team invites table. #[derive(Debug, Clone)] pub struct TeamInviteRowCtx { /// PK of the invite row. pub id: String, /// `@user@domain` handle. pub invited_handle: String, /// Wire form: `owner` / `admin` / `member`. pub role: String, /// Wire form: `pending` / `accepted` / `rejected` / `revoked` / `expired`. pub status: String, /// `created_at` formatted `YYYY-MM-DD HH:MM`. pub created_at: String, } /// Form payload for `POST /admin/fediverse/invites/{team_id}/add`. #[derive(Debug, Deserialize)] pub struct CreateTeamInviteForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// `@user@domain` handle to invite. pub handle: String, /// `owner` / `admin` / `member`. pub role: String, } /// Form payload for `POST /admin/fediverse/invites/{team_id}/revoke`. #[derive(Debug, Deserialize)] pub struct RevokeInviteForm { /// CSRF token from the hidden form field. pub csrf_token: String, /// PK of the invite row to revoke. pub id: String, } /// Askama template for `GET /admin/fediverse/invites/{team_id}`. #[derive(Template)] #[template(path = "admin/team_invites.html")] #[allow(dead_code, missing_docs)] pub struct FediverseTeamInvitesTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, pub saved_flash: bool, /// Team name for the page heading. pub team_name: String, /// Team UUID (for form actions). pub team_id: String, pub rows: Vec<TeamInviteRowCtx>, pub has_rows: bool, pub heading: String, pub subtitle: String, pub add_form_legend: String, pub handle_label: String, pub handle_placeholder: String, pub role_label: String, pub role_member: String, pub role_admin: String, pub role_owner: String, pub add_button: String, pub table_handle: String, pub table_role: String, pub table_status: String, pub table_created_at: String, pub table_actions: String, pub revoke_button: String, pub revoke_confirm_title: String, pub revoke_confirm_body: String, pub modal_cancel: String, pub modal_confirm: String, pub empty_title: String, pub empty_body: String, pub saved_toast: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseTeamInvitesTemplate); /// Default TTL for team invitations (RFC 0009 §6). const INVITE_TTL: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60); /// `GET /admin/fediverse/invites/{team_id}` — list pending invites. #[allow(clippy::too_many_arguments)] pub async fn get_team_invites( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, axum::extract::Query(query): axum::extract::Query<SavedQuery>, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } // Verify team exists; otherwise 404. let Some(team_row) = team::Entity::find_by_id(team_id).one(&db).await? else { return Ok((axum::http::StatusCode::NOT_FOUND, "Team not found").into_response()); }; let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; // Show all statuses for this team (not just pending) so the admin // sees the historical decisions. Order by created_at desc. let invite_rows = fediverse_pending_invite::Entity::find() .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) .order_by_desc(fediverse_pending_invite::Column::CreatedAt) .limit(100) .all(&db) .await?; let rows: Vec<TeamInviteRowCtx> = invite_rows .into_iter() .map(|m| TeamInviteRowCtx { id: m.id.to_string(), invited_handle: m.invited_handle, role: m.role, status: m.status, created_at: m.created_at.format("%Y-%m-%d %H:%M").to_string(), }) .collect(); let has_rows = !rows.is_empty(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseTeamInvitesTemplate { page_title: t("fediverse_invites.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, saved_flash: query.saved.unwrap_or(0) == 1, team_name: team_row.name, team_id: team_id.to_string(), rows, has_rows, heading: t("fediverse_invites.heading").await, subtitle: t("fediverse_invites.subtitle").await, add_form_legend: t("fediverse_invites.add_legend").await, handle_label: t("fediverse_invites.handle_label").await, handle_placeholder: t("fediverse_invites.handle_placeholder").await, role_label: t("fediverse_invites.role_label").await, role_member: t("fediverse_invites.role_member").await, role_admin: t("fediverse_invites.role_admin").await, role_owner: t("fediverse_invites.role_owner").await, add_button: t("fediverse_invites.add_button").await, table_handle: t("fediverse_invites.table_handle").await, table_role: t("fediverse_invites.table_role").await, table_status: t("fediverse_invites.table_status").await, table_created_at: t("fediverse_invites.table_created_at").await, table_actions: t("fediverse_invites.table_actions").await, revoke_button: t("fediverse_invites.revoke_button").await, revoke_confirm_title: t("fediverse_invites.revoke_confirm_title").await, revoke_confirm_body: t("fediverse_invites.revoke_confirm_body").await, modal_cancel: t("fediverse_invites.modal_cancel").await, modal_confirm: t("fediverse_invites.modal_confirm").await, empty_title: t("fediverse_invites.empty_title").await, empty_body: t("fediverse_invites.empty_body").await, saved_toast: t("fediverse_invites.saved_toast").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse_invites.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "invites".to_string(), }; Ok(template.into_response()) } /// `POST /admin/fediverse/invites/{team_id}/add` — create a pending invite. pub async fn create_team_invite( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, Form(form): Form<CreateTeamInviteForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let handle = form.handle.trim(); if handle.is_empty() || !handle.contains('@') { return Err(AppError::Validation( "handle must be of form @user@domain".into(), )); } let role = TeamRole::parse(&form.role) .ok_or_else(|| AppError::Validation(format!("invalid role: {}", form.role)))?; FediverseInviteService::invite_remote(&db, team_id, handle, role, claims.sub, INVITE_TTL) .await?; Ok(Redirect::to(&format!("/admin/fediverse/invites/{team_id}?saved=1")).into_response()) } /// `POST /admin/fediverse/invites/{team_id}/revoke` — revoke an invite. pub async fn revoke_team_invite( Extension(claims): Extension<Claims>, State(db): State<DatabaseConnection>, session: Session, axum::extract::Path(team_id): axum::extract::Path<uuid::Uuid>, Form(form): Form<RevokeInviteForm>, ) -> Result<axum::response::Response, AppError> { csrf::validate_csrf_token(&session, &form.csrf_token).await?; // ANSSI FUI-AUTH-1 — audit trail for team invite revocation. tracing::info!( admin_id = %claims.sub, team_id = %team_id, invite_id = %form.id, action = "revoke_team_invite", "admin destructive fediverse action", ); let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let invite_id = uuid::Uuid::parse_str(&form.id) .map_err(|_| AppError::Validation("invalid invite id".into()))?; // Touch unused-import guard for InviteStatus (kept exported). let _ = InviteStatus::Pending; FediverseInviteService::revoke(&db, invite_id).await?; Ok(Redirect::to(&format!("/admin/fediverse/invites/{team_id}?saved=1")).into_response()) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse — federation admin hub // ───────────────────────────────────────────────────────────────────── /// One card rendered into the federation admin hub. #[derive(Debug, Clone)] pub struct HubCardCtx { /// Translated card title. pub title: String, /// Translated short description. pub desc: String, /// Absolute URL the card links to. pub href: String, /// Unicode glyph rendered as a leading icon. pub icon: String, } /// Askama template for `GET /admin/fediverse` (the federation hub). #[derive(Template)] #[template(path = "admin/hub.html")] #[allow(dead_code, missing_docs)] pub struct FediverseHubTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, pub heading: String, pub subtitle: String, pub cards: Vec<HubCardCtx>, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_self: String, } rustwarden_core::impl_template_response!(FediverseHubTemplate); /// Render `GET /admin/fediverse` — entry point for every admin /// sub-section. Lists the six federation cards (settings, trust list, /// queue, remote sessions, nodeinfo, invites) so the admin can pick /// where to land instead of memorising URLs. /// /// Unlike the rest of `/admin/fediverse/*`, the hub is reachable even /// when `fediverse_enabled = false` so the admin can navigate to the /// settings page and turn the feature on. pub async fn get_hub( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, ) -> Result<impl IntoResponse, AppError> { let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let card_specs: &[(&str, &str, &str, &str)] = &[ ( "settings", "/admin/fediverse/settings", "fediverse.admin.hub.card.settings.title", "fediverse.admin.hub.card.settings.desc", ), ( "trust_list", "/admin/fediverse/trust_list", "fediverse.admin.hub.card.trust_list.title", "fediverse.admin.hub.card.trust_list.desc", ), ( "queue", "/admin/fediverse/queue", "fediverse.admin.hub.card.queue.title", "fediverse.admin.hub.card.queue.desc", ), ( "sessions_remote", "/admin/fediverse/sessions_remote", "fediverse.admin.hub.card.sessions_remote.title", "fediverse.admin.hub.card.sessions_remote.desc", ), ( "nodeinfo", "/admin/fediverse/nodeinfo", "fediverse.admin.hub.card.nodeinfo.title", "fediverse.admin.hub.card.nodeinfo.desc", ), ( "invites", "/admin/fediverse/teams", "fediverse.admin.hub.card.invites.title", "fediverse.admin.hub.card.invites.desc", ), ]; let icons: &[&str] = &["⚙️", "🛡️", "📤", "🌐", "📊", "✉️"]; let mut cards = Vec::with_capacity(card_specs.len()); for ((_, href, tk, dk), icon) in card_specs.iter().zip(icons.iter()) { cards.push(HubCardCtx { title: t(tk).await, desc: t(dk).await, href: (*href).to_string(), icon: (*icon).to_string(), }); } let template = FediverseHubTemplate { page_title: t("fediverse.admin.hub.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, heading: t("fediverse.admin.hub.title").await, subtitle: t("fediverse.admin.hub.subtitle").await, cards, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_self: t("fediverse.admin.hub.title").await, }; Ok(template) } // ───────────────────────────────────────────────────────────────────── // /admin/fediverse/teams — picker for the per-team invites screen // ───────────────────────────────────────────────────────────────────── /// One row rendered into the team picker for invites. #[derive(Debug, Clone)] pub struct TeamRowCtx { /// Team UUID used in the per-team invites URL. pub id: String, /// Team display name. pub name: String, /// Team URL slug (rendered for disambiguation). pub slug: String, } /// Askama template for `GET /admin/fediverse/teams`. #[derive(Template)] #[template(path = "admin/teams_for_invites.html")] #[allow(dead_code, missing_docs)] pub struct FediverseTeamsForInvitesTemplate { pub page_title: String, pub csrf_token: String, pub app_name: String, pub app_version: String, pub app_domain: String, pub app_description: String, /// Audience-measurement opt-out. Empty renders nothing. pub analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext, pub username: String, pub email: String, pub initials: String, pub avatar_color: String, pub roles_text: String, pub has_pending_password_change: bool, pub nav_items: Vec<NavItem>, pub user_menu_profile: String, pub user_menu_logout: String, pub heading: String, pub subtitle: String, pub teams: Vec<TeamRowCtx>, pub has_teams: bool, pub empty_title: String, pub empty_body: String, pub breadcrumb_home: String, pub breadcrumb_admin: String, pub breadcrumb_federation: String, pub breadcrumb_self: String, pub tabs: Vec<AdminTabDTO>, pub active_tab: String, } rustwarden_core::impl_template_response!(FediverseTeamsForInvitesTemplate); /// Render `GET /admin/fediverse/teams` — list every team so the admin /// can pick one before reaching `/admin/fediverse/invites/{team_id}`. /// Lives at the federation hub level because no global "all invites" /// view exists today. pub async fn get_teams_for_invites( Extension(claims): Extension<Claims>, Extension(i18n_service): Extension<Arc<I18nService>>, Extension(locale): Extension<Locale>, analytics: rustwarden_core::config::analytics::AnalyticsContext, State(db): State<DatabaseConnection>, session: Session, ) -> Result<axum::response::Response, AppError> { let trust = FediverseTrustService::new(db.clone()); if !trust.is_enabled().await? { return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response()); } let locale_str = locale.as_str(); let base_context = build_base_context_with_claims(&session, &db, &claims).await?; let csrf_token = csrf::get_or_create_csrf_token(&session).await?; let team_rows = team::Entity::find() .order_by_asc(team::Column::Name) .all(&db) .await?; let teams: Vec<TeamRowCtx> = team_rows .into_iter() .map(|t| TeamRowCtx { id: t.id.to_string(), name: t.name, slug: t.slug, }) .collect(); let has_teams = !teams.is_empty(); let t = |k: &str| { let i18n = i18n_service.clone(); let key = k.to_string(); let loc = locale_str.to_string(); async move { i18n.translate(&key, &loc).await } }; let template = FediverseTeamsForInvitesTemplate { page_title: t("fediverse.admin.teams_for_invites.title").await, csrf_token, app_name: base_context.app_name, app_version: base_context.app_version, app_domain: base_context.app_domain, app_description: base_context.app_description, analytics_opt_out: rustwarden_ui::analytics::AnalyticsOptOutContext::build( &analytics, &i18n_service, locale.as_str(), ) .await, username: base_context.username, email: base_context.email, initials: base_context.initials, avatar_color: base_context.avatar_color, roles_text: base_context.roles.join(", "), has_pending_password_change: false, nav_items: build_fediverse_admin_nav(&base_context.roles, &i18n_service, &locale, &db) .await, user_menu_profile: t("user_menu.profile").await, user_menu_logout: t("user_menu.logout").await, heading: t("fediverse.admin.teams_for_invites.heading").await, subtitle: t("fediverse.admin.teams_for_invites.subtitle").await, teams, has_teams, empty_title: t("fediverse.admin.teams_for_invites.empty_title").await, empty_body: t("fediverse.admin.teams_for_invites.empty_body").await, breadcrumb_home: t("breadcrumb.home").await, breadcrumb_admin: t("breadcrumb.admin").await, breadcrumb_federation: t("fediverse.admin.hub.title").await, breadcrumb_self: t("fediverse.admin.teams_for_invites.breadcrumb").await, tabs: admin_tabs(&i18n_service, locale_str).await, active_tab: "invites".to_string(), }; Ok(template.into_response()) } // ─── ANSSI FUI-TEST-1 — scaffold ──────────────────────────────────── // // Initial coverage focused on the pure-Rust helpers exercised by the // admin handlers (truncation, allow-listed config values). The full // integration matrix (CSRF rejection × auth absence × happy path for // the 8 POST handlers) is a follow-up that requires a // `tower-sessions::MemoryStore` + `SeaORM MockDatabase` harness and // is tracked in docs/ANSSI_FEDIVERSE_COMPLIANCE.md → FUI-TEST-1. #[cfg(test)] mod tests { use super::*; #[test] fn truncate_returns_original_when_shorter_than_max() { assert_eq!(truncate("hello", 10), "hello"); } #[test] fn truncate_returns_original_when_equal_to_max() { assert_eq!(truncate("hello", 5), "hello"); } #[test] fn truncate_appends_ellipsis_when_cut() { let out = truncate("hello world", 6); assert!(out.ends_with('…'), "expected ellipsis suffix, got {out:?}"); assert_eq!(out.chars().count(), 6); } #[test] fn truncate_handles_multibyte_chars_without_panic() { // Each emoji is several bytes — `truncate` must count chars, not bytes. let out = truncate("🚀🚀🚀🚀🚀", 3); assert_eq!(out.chars().count(), 3); assert!(out.ends_with('…')); } #[test] fn trust_modes_contains_canonical_values() { assert!(TRUST_MODES.contains(&"allowlist")); assert!(TRUST_MODES.contains(&"open_with_blocklist")); } #[test] fn severities_covers_three_documented_levels() { assert!(SEVERITIES.contains(&"suspend")); assert!(SEVERITIES.contains(&"silence")); assert!(SEVERITIES.contains(&"reject_auth")); assert_eq!(SEVERITIES.len(), 3); } #[test] fn invite_ttl_default_is_seven_days() { assert_eq!(INVITE_TTL.as_secs(), 7 * 24 * 60 * 60); } }
GitRust