pinakes/crates/pinakes-server/src/app.rs
NotAShelf ada1c07f66
pinakes-server: add widget, theme-extension, and event plugin routes; expose allowed_endpoints in UI page DTO
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia7efa6db85da2d44b59e0e2e57f6e45b6a6a6964
2026-03-11 21:30:43 +03:00

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