You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

128 lines
4.8 KiB
Rust

//! 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::<HeaderValue>()
.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))
}