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.

225 lines
11 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::{delete, 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))
// Sources routes (authenticated)
.route("/sources", get(handlers::sources::list))
.route("/sources", post(handlers::sources::create))
.route("/sources/{id}", delete(handlers::sources::delete))
.route("/sources/bulk", post(handlers::sources::bulk_import))
.route("/sources/import-csv", post(handlers::sources::import_csv))
.route("/sources/export-csv", get(handlers::sources::export_csv))
// User API key management
.route("/user/api-keys", get(handlers::api_keys::list))
.route("/user/api-keys", post(handlers::api_keys::create))
.route("/user/api-keys/{provider}", delete(handlers::api_keys::delete))
.route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key))
.route("/user/api-keys/export", post(handlers::api_keys::export_keys))
// Generation routes (authenticated) — registered before /syntheses/{id}
// to avoid ambiguity with path parameter matching
.route("/syntheses/generate", post(handlers::generation::trigger_generate))
.route("/syntheses/generate/{job_id}/progress", get(handlers::generation::progress_stream))
// Article history & provenance routes (authenticated)
.route("/article-history", get(handlers::article_history::list_history).delete(handlers::article_history::clear_history))
.route("/syntheses/{id}/provenance", get(handlers::article_history::get_provenance))
// LLM call log routes (authenticated)
.route("/llm-logs/{job_id}", get(handlers::llm_logs::get_logs))
// Syntheses CRUD routes (authenticated)
.route("/syntheses", get(handlers::syntheses::list))
.route("/syntheses/{id}", get(handlers::syntheses::get))
.route("/syntheses/{id}", delete(handlers::syntheses::delete))
// Syntheses email & export routes (authenticated)
.route("/syntheses/{id}/send-email", post(handlers::syntheses::send_email))
.route("/syntheses/{id}/export/markdown", get(handlers::syntheses::export_markdown))
.route("/syntheses/{id}/export/pdf", get(handlers::syntheses::export_pdf))
// Public config (authenticated, non-admin)
.route("/config/providers", get(handlers::config::list_enabled_providers))
// Admin routes
.route("/admin/providers", get(handlers::admin::list_providers))
.route("/admin/providers", post(handlers::admin::create_provider))
.route("/admin/providers/{id}", put(handlers::admin::update_provider))
.route("/admin/providers/{id}", delete(handlers::admin::delete_provider))
.route("/admin/rate-limits", get(handlers::admin::list_rate_limits))
.route("/admin/rate-limits/{provider_name}", put(handlers::admin::update_rate_limit))
.route("/admin/users", get(handlers::admin::list_users))
.route("/admin/users/{id}/role", put(handlers::admin::update_user_role))
// 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:; font-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 a lightweight router for integration tests.
///
/// Skips static file serving (SPA fallback) and TraceLayer to avoid
/// issues with `oneshot()` in test environments.
pub fn build_test_router(state: AppState, config: &AppConfig) -> Router {
let api_routes = Router::new()
.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))
.route("/settings", get(handlers::settings::get_settings))
.route("/settings", put(handlers::settings::update_settings))
.route("/sources", get(handlers::sources::list))
.route("/sources", post(handlers::sources::create))
.route("/sources/{id}", delete(handlers::sources::delete))
.route("/sources/bulk", post(handlers::sources::bulk_import))
.route("/sources/import-csv", post(handlers::sources::import_csv))
.route("/sources/export-csv", get(handlers::sources::export_csv))
.route("/user/api-keys", get(handlers::api_keys::list))
.route("/user/api-keys", post(handlers::api_keys::create))
.route("/user/api-keys/{provider}", delete(handlers::api_keys::delete))
.route("/user/api-keys/{provider}/test", post(handlers::api_keys::test_key))
.route("/user/api-keys/export", post(handlers::api_keys::export_keys))
.route("/syntheses/generate", post(handlers::generation::trigger_generate))
.route("/syntheses/generate/{job_id}/progress", get(handlers::generation::progress_stream))
.route("/article-history", get(handlers::article_history::list_history).delete(handlers::article_history::clear_history))
.route("/syntheses/{id}/provenance", get(handlers::article_history::get_provenance))
.route("/llm-logs/{job_id}", get(handlers::llm_logs::get_logs))
.route("/syntheses", get(handlers::syntheses::list))
.route("/syntheses/{id}", get(handlers::syntheses::get))
.route("/syntheses/{id}", delete(handlers::syntheses::delete))
.route("/syntheses/{id}/send-email", post(handlers::syntheses::send_email))
.route("/syntheses/{id}/export/markdown", get(handlers::syntheses::export_markdown))
.route("/syntheses/{id}/export/pdf", get(handlers::syntheses::export_pdf))
.route("/config/providers", get(handlers::config::list_enabled_providers))
.route("/admin/providers", get(handlers::admin::list_providers))
.route("/admin/providers", post(handlers::admin::create_provider))
.route("/admin/providers/{id}", put(handlers::admin::update_provider))
.route("/admin/providers/{id}", delete(handlers::admin::delete_provider))
.route("/admin/rate-limits", get(handlers::admin::list_rate_limits))
.route("/admin/rate-limits/{provider_name}", put(handlers::admin::update_rate_limit))
.route("/admin/users", get(handlers::admin::list_users))
.route("/admin/users/{id}/role", put(handlers::admin::update_user_role))
.route("/health", get(handlers::health::health_check))
.layer(axum_mw::from_fn(csrf::csrf_check));
let api = Router::new().nest("/api/v1", api_routes);
Router::new()
.merge(api)
.with_state(state)
.layer(DefaultBodyLimit::max(1024 * 1024))
}
/// 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, Method::DELETE])
.allow_headers([
CONTENT_TYPE,
ACCEPT,
AUTHORIZATION,
HeaderName::from_static("x-requested-with"),
])
.allow_credentials(true)
.max_age(std::time::Duration::from_secs(3600))
}