//! Route definitions and middleware wiring. //! //! Builds the complete Axum router with: //! - API routes (auth, settings, health) //! - CSRF protection on mutating endpoints //! - Security headers //! - CORS configuration //! - Static file serving with SPA fallback use axum::extract::DefaultBodyLimit; use axum::http::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; use axum::http::Method; use axum::middleware as axum_mw; use axum::routing::{get, post, put}; use axum::Router; use tower_http::cors::CorsLayer; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use crate::app_state::AppState; use crate::config::AppConfig; use crate::handlers; use crate::middleware::csrf; /// Build the complete application router. pub fn build_router(state: AppState, config: &AppConfig) -> Router { // API routes with CSRF protection on mutating endpoints let api_routes = Router::new() // Auth routes (public, but CSRF-protected on POST) .route("/auth/register", post(handlers::auth::register)) .route("/auth/login", post(handlers::auth::login)) .route("/auth/verify", get(handlers::auth::verify_get)) .route("/auth/verify", post(handlers::auth::verify_post)) .route("/auth/logout", post(handlers::auth::logout)) .route("/auth/me", get(handlers::auth::me)) // Settings routes (authenticated) .route("/settings", get(handlers::settings::get_settings)) .route("/settings", put(handlers::settings::update_settings)) // Health check (public) .route("/health", get(handlers::health::health_check)) // Apply CSRF middleware to all API routes .layer(axum_mw::from_fn(csrf::csrf_check)); let api = Router::new().nest("/api/v1", api_routes); // Static file serving with SPA fallback let static_dir = config.static_dir.clone(); let index_file = format!("{}/index.html", static_dir); let spa_fallback = ServeDir::new(&static_dir).not_found_service(ServeFile::new(&index_file)); // Build the full application with state let mut app = Router::new() .merge(api) .fallback_service(spa_fallback) .with_state(state); // Apply global middleware layers app = app .layer(DefaultBodyLimit::max(1024 * 1024)) // 1 MB max request body .layer(TraceLayer::new_for_http()) .layer(build_cors_layer(config)) .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), )) .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("x-frame-options"), HeaderValue::from_static("DENY"), )) .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("referrer-policy"), HeaderValue::from_static("strict-origin-when-cross-origin"), )) .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("x-xss-protection"), HeaderValue::from_static("1; mode=block"), )) .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("content-security-policy"), HeaderValue::from_static( "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; img-src 'self' data:; connect-src 'self'", ), )); // Add HSTS header only in HTTPS mode if config.is_secure() { app = app.layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("strict-transport-security"), HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"), )); } app } /// Build the CORS layer based on configuration. /// /// Allows the configured `APP_URL` as origin, with credentials (cookies). /// Panics at startup if `APP_URL` cannot be parsed — this is intentional /// to prevent running with a misconfigured (and potentially insecure) CORS policy. fn build_cors_layer(config: &AppConfig) -> CorsLayer { let origin = config .app_url .parse::() .unwrap_or_else(|e| { panic!( "FATAL: APP_URL '{}' is not a valid HTTP header value: {}. \ Fix APP_URL in your environment before starting the server.", config.app_url, e ) }); CorsLayer::new() .allow_origin(origin) .allow_methods([Method::GET, Method::POST, Method::PUT]) .allow_headers([ CONTENT_TYPE, ACCEPT, AUTHORIZATION, HeaderName::from_static("x-requested-with"), ]) .allow_credentials(true) .max_age(std::time::Duration::from_secs(3600)) }