use std::sync::Arc; use axum::Router; use axum::extract::DefaultBodyLimit; use axum::http::{HeaderValue, Method, header}; use axum::middleware; use axum::routing::{delete, get, patch, post, put}; use tower::ServiceBuilder; use tower_governor::GovernorLayer; use tower_governor::governor::GovernorConfigBuilder; use tower_http::cors::CorsLayer; use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use crate::auth; use crate::routes; use crate::state::AppState; /// Create the router with optional TLS configuration for HSTS headers pub fn create_router(state: AppState) -> Router { create_router_with_tls(state, None) } /// Create the router with TLS configuration for security headers pub fn create_router_with_tls( state: AppState, tls_config: Option<&pinakes_core::config::TlsConfig>, ) -> Router { // Global rate limit: 100 requests/sec per IP let global_governor = Arc::new( GovernorConfigBuilder::default() .per_second(1) .burst_size(100) .finish() .unwrap(), ); // Strict rate limit for login: 5 requests/min per IP let login_governor = Arc::new( GovernorConfigBuilder::default() .per_second(12) // replenish one every 12 seconds .burst_size(5) .finish() .unwrap(), ); // Rate limit for search: 10 requests/min per IP let search_governor = Arc::new( GovernorConfigBuilder::default() .per_second(6) // replenish one every 6 seconds (10/min) .burst_size(10) .finish() .unwrap(), ); // Rate limit for streaming: 5 requests per IP (very restrictive for concurrent streams) let stream_governor = Arc::new( GovernorConfigBuilder::default() .per_second(60) // replenish slowly (one per minute) .burst_size(5) // max 5 concurrent connections .finish() .unwrap(), ); // Login route with strict rate limiting let login_route = Router::new() .route("/auth/login", post(routes::auth::login)) .layer(GovernorLayer::new(login_governor)); // Public routes (no auth required) let public_routes = Router::new() .route("/s/{token}", get(routes::social::access_shared_media)) // Enhanced sharing: public share access .route("/shared/{token}", get(routes::shares::access_shared)) // 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 handled separately with stricter rate limit .route("/auth/logout", post(routes::auth::logout)) .route("/auth/me", get(routes::auth::me)) .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)) // Plugin management .route("/plugins", get(routes::plugins::list_plugins)) .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: allow same-origin by default, plus the desktop UI origin let cors = CorsLayer::new() .allow_origin([ "http://localhost:3000".parse::().unwrap(), "http://127.0.0.1:3000".parse::().unwrap(), "tauri://localhost".parse::().unwrap(), ]) .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 and public routes let full_api = Router::new() .merge(login_route) .merge(public_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) } }