Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia7efa6db85da2d44b59e0e2e57f6e45b6a6a6964
638 lines
24 KiB
Rust
638 lines
24 KiB
Rust
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<HeaderValue> =
|
|
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)
|
|
}
|
|
}
|