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