use std::sync::Arc; use axum::{ Router, extract::DefaultBodyLimit, http::{HeaderValue, Method, header}, middleware, routing::{delete, get, patch, post, put}, }; use tower::ServiceBuilder; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; use tower_http::{ cors::CorsLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer, }; use crate::{auth, routes, state::AppState}; /// Create the router with optional TLS configuration for HSTS headers pub fn create_router( state: AppState, rate_limits: &pinakes_core::config::RateLimitConfig, ) -> Router { create_router_with_tls(state, rate_limits, None) } /// Build a governor rate limiter from per-second and burst-size values. /// Panics if the config is invalid (callers must validate before use). fn build_governor( per_second: u64, burst_size: u32, ) -> Arc< tower_governor::governor::GovernorConfig< tower_governor::key_extractor::PeerIpKeyExtractor, governor::middleware::NoOpMiddleware, >, > { Arc::new( GovernorConfigBuilder::default() .per_second(per_second) .burst_size(burst_size) .finish() .expect("rate limit config was validated at startup"), ) } /// Create the router with TLS configuration for security headers pub fn create_router_with_tls( state: AppState, rate_limits: &pinakes_core::config::RateLimitConfig, tls_config: Option<&pinakes_core::config::TlsConfig>, ) -> Router { let global_governor = build_governor( rate_limits.global_per_second, rate_limits.global_burst_size, ); let login_governor = build_governor(rate_limits.login_per_second, rate_limits.login_burst_size); let search_governor = build_governor( rate_limits.search_per_second, rate_limits.search_burst_size, ); let stream_governor = build_governor( rate_limits.stream_per_second, rate_limits.stream_burst_size, ); let share_governor = build_governor(rate_limits.share_per_second, rate_limits.share_burst_size); // Login route with strict rate limiting let login_route = Router::new() .route("/auth/login", post(routes::auth::login)) .layer(GovernorLayer::new(login_governor)); // Share routes with dedicated rate limiting let share_routes = Router::new() .route("/s/{token}", get(routes::social::access_shared_media)) .route("/shared/{token}", get(routes::shares::access_shared)) .layer(GovernorLayer::new(share_governor)); // Public routes (no auth required) let public_routes = Router::new() // Kubernetes-style health probes (no auth required for orchestration) .route("/health/live", get(routes::health::liveness)) .route("/health/ready", get(routes::health::readiness)); // Search routes with enhanced rate limiting (10 req/min) let search_routes = Router::new() .route("/search", get(routes::search::search)) .route("/search", post(routes::search::search_post)) .layer(GovernorLayer::new(search_governor)); // Streaming routes with enhanced rate limiting (5 concurrent) let streaming_routes = Router::new() .route("/media/{id}/stream", get(routes::media::stream_media)) .layer(GovernorLayer::new(stream_governor)); // Read-only routes: any authenticated user (Viewer+) let viewer_routes = Router::new() .route("/health", get(routes::health::health)) .route("/health/detailed", get(routes::health::health_detailed)) .route("/media/count", get(routes::media::get_media_count)) .route("/media", get(routes::media::list_media)) .route("/media/{id}", get(routes::media::get_media)) .route("/media/{id}/thumbnail", get(routes::media::get_thumbnail)) .route("/media/{media_id}/tags", get(routes::tags::get_media_tags)) // Books API .nest("/books", routes::books::routes()) // Photos API .nest("/photos", routes::photos::routes()) .route("/tags", get(routes::tags::list_tags)) .route("/tags/{id}", get(routes::tags::get_tag)) .route("/collections", get(routes::collections::list_collections)) .route( "/collections/{id}", get(routes::collections::get_collection), ) .route( "/collections/{id}/members", get(routes::collections::get_members), ) .route("/audit", get(routes::audit::list_audit)) .route("/scan/status", get(routes::scan::scan_status)) .route("/config", get(routes::config::get_config)) .route("/config/ui", get(routes::config::get_ui_config)) .route("/database/stats", get(routes::database::database_stats)) .route("/duplicates", get(routes::duplicates::list_duplicates)) // Statistics .route("/statistics", get(routes::statistics::library_statistics)) // Scheduled tasks (read) .route( "/tasks/scheduled", get(routes::scheduled_tasks::list_scheduled_tasks), ) // Jobs .route("/jobs", get(routes::jobs::list_jobs)) .route("/jobs/{id}", get(routes::jobs::get_job)) // Saved searches (read) .route( "/searches/saved", get(routes::saved_searches::list_saved_searches), ) // Webhooks (read) .route("/webhooks", get(routes::webhooks::list_webhooks)) // Auth endpoints (self-service); login is handled separately with a stricter rate limit .route("/auth/logout", post(routes::auth::logout)) .route("/auth/me", get(routes::auth::me)) .route("/auth/refresh", post(routes::auth::refresh)) .route("/auth/revoke-all", post(routes::auth::revoke_all_sessions)) // Social: ratings & comments (read) .route( "/media/{id}/ratings", get(routes::social::get_media_ratings), ) .route( "/media/{id}/comments", get(routes::social::get_media_comments), ) // Favorites (read) .route("/favorites", get(routes::social::list_favorites)) // Playlists (read) .route("/playlists", get(routes::playlists::list_playlists)) .route("/playlists/{id}", get(routes::playlists::get_playlist)) .route("/playlists/{id}/items", get(routes::playlists::list_items)) .route( "/playlists/{id}/shuffle", post(routes::playlists::shuffle_playlist), ) // Analytics (read) .route( "/analytics/most-viewed", get(routes::analytics::get_most_viewed), ) .route( "/analytics/recently-viewed", get(routes::analytics::get_recently_viewed), ) .route("/analytics/events", post(routes::analytics::record_event)) .route( "/media/{id}/progress", get(routes::analytics::get_watch_progress), ) .route( "/media/{id}/progress", post(routes::analytics::update_watch_progress), ) // Subtitles (read) .route( "/media/{id}/subtitles", get(routes::subtitles::list_subtitles), ) .route( "/media/{media_id}/subtitles/{subtitle_id}/content", get(routes::subtitles::get_subtitle_content), ) // Enrichment (read) .route( "/media/{id}/external-metadata", get(routes::enrichment::get_external_metadata), ) // Transcode (read) .route("/transcode/{id}", get(routes::transcode::get_session)) .route("/transcode", get(routes::transcode::list_sessions)) // Streaming .route( "/media/{id}/stream/hls/master.m3u8", get(routes::streaming::hls_master_playlist), ) .route( "/media/{id}/stream/hls/{profile}/playlist.m3u8", get(routes::streaming::hls_variant_playlist), ) .route( "/media/{id}/stream/hls/{profile}/{segment}", get(routes::streaming::hls_segment), ) .route( "/media/{id}/stream/dash/manifest.mpd", get(routes::streaming::dash_manifest), ) .route( "/media/{id}/stream/dash/{profile}/{segment}", get(routes::streaming::dash_segment), ) // Managed storage (read) .route("/media/{id}/download", get(routes::upload::download_file)) .route("/managed/stats", get(routes::upload::managed_stats)) // Sync (read) .route("/sync/devices", get(routes::sync::list_devices)) .route("/sync/devices/{id}", get(routes::sync::get_device)) .route("/sync/changes", get(routes::sync::get_changes)) .route("/sync/conflicts", get(routes::sync::list_conflicts)) .route("/sync/upload/{id}", get(routes::sync::get_upload_status)) .route("/sync/download/{*path}", get(routes::sync::download_file)) // Enhanced sharing (read) .route("/shares/outgoing", get(routes::shares::list_outgoing)) .route("/shares/incoming", get(routes::shares::list_incoming)) .route("/shares/{id}", get(routes::shares::get_share)) .route("/shares/{id}/activity", get(routes::shares::get_activity)) .route( "/notifications/shares", get(routes::shares::get_notifications), ) // Markdown notes/links (read) .route("/media/{id}/backlinks", get(routes::notes::get_backlinks)) .route( "/media/{id}/outgoing-links", get(routes::notes::get_outgoing_links), ) .nest("/notes", routes::notes::routes()); // Write routes: Editor+ required let editor_routes = Router::new() .route("/media/import", post(routes::media::import_media)) .route( "/media/import/options", post(routes::media::import_with_options), ) .route("/media/import/batch", post(routes::media::batch_import)) .route( "/media/import/directory", post(routes::media::import_directory_endpoint), ) .route( "/media/import/preview", post(routes::media::preview_directory), ) .route("/media/batch/tag", post(routes::media::batch_tag)) .route("/media/batch/delete", post(routes::media::batch_delete)) .route("/media/batch/update", patch(routes::media::batch_update)) .route( "/media/batch/collection", post(routes::media::batch_add_to_collection), ) .route("/media/all", delete(routes::media::delete_all_media)) .route("/media/{id}", patch(routes::media::update_media)) .route("/media/{id}", delete(routes::media::permanent_delete_media)) .route("/media/{id}/open", post(routes::media::open_media)) // File management .route("/media/{id}/rename", patch(routes::media::rename_media)) .route( "/media/{id}/move", patch(routes::media::move_media_endpoint), ) .route("/media/{id}/trash", post(routes::media::soft_delete_media)) .route("/media/{id}/restore", post(routes::media::restore_media)) .route("/media/batch/move", post(routes::media::batch_move_media)) // Trash management .route("/trash", get(routes::media::list_trash)) .route("/trash/info", get(routes::media::trash_info)) .route("/trash", delete(routes::media::empty_trash)) .route( "/media/{id}/custom-fields", post(routes::media::set_custom_field), ) .route( "/media/{id}/custom-fields/{name}", delete(routes::media::delete_custom_field), ) // Markdown notes/links (write) .route( "/media/{id}/reindex-links", post(routes::notes::reindex_links), ) .route("/tags", post(routes::tags::create_tag)) .route("/tags/{id}", delete(routes::tags::delete_tag)) .route("/media/{media_id}/tags", post(routes::tags::tag_media)) .route( "/media/{media_id}/tags/{tag_id}", delete(routes::tags::untag_media), ) .route("/collections", post(routes::collections::create_collection)) .route( "/collections/{id}", delete(routes::collections::delete_collection), ) .route( "/collections/{id}/members", post(routes::collections::add_member), ) .route( "/collections/{collection_id}/members/{media_id}", delete(routes::collections::remove_member), ) .route("/scan", post(routes::scan::trigger_scan)) .route("/jobs/{id}/cancel", post(routes::jobs::cancel_job)) // Saved searches (write) .route( "/searches/saved", post(routes::saved_searches::create_saved_search), ) .route( "/searches/saved/{id}", delete(routes::saved_searches::delete_saved_search), ) // Integrity .route( "/jobs/orphan-detection", post(routes::integrity::trigger_orphan_detection), ) .route( "/jobs/verify-integrity", post(routes::integrity::trigger_verify_integrity), ) .route( "/jobs/cleanup-thumbnails", post(routes::integrity::trigger_cleanup_thumbnails), ) .route( "/jobs/generate-thumbnails", post(routes::integrity::generate_all_thumbnails), ) .route("/orphans/resolve", post(routes::integrity::resolve_orphans)) // Export .route("/jobs/export", post(routes::export::trigger_export)) .route( "/jobs/export/options", post(routes::export::trigger_export_with_options), ) // Scheduled tasks (write) .route( "/tasks/scheduled/{id}/toggle", post(routes::scheduled_tasks::toggle_scheduled_task), ) .route( "/tasks/scheduled/{id}/run-now", post(routes::scheduled_tasks::run_scheduled_task_now), ) // Webhooks .route("/webhooks/test", post(routes::webhooks::test_webhook)) // Social: ratings & comments (write) .route("/media/{id}/ratings", post(routes::social::rate_media)) .route("/media/{id}/comments", post(routes::social::add_comment)) // Favorites (write) .route("/favorites", post(routes::social::add_favorite)) .route( "/favorites/{media_id}", delete(routes::social::remove_favorite), ) // Share links .route("/share", post(routes::social::create_share_link)) // Playlists (write) .route("/playlists", post(routes::playlists::create_playlist)) .route("/playlists/{id}", patch(routes::playlists::update_playlist)) .route( "/playlists/{id}", delete(routes::playlists::delete_playlist), ) .route("/playlists/{id}/items", post(routes::playlists::add_item)) .route( "/playlists/{id}/items/{media_id}", delete(routes::playlists::remove_item), ) .route( "/playlists/{id}/reorder", post(routes::playlists::reorder_item), ) // Subtitles (write) .route( "/media/{id}/subtitles", post(routes::subtitles::add_subtitle), ) .route( "/subtitles/{id}", delete(routes::subtitles::delete_subtitle), ) .route( "/subtitles/{id}/offset", patch(routes::subtitles::update_offset), ) // Enrichment (write) .route( "/media/{id}/enrich", post(routes::enrichment::trigger_enrichment), ) .route("/jobs/enrich", post(routes::enrichment::batch_enrich)) // Transcode (write) .route( "/media/{id}/transcode", post(routes::transcode::start_transcode), ) .route("/transcode/{id}", delete(routes::transcode::cancel_session)) // Managed storage (write) .route("/upload", post(routes::upload::upload_file)) .route( "/media/{id}/move-to-managed", post(routes::upload::move_to_managed), ) // Sync (write) .route("/sync/devices", post(routes::sync::register_device)) .route("/sync/devices/{id}", put(routes::sync::update_device)) .route("/sync/devices/{id}", delete(routes::sync::delete_device)) .route( "/sync/devices/{id}/token", post(routes::sync::regenerate_token), ) .route("/sync/report", post(routes::sync::report_changes)) .route("/sync/ack", post(routes::sync::acknowledge_changes)) .route( "/sync/conflicts/{id}/resolve", post(routes::sync::resolve_conflict), ) .route("/sync/upload", post(routes::sync::create_upload)) .route( "/sync/upload/{id}/chunks/{index}", put(routes::sync::upload_chunk), ) .route( "/sync/upload/{id}/complete", post(routes::sync::complete_upload), ) .route("/sync/upload/{id}", delete(routes::sync::cancel_upload)) // Enhanced sharing (write) .route("/shares", post(routes::shares::create_share)) .route("/shares/{id}", patch(routes::shares::update_share)) .route("/shares/{id}", delete(routes::shares::delete_share)) .route("/shares/batch/delete", post(routes::shares::batch_delete)) .route( "/notifications/shares/{id}/read", post(routes::shares::mark_notification_read), ) .route( "/notifications/shares/read-all", post(routes::shares::mark_all_read), ) .layer(middleware::from_fn(auth::require_editor)); // Admin-only routes: destructive/config operations let admin_routes = Router::new() .route( "/config/scanning", put(routes::config::update_scanning_config), ) .route("/config/roots", post(routes::config::add_root)) .route("/config/roots", delete(routes::config::remove_root)) .route("/config/ui", put(routes::config::update_ui_config)) .route("/database/vacuum", post(routes::database::vacuum_database)) .route("/database/clear", post(routes::database::clear_database)) .route("/database/backup", post(routes::backup::create_backup)) // Plugin management .route("/plugins", get(routes::plugins::list_plugins)) .route("/plugins/events", post(routes::plugins::emit_plugin_event)) .route("/plugins/ui-pages", get(routes::plugins::list_plugin_ui_pages)) .route("/plugins/ui-widgets", get(routes::plugins::list_plugin_ui_widgets)) .route("/plugins/ui-theme-extensions", get(routes::plugins::list_plugin_ui_theme_extensions)) .route("/plugins/{id}", get(routes::plugins::get_plugin)) .route("/plugins/install", post(routes::plugins::install_plugin)) .route("/plugins/{id}", delete(routes::plugins::uninstall_plugin)) .route("/plugins/{id}/toggle", post(routes::plugins::toggle_plugin)) .route("/plugins/{id}/reload", post(routes::plugins::reload_plugin)) // User management .route("/users", get(routes::users::list_users)) .route("/users", post(routes::users::create_user)) .route("/users/{id}", get(routes::users::get_user)) .route("/users/{id}", patch(routes::users::update_user)) .route("/users/{id}", delete(routes::users::delete_user)) .route( "/users/{id}/libraries", get(routes::users::get_user_libraries), ) .route( "/users/{id}/libraries", post(routes::users::grant_library_access), ) .route( "/users/{id}/libraries", delete(routes::users::revoke_library_access), ) // Session management (admin) .route("/auth/sessions", get(routes::auth::list_active_sessions)) .layer(middleware::from_fn(auth::require_admin)); // CORS configuration: use config-driven origins if specified, // otherwise fall back to default localhost origins let cors = { let origins: Vec = if let Ok(config_read) = state.config.try_read() { if config_read.server.cors_enabled && !config_read.server.cors_origins.is_empty() { config_read .server .cors_origins .iter() .filter_map(|o| HeaderValue::from_str(o).ok()) .collect() } else { vec![ HeaderValue::from_static("http://localhost:3000"), HeaderValue::from_static("http://127.0.0.1:3000"), HeaderValue::from_static("tauri://localhost"), ] } } else { vec![ HeaderValue::from_static("http://localhost:3000"), HeaderValue::from_static("http://127.0.0.1:3000"), HeaderValue::from_static("tauri://localhost"), ] }; CorsLayer::new() .allow_origin(origins) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, ]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) .allow_credentials(true) }; // Create protected routes with auth middleware let protected_api = Router::new() .merge(viewer_routes) .merge(search_routes) .merge(streaming_routes) .merge(editor_routes) .merge(admin_routes) .layer(middleware::from_fn_with_state( state.clone(), auth::require_auth, )); // Combine protected, public, and share routes let full_api = Router::new() .merge(login_route) .merge(public_routes) .merge(share_routes) .merge(protected_api); // Build security headers layer let security_headers = ServiceBuilder::new() // Prevent MIME type sniffing .layer(SetResponseHeaderLayer::overriding( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), )) // Prevent clickjacking .layer(SetResponseHeaderLayer::overriding( header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"), )) // XSS protection (legacy but still useful for older browsers) .layer(SetResponseHeaderLayer::overriding( header::HeaderName::from_static("x-xss-protection"), HeaderValue::from_static("1; mode=block"), )) // Referrer policy .layer(SetResponseHeaderLayer::overriding( header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"), )) // Permissions policy (disable unnecessary features) .layer(SetResponseHeaderLayer::overriding( header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("geolocation=(), microphone=(), camera=()"), )) // Content Security Policy for API responses .layer(SetResponseHeaderLayer::overriding( header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), )); let router = Router::new() .nest("/api/v1", full_api) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(GovernorLayer::new(global_governor)) .layer(TraceLayer::new_for_http()) .layer(cors) .layer(security_headers); // Add HSTS header when TLS is enabled if let Some(tls) = tls_config { if tls.enabled && tls.hsts_enabled { let hsts_value = format!("max-age={}; includeSubDomains", tls.hsts_max_age); let hsts_header = HeaderValue::from_str(&hsts_value).unwrap_or_else(|_| { HeaderValue::from_static("max-age=31536000; includeSubDomains") }); router .layer(SetResponseHeaderLayer::overriding( header::STRICT_TRANSPORT_SECURITY, hsts_header, )) .with_state(state) } else { router.with_state(state) } } else { router.with_state(state) } }