// HTTP/JSON REST API. // // This module is a transport adapter. Business behavior belongs in // synctv-api-common/src/impls and synctv-core so HTTP and gRPC share one execution // path. JSON request bodies use protobuf messages; REST handlers compose // path/query fields into those protobuf requests before calling impls. Bare // binary bodies, file uploads, and playback streams own raw bytes/streams. // File downloads still look like ordinary binary HTTP responses; the body is // backed by FileObjectDownload streams from core storage services. pub(crate) mod admin; pub(crate) mod admin_execute; pub(crate) mod auth; pub(crate) mod email; pub(crate) mod error; pub(crate) mod health; pub(crate) mod metrics_middleware; pub(crate) mod middleware; pub(crate) mod native_app_association; pub(crate) mod notifications; pub(crate) mod oauth2; pub(crate) mod public; pub(crate) mod room; pub(crate) mod room_extra; pub(crate) mod ticket; pub(crate) mod user; pub(crate) mod validation; pub(crate) mod webrtc; pub(crate) mod websocket; use crate::providers; use axum::{ body::Body, extract::State, http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, middleware as axum_middleware, response::{IntoResponse, Redirect}, routing::{get, post}, Router, }; use futures::StreamExt; use std::sync::{Arc, LazyLock}; use tower_http::compression::{ predicate::{DefaultPredicate, Predicate}, CompressionLayer, }; use tower_http::cors::CorsLayer; use tower_http::on_early_drop::{EarlyDropsAsFailures, OnEarlyDropLayer}; use tower_http::trace::{ DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer, }; pub use auth::extract_client_ip; pub use error::{map_api_error, AppError, AppResult}; pub use health::{create_health_router, create_metrics_router, liveness_check}; pub use middleware::{hsts_header, security_headers_middleware}; pub use websocket::{websocket_handler, AuthMethod}; pub(crate) fn reject_duplicate_header(headers: &HeaderMap, name: &HeaderName) -> AppResult<()> { let mut values = headers.get_all(name).iter(); let _ = values.next(); if values.next().is_some() { return Err(AppError::bad_request(format!( "Multiple {name} headers are not allowed" ))); } Ok(()) } pub(crate) fn required_header_str<'a>( headers: &'a HeaderMap, name: &'static str, missing_message: &'static str, ) -> AppResult<&'a str> { let header_name = HeaderName::from_static(name); reject_duplicate_header(headers, &header_name)?; let value = headers .get(&header_name) .ok_or_else(|| AppError::bad_request(missing_message)) .and_then(|value| { value .to_str() .map_err(|_| AppError::bad_request(format!("Invalid {name} header"))) })?; if value.trim().is_empty() { return Err(AppError::bad_request(missing_message)); } Ok(value) } pub(crate) fn optional_header_str<'a>( headers: &'a HeaderMap, name: &'static HeaderName, ) -> AppResult> { reject_duplicate_header(headers, name)?; headers .get(name) .map(|value| { value .to_str() .map_err(|_| AppError::bad_request(format!("Invalid {name} header"))) }) .transpose() } pub(crate) fn optional_content_range( headers: &HeaderMap, ) -> AppResult> { let Some(value) = optional_header_str(headers, &axum::http::header::CONTENT_RANGE)? else { return Ok(None); }; let value = value.trim(); let Some(rest) = value.strip_prefix("bytes ") else { return Err(AppError::bad_request("Invalid Content-Range header")); }; let (range, total) = rest .split_once('/') .ok_or_else(|| AppError::bad_request("Invalid Content-Range header"))?; let (start, end) = range .split_once('-') .ok_or_else(|| AppError::bad_request("Invalid Content-Range header"))?; let start = start .parse::() .map_err(|_| AppError::bad_request("Invalid Content-Range header"))?; let end_inclusive = end .parse::() .map_err(|_| AppError::bad_request("Invalid Content-Range header"))?; let total_size = total .parse::() .map_err(|_| AppError::bad_request("Invalid Content-Range header"))?; Ok(Some(synctv_core::models::FileUploadRange { start, end_inclusive, total_size, })) } pub(crate) fn optional_file_range( headers: &HeaderMap, ) -> AppResult> { let Some(value) = optional_header_str(headers, &header::RANGE)? else { return Ok(None); }; let value = value.trim(); let Some(rest) = value.strip_prefix("bytes=") else { return Err(AppError::bad_request("Invalid Range header")); }; if rest.contains(',') { return Err(AppError::bad_request( "Multiple byte ranges are unsupported", )); } let (start, end) = rest .split_once('-') .ok_or_else(|| AppError::bad_request("Invalid Range header"))?; match (start.trim(), end.trim()) { ("", "") => Err(AppError::bad_request("Invalid Range header")), ("", suffix) => { let length = suffix .parse::() .map_err(|_| AppError::bad_request("Invalid Range header"))?; if length == 0 { return Err(AppError::bad_request("Invalid Range header")); } Ok(Some(synctv_core::models::FileRangeRequest::Suffix { length, })) } (start, "") => { let start = start .parse::() .map_err(|_| AppError::bad_request("Invalid Range header"))?; Ok(Some(synctv_core::models::FileRangeRequest::From { start })) } (start, end) => { let start = start .parse::() .map_err(|_| AppError::bad_request("Invalid Range header"))?; let end_inclusive = end .parse::() .map_err(|_| AppError::bad_request("Invalid Range header"))?; if end_inclusive < start { return Err(AppError::bad_request("Invalid Range header")); } Ok(Some(synctv_core::models::FileRangeRequest::Exact( synctv_core::models::FileByteRange { start, end_inclusive, }, ))) } } } pub(crate) fn file_range_request_to_proto( range: synctv_core::models::FileRangeRequest, ) -> synctv_proto::client::FileRangeRequest { use synctv_proto::client::file_range_request::Range; let range = match range { synctv_core::models::FileRangeRequest::Exact(range) => { Range::Exact(synctv_proto::client::FileByteRange { start: range.start, end_inclusive: range.end_inclusive, }) } synctv_core::models::FileRangeRequest::From { start } => Range::FromStart(start), synctv_core::models::FileRangeRequest::Suffix { length } => Range::SuffixLength(length), }; synctv_proto::client::FileRangeRequest { range: Some(range) } } pub(crate) fn file_object_download_response( download: synctv_core::models::FileObjectDownload, cache_control: Option<&'static str>, ) -> AppResult { let metadata = download.metadata; let body_stream = download.stream.map(|chunk| { chunk.map_err(|error| { let app_error = AppError::from(error); std::io::Error::other(app_error.message().to_string()) }) }); let mut response = (StatusCode::OK, Body::from_stream(body_stream)).into_response(); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_str(&metadata.mime_type) .map_err(|_| AppError::internal_server_error("Invalid file content type"))?, ); response .headers_mut() .insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); if let Ok(value) = HeaderValue::from_str(&metadata.size_bytes.to_string()) { response.headers_mut().insert(header::CONTENT_LENGTH, value); } if let Some(cache_control) = cache_control { response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_static(cache_control), ); } if let Ok(value) = HeaderValue::from_str(&metadata.content_manifest_sha256) { response.headers_mut().insert( HeaderName::from_static("x-synctv-content-manifest-sha256"), value, ); } if let Some(range) = metadata.range { let start = range.start; let end = range.end_inclusive; let content_range = format!("bytes {start}-{end}/{}", metadata.total_size_bytes); *response.status_mut() = StatusCode::PARTIAL_CONTENT; if let Ok(value) = HeaderValue::from_str(&content_range) { response.headers_mut().insert(header::CONTENT_RANGE, value); } } Ok(response) } static X_FORWARDED_PROTO: LazyLock = LazyLock::new(|| HeaderName::from_static("x-forwarded-proto")); pub use synctv_api_common::app_state::{ build_app_state, AppState, ProxyCacheLifecycleRuntime, RouterOptions, }; pub fn create_router_from_options(options: RouterOptions) -> anyhow::Result { let state = build_app_state(options)?; create_router_from_shared_state(&state) } pub fn create_router_from_shared_state(state: &AppState) -> anyhow::Result { let state = state.clone(); let router = register_all_routes(); apply_global_layers(router, &state) } pub fn create_router_with_state_from_options( options: RouterOptions, ) -> anyhow::Result<(axum::Router, AppState)> { let state = build_app_state(options)?; let router = create_router_from_shared_state(&state)?; Ok((router, state)) } pub fn start_proxy_cache_lifecycle( cache: &Arc, ) -> ProxyCacheLifecycleRuntime { let manager = synctv_proxy::slice_cache::CacheLifecycleManager::new( cache.backend().clone(), cache.config().clone(), ); let cancel = manager.cancellation_token(); let handle = manager.start(); ProxyCacheLifecycleRuntime { cancel, handle } } /// Body size limits for specific endpoint categories. /// /// These are applied as `route_layer`s INSIDE the rate-limit route groups so that /// the limit is enforced before the handler reads the body, and the global 10 MB /// safety net remains as a fallback for routes not explicitly limited here. pub(crate) mod body_limits { /// Auth endpoints: 64 KB. /// Typical auth JSON bodies are under 1 KB; 64 KB leaves room for OPAQUE /// and passkey payloads. pub const AUTH: usize = 64 * 1024; /// Room create / update / settings: 64 KB. pub const ROOM: usize = 64 * 1024; /// Media add / edit requests: 512 KB (media metadata may include longer URLs or /// subtitles, but should never be megabyte-scale). pub const MEDIA: usize = 512 * 1024; /// Chat attachment database uploads: match the service-level attachment cap. pub const CHAT_ATTACHMENT: usize = 50 * 1024 * 1024; /// User avatar database uploads: match the service-level avatar cap. pub const USER_AVATAR: usize = 5 * 1024 * 1024; /// Cover database uploads: match the service-level cover cap. pub const COVER: usize = 10 * 1024 * 1024; } /// Authentication routes that are mounted inside the strict rate-limit group. /// Strict rate limiting: 5 req/min. Body limit: 64 KB. fn register_auth_routes() -> Router { Router::new() .route( "/api/oauth2/exchange", post(oauth2::exchange_authorization_code), ) .layer(axum::extract::DefaultBodyLimit::max(body_limits::AUTH)) } fn register_extracted_auth_routes() -> Router { Router::new() .route("/api/auth/email/confirm", post(auth::confirm_email_login)) .route("/api/auth/guest-token", post(auth::create_guest_token)) .route( "/api/auth/direct-password/register", post(auth::register_with_direct_password), ) .route( "/api/auth/direct-password/login", post(auth::login_with_direct_password), ) .route("/api/auth/login/start", post(auth::start_login)) .route( "/api/auth/email/registration/request", post(auth::request_email_registration), ) .route( "/api/auth/email/registration/confirm", post(auth::confirm_email_registration), ) .route( "/api/auth/passkeys/registration/start", post(auth::start_passkey_registration), ) .route( "/api/auth/passkeys/registration/finish", post(auth::finish_passkey_registration), ) .route( "/api/auth/passkeys/login/start", post(auth::start_passkey_login), ) .route( "/api/auth/passkeys/login/finish", post(auth::finish_passkey_login), ) .route( "/api/auth/opaque/login/start", post(auth::start_opaque_login), ) .route( "/api/auth/opaque/login/finish", post(auth::finish_opaque_login), ) .route( "/api/auth/opaque/registration/start", post(auth::start_opaque_registration), ) .route( "/api/auth/opaque/registration/finish", post(auth::finish_opaque_registration), ) .route("/api/auth/email/request", post(auth::request_email_login)) .route( "/api/auth/mfa/email/request", post(auth::request_mfa_email_code), ) .route( "/api/auth/mfa/email/verify", post(auth::verify_mfa_email_code), ) .route( "/api/auth/mfa/passkeys/start", post(auth::start_mfa_passkey), ) .route( "/api/auth/mfa/passkeys/finish", post(auth::finish_mfa_passkey), ) .route("/api/auth/mfa/totp/verify", post(auth::verify_mfa_totp)) .route( "/api/auth/mfa/recovery-code/verify", post(auth::verify_mfa_recovery_code), ) .route("/api/auth/refresh", post(auth::refresh_token)) // Tighter body limit for authentication endpoints (64 KB) .layer(axum::extract::DefaultBodyLimit::max(body_limits::AUTH)) } /// Media mutation routes (add, delete, reorder, edit, batch operations). /// Moderate rate limiting: 20 req/min. Body limit: 512 KB. fn register_media_routes() -> Router { Router::new() .route("/api/rooms/{roomId}/media", post(room::add_media)) .route( "/api/rooms/{roomId}/media", axum::routing::delete(room::clear_playlist), ) .route( "/api/rooms/{roomId}/media/batch", post(room::push_media_batch), ) .route("/api/rooms/{roomId}/media/move", post(room::move_media)) .route( "/api/rooms/{roomId}/media/{mediaId}", axum::routing::delete(room::delete_media), ) .route( "/api/rooms/{roomId}/media/{mediaId}", axum::routing::patch(room::edit_media), ) .route( "/api/rooms/{roomId}/media/{mediaId}/cover/upload-session", post(room::create_media_cover_upload_session), ) .route( "/api/rooms/{roomId}/media/{mediaId}/cover", axum::routing::put(room::update_media_cover).delete(room::clear_media_cover), ) .route( "/api/rooms/{roomId}/media/{mediaId}/thumbnail/upload-session", post(room::create_media_thumbnail_upload_session), ) .route( "/api/rooms/{roomId}/media/{mediaId}/thumbnail", axum::routing::put(room::update_media_thumbnail).delete(room::clear_media_thumbnail), ) .route( "/api/rooms/{roomId}/cover/upload-session", post(room::create_room_cover_upload_session), ) .route( "/api/rooms/{roomId}/cover", axum::routing::put(room::update_room_cover).delete(room::clear_room_cover), ) .route( "/api/rooms/{roomId}/playlists/{playlistId}/cover/upload-session", post(room::create_playlist_cover_upload_session), ) .route( "/api/rooms/{roomId}/playlists/{playlistId}/cover", axum::routing::put(room::update_playlist_cover).delete(room::clear_playlist_cover), ) // Media metadata bodies are small (URLs, titles, subtitles) .layer(axum::extract::DefaultBodyLimit::max(body_limits::MEDIA)) } /// Write routes (room CRUD, membership, playback control, playlists, user updates). /// Moderate rate limiting: 30 req/min. Room create/update body limit: 64 KB. fn register_write_routes() -> Router { let router = Router::new() .route("/api/rooms", post(room::create_room)) .route( "/api/rooms/{roomId}", axum::routing::delete(room::delete_room), ) .route( "/api/rooms/{roomId}/members/@me", axum::routing::put(room::join_room), ) .route( "/api/rooms/{roomId}/password/opaque/login/start", post(room::start_room_password_login), ) .route( "/api/rooms/{roomId}/password/opaque/login/finish", post(room::finish_room_password_login), ) .route( "/api/rooms/{roomId}/members/@me", axum::routing::delete(room::leave_room), ) .route( "/api/rooms/{roomId}/settings", axum::routing::patch(room::update_room_settings), ) .route( "/api/rooms/{roomId}/visibility", axum::routing::patch(room::update_room_visibility), ) .route( "/api/rooms/{roomId}/owner", post(room::transfer_room_ownership), ) .route( "/api/rooms/{roomId}/password/opaque/registration/start", axum::routing::patch(room::start_room_password_registration), ) .route( "/api/rooms/{roomId}/password/opaque/registration/finish", axum::routing::patch(room::finish_room_password_registration), ) .route( "/api/rooms/{roomId}/password", axum::routing::delete(room::clear_room_password), ) .route( "/api/rooms/{roomId}/playback/start", post(room::start_playback), ) .route( "/api/rooms/{roomId}/playback/stop", post(room::stop_playback), ) .route("/api/rooms/{roomId}/playback/next", post(room::play_next)) .route( "/api/rooms/{roomId}/playback/previous", post(room::play_previous), ) .route( "/api/rooms/{roomId}/playback/history/{entryId}/play", post(room::play_history_entry), ) .route( "/api/rooms/{roomId}/playback", axum::routing::patch(room::update_playback_state), ) .route("/api/rooms/{roomId}/playlists", post(room::create_playlist)) .route( "/api/rooms/{roomId}/playlists/{playlistId}", axum::routing::patch(room::update_playlist), ) .route( "/api/rooms/{roomId}/playlists/{playlistId}/move", post(room::move_playlist), ) .route( "/api/rooms/{roomId}/playlists/{playlistId}", axum::routing::delete(room::delete_playlist), ) .route( "/api/rooms/{roomId}/entries", axum::routing::delete(room::delete_entries), ) .route( "/api/rooms/{roomId}/streams/{mediaId}/kick", post(room::kick_room_stream), ) .route( "/api/rooms/{roomId}/settings/reset", post(room::reset_room_settings), ) .route( "/api/rooms/{roomId}/chat/messages", post(room::send_chat_message), ) .route( "/api/rooms/{roomId}/chat/attachments/upload-session", post(room::create_chat_attachment_upload_session), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}", axum::routing::patch(room::edit_chat_message), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}", axum::routing::delete(room::delete_chat_message), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}/pin", axum::routing::put(room::pin_chat_message).delete(room::unpin_chat_message), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}/reactions/{reactionKey}", axum::routing::put(room::set_chat_reaction).delete(room::clear_chat_reaction), ) .route( "/api/rooms/{roomId}/chat/read-state", post(room::mark_chat_read), ); router // Room/user write bodies should be small (room metadata, settings, passwords) .layer(axum::extract::DefaultBodyLimit::max(body_limits::ROOM)) } /// Read routes (user info, room discovery, room details, playlists, chat, media, playback). /// Rate limited: 100 req/min. fn register_read_routes() -> Router { Router::new() .route("/api/rooms/discover", get(room::discover_rooms)) .route("/api/rooms/categories", get(room::list_room_categories)) .route("/api/rooms/labels", get(room::list_room_labels)) .route( "/api/rooms/{roomId}/discovery", get(room::get_room_discovery), ) .route("/api/rooms/{roomId}", get(room::get_room)) .route("/api/rooms/{roomId}/settings", get(room::get_room_settings)) .route("/api/rooms/{roomId}/members", get(room::get_room_members)) .route("/api/rooms/{roomId}/streams", get(room::list_room_streams)) .route( "/api/rooms/{roomId}/streams/{mediaId}", get(room::get_room_stream_info), ) .route( "/api/rooms/{roomId}/chat/history", get(room::get_chat_history), ) .route( "/api/rooms/{roomId}/chat/search", get(room::search_chat_messages), ) .route( "/api/rooms/{roomId}/chat/playback-messages", get(room::get_chat_playback_messages), ) .route( "/api/rooms/{roomId}/chat/pinned-messages", get(room::list_pinned_chat_messages), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}", get(room::get_chat_message), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}/context", get(room::get_chat_message_context), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}/reactions/{reactionKey}/users", get(room::list_chat_reaction_users), ) .route( "/api/rooms/{roomId}/chat/messages/{messageId}/read-receipts", get(room::get_chat_message_read_receipts), ) .route( "/api/rooms/{roomId}/chat/read-state", get(room::get_chat_read_state), ) // Playlist and Media APIs .route("/api/rooms/{roomId}/playlists", get(room::list_playlists)) .route( "/api/rooms/{roomId}/playlists/{playlistId}", get(room::get_playlist), ) .route( "/api/rooms/{roomId}/media/list", post(room::list_playlist_items), ) .route("/api/rooms/{roomId}/media/{mediaId}", get(room::get_media)) .route("/api/rooms/{roomId}/playback", get(room::get_playback)) .route( "/api/rooms/{roomId}/playback/history", get(room::list_playback_history), ) .route( "/api/rooms/{roomId}/watch/playback-state", get(room::watch_playback_state), ) .route( "/api/rooms/{roomId}/watch/playback", get(room::watch_playback), ) .route( "/api/rooms/{roomId}/watch/room-settings", get(room::watch_room_settings), ) .route( "/api/rooms/{roomId}/watch/playlist-items", get(room::watch_playlist_items), ) .route( "/api/rooms/{roomId}/watch/room-members", get(room::watch_room_members), ) .route( "/api/rooms/{roomId}/watch/chat-events", get(room::watch_chat_events), ) .route( "/api/rooms/{roomId}/watch/chat-pin-events", get(room::watch_chat_pin_events), ) } fn register_chat_attachment_object_routes() -> Router { Router::new() .route( "/api/chat/attachment-objects/{encodedObjectKey}/complete", post(room::complete_chat_attachment_upload_session), ) .route( "/api/chat/attachment-objects/{encodedObjectKey}", axum::routing::put(room::upload_chat_attachment_object) .get(room::get_chat_attachment_object), ) .layer(axum::extract::DefaultBodyLimit::max( body_limits::CHAT_ATTACHMENT, )) } fn register_media_cover_object_routes() -> Router { Router::new() .route( "/api/media/cover-objects/{encodedObjectKey}/complete", post(room::complete_media_cover_upload_session), ) .route( "/api/media/cover-objects/{encodedObjectKey}", axum::routing::put(room::upload_media_cover_object).get(room::get_media_cover_object), ) .layer(axum::extract::DefaultBodyLimit::max(body_limits::COVER)) } fn register_media_thumbnail_object_routes() -> Router { Router::new() .route( "/api/media/thumbnail-objects/{encodedObjectKey}/complete", post(room::complete_media_thumbnail_upload_session), ) .route( "/api/media/thumbnail-objects/{encodedObjectKey}", axum::routing::put(room::upload_media_thumbnail_object) .get(room::get_media_thumbnail_object), ) .layer(axum::extract::DefaultBodyLimit::max(body_limits::COVER)) } fn register_room_cover_object_routes() -> Router { Router::new() .route( "/api/room/cover-objects/{encodedObjectKey}/complete", post(room::complete_room_cover_upload_session), ) .route( "/api/room/cover-objects/{encodedObjectKey}", axum::routing::put(room::upload_room_cover_object).get(room::get_room_cover_object), ) .layer(axum::extract::DefaultBodyLimit::max(body_limits::COVER)) } fn register_playlist_cover_object_routes() -> Router { Router::new() .route( "/api/playlist/cover-objects/{encodedObjectKey}/complete", post(room::complete_playlist_cover_upload_session), ) .route( "/api/playlist/cover-objects/{encodedObjectKey}", axum::routing::put(room::upload_playlist_cover_object) .get(room::get_playlist_cover_object), ) .layer(axum::extract::DefaultBodyLimit::max(body_limits::COVER)) } fn register_extracted_user_routes() -> Router { Router::new() .route("/api/user", get(user::get_me)) .route("/api/user/rooms/discover", get(user::discover_rooms)) .route( "/api/user/rooms/{roomId}/discovery", get(user::get_room_discovery), ) .route("/api/user/rooms", get(user::list_my_rooms)) .route("/api/user/favorite-rooms", get(user::list_favorite_rooms)) .route( "/api/user/rooms/{roomId}/favorite", axum::routing::put(user::favorite_room).delete(user::unfavorite_room), ) .route("/api/user", axum::routing::patch(user::update_user)) .route( "/api/user/avatar/upload-session", post(user::create_user_avatar_upload_session), ) .route( "/api/user/avatar", axum::routing::put(user::update_user_avatar).delete(user::clear_user_avatar), ) .route("/api/user/email/bind/start", post(user::start_email_bind)) .route( "/api/user/email/bind/confirm", post(user::confirm_email_bind), ) .route("/api/user/email/unbind", post(user::unbind_email)) .route( "/api/user/sensitive-verification/start", post(user::start_sensitive_operation_verification), ) .route( "/api/user/sensitive-verification/passkey/start", post(user::start_sensitive_operation_passkey), ) .route( "/api/user/sensitive-verification/email/request", post(user::request_sensitive_operation_email_code), ) .route( "/api/user/sensitive-verification/finish", post(user::finish_sensitive_operation_verification), ) .route( "/api/user/preferences", get(user::get_user_preferences).patch(user::update_user_preferences), ) .route( "/api/user/two-factor", axum::routing::put(user::set_two_factor_enabled), ) .route("/api/user/passkeys", get(user::list_passkeys)) .route( "/api/user/passkeys/bind/start", post(user::start_passkey_bind), ) .route( "/api/user/passkeys/bind/finish", post(user::finish_passkey_bind), ) .route( "/api/user/opaque-password/update/start", post(user::start_opaque_password_update), ) .route( "/api/user/opaque-password/update/finish", post(user::finish_opaque_password_update), ) .route( "/api/user/passkeys/{credentialId}", axum::routing::delete(user::delete_passkey), ) .route("/api/user/totp/setup/start", post(user::start_totp_setup)) .route("/api/user/totp/setup/finish", post(user::finish_totp_setup)) .route( "/api/user/totp/recovery-codes/regenerate", post(user::regenerate_totp_recovery_codes), ) .route("/api/user/totp", axum::routing::delete(user::delete_totp)) .route("/api/user/account-closure", post(user::close_account)) .route("/api/user/logout", post(auth::logout)) } fn register_user_avatar_object_routes() -> Router { Router::new() .route( "/api/user/avatar-objects/{encodedObjectKey}/complete", post(user::complete_user_avatar_upload_session), ) .route( "/api/user/avatar-objects/{encodedObjectKey}", axum::routing::put(user::upload_user_avatar_object).get(user::get_user_avatar_object), ) .layer(axum::extract::DefaultBodyLimit::max( body_limits::USER_AVATAR, )) } /// Assemble all route groups into a single router. fn register_websocket_routes() -> Router { Router::new().route( "/ws/rooms/{roomId}", axum::routing::get(websocket::websocket_handler), ) } fn register_all_routes() -> Router { let mut router = Router::new() .route("/", get(redirect_to_project)) .route( "/.well-known/apple-app-site-association", get(native_app_association::apple_app_site_association), ) .route( "/.well-known/assetlinks.json", get(native_app_association::android_asset_links), ) .merge(openapi_router()) .merge(public::create_public_router()) .merge(register_extracted_auth_routes()) .merge(register_auth_routes()) .merge(register_extracted_user_routes()) .merge(register_user_avatar_object_routes()) .merge( Router::new() .route("/api/rooms/{roomId}/members", post(room_extra::add_member)) .route( "/api/rooms/{roomId}/reviews/joins", get(room_extra::list_room_join_reviews), ) .route( "/api/rooms/{roomId}/reviews/joins/{requestId}/approve", post(room_extra::approve_room_join_review), ) .route( "/api/rooms/{roomId}/reviews/joins/{requestId}/reject", post(room_extra::reject_room_join_review), ) .route( "/api/rooms/{roomId}/members/{userId}", axum::routing::delete(room_extra::kick_member), ) .route( "/api/rooms/{roomId}/members/{userId}", axum::routing::patch(room_extra::set_member_permissions), ) .route( "/api/rooms/{roomId}/members/{userId}/remark-name", axum::routing::patch(room_extra::update_member_remark_name), ) .route( "/api/rooms/{roomId}/members/{userId}/display-tag", axum::routing::patch(room_extra::update_member_display_tag), ) .route( "/api/rooms/{roomId}/reports", get(room::list_room_content_reports).post(room::report_content), ) .route( "/api/rooms/{roomId}/reports/{reportId}", get(room::get_room_content_report), ) .route( "/api/rooms/{roomId}/reports/{reportId}/status", post(room::update_room_content_report_status), ), ) .merge(Router::new().route("/api/tickets", post(ticket::create_ticket))) .merge( Router::new() .route( "/api/oauth2/{provider}/bind", get(oauth2::get_bind_authorize_url), ) .route( "/api/oauth2/type/{provider}/unlink", axum::routing::delete(oauth2::unlink_provider), ) .route("/api/oauth2/linked", get(oauth2::get_linked_providers)), ) .merge(register_media_routes()) .merge(register_write_routes()) .merge(register_read_routes()) .merge(register_chat_attachment_object_routes()) .merge(register_media_cover_object_routes()) .merge(register_media_thumbnail_object_routes()) .merge(register_room_cover_object_routes()) .merge(register_playlist_cover_object_routes()) // WebRTC configuration endpoints .merge(Router::new().route( "/api/rooms/{roomId}/webrtc/ice-servers", get(webrtc::get_ice_servers), )) // Admin routes .merge(Router::new().nest("/api/admin", admin::create_admin_router())) // Provider routes .merge( Router::new() .merge(register_provider_management_routes()) .merge(Router::new().nest( "/api/providers", providers::common::register_common_routes(), )), ) .route( "/api/playback-providers/{roomId}/emby/thumbnail/{itemId}", get(crate::providers::playback_provider::emby::get_emby_thumbnail_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/fnos/image", get(crate::providers::playback_provider::fnos::get_fnos_image_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/thumbnail", get(crate::providers::playback_provider::qnap::get_qnap_thumbnail_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/nextcloud/preview", get(crate::providers::playback_provider::nextcloud::get_nextcloud_preview_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/seafile/thumbnail", get(crate::providers::playback_provider::seafile::get_seafile_thumbnail_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/synology/image", get(crate::providers::playback_provider::synology::get_synology_image_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/live-danmaku/{mediaId}", get(crate::providers::playback_provider::bilibili::watch_bilibili_live_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/live-danmaku/dynamic/{playlistId}", get(crate::providers::playback_provider::bilibili::watch_bilibili_dynamic_live_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/media-streams/{modeName}/{urlIndex}", get(crate::providers::playback_provider::bilibili::get_bilibili_media_stream) .head(crate::providers::playback_provider::bilibili::head_bilibili_media_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/hls-manifests/{modeName}/{urlIndex}", get(crate::providers::playback_provider::bilibili::get_bilibili_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::bilibili::get_bilibili_hls_resource) .head(crate::providers::playback_provider::bilibili::head_bilibili_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/dash-manifests/{modeName}", get(crate::providers::playback_provider::bilibili::get_bilibili_dash_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/dash-manifests/{modeName}/{manifestMode}", get(crate::providers::playback_provider::bilibili::get_bilibili_dash_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/dash-resources/{modeName}/{resourceKind}/{scope}/{uid}/{exp}/{sig}", get(crate::providers::playback_provider::bilibili::get_bilibili_dash_resource) .head(crate::providers::playback_provider::bilibili::head_bilibili_dash_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/dash-resources/{modeName}/{resourceKind}/{scope}/{uid}/{exp}/{sig}/{*resourcePath}", get(crate::providers::playback_provider::bilibili::get_bilibili_dash_resource) .head(crate::providers::playback_provider::bilibili::head_bilibili_dash_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::bilibili::get_bilibili_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/bilibili/{version}/danmaku-files/{danmakuIndex}", get(crate::providers::playback_provider::bilibili::get_bilibili_danmaku_file) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/streams/{modeName}/{urlIndex}", get(crate::providers::playback_provider::direct_url::get_direct_url_stream) .head(crate::providers::playback_provider::direct_url::head_direct_url_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/hls-manifests/{modeName}/{urlIndex}", get(crate::providers::playback_provider::direct_url::get_direct_url_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/hls-resources/{modeName}/{urlIndex}/{resourceKind}", get(crate::providers::playback_provider::direct_url::get_direct_url_hls_resource) .head(crate::providers::playback_provider::direct_url::head_direct_url_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/dash-manifests/{modeName}/{urlIndex}", get(crate::providers::playback_provider::direct_url::get_direct_url_dash_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/dash-resources/{modeName}/{urlIndex}/{resourceKind}/{scope}/{uid}/{exp}/{sig}", get(crate::providers::playback_provider::direct_url::get_direct_url_dash_resource) .head(crate::providers::playback_provider::direct_url::head_direct_url_dash_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/dash-resources/{modeName}/{urlIndex}/{resourceKind}/{scope}/{uid}/{exp}/{sig}/{*resourcePath}", get(crate::providers::playback_provider::direct_url::get_direct_url_dash_resource) .head(crate::providers::playback_provider::direct_url::head_direct_url_dash_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/direct-url/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::direct_url::get_direct_url_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/twitch/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::twitch::get_twitch_resource) .head(crate::providers::playback_provider::twitch::head_twitch_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/twitch/{version}/segments", get(crate::providers::playback_provider::twitch::get_twitch_segment) .head(crate::providers::playback_provider::twitch::head_twitch_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/twitch/{version}/chats/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::twitch::watch_twitch_chat) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/youtube/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::youtube::get_youtube_resource) .head(crate::providers::playback_provider::youtube::head_youtube_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/youtube/{version}/segments", get(crate::providers::playback_provider::youtube::get_youtube_segment) .head(crate::providers::playback_provider::youtube::head_youtube_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/youtube/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::youtube::get_youtube_subtitle) .head(crate::providers::playback_provider::youtube::head_youtube_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/huya/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::huya::get_huya_resource) .head(crate::providers::playback_provider::huya::head_huya_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/huya/{version}/segments", get(crate::providers::playback_provider::huya::get_huya_segment) .head(crate::providers::playback_provider::huya::head_huya_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/huya/{version}/segments.ts", get(crate::providers::playback_provider::huya::get_huya_segment) .head(crate::providers::playback_provider::huya::head_huya_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/huya/{version}/danmakus/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::huya::watch_huya_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyu/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::douyu::get_douyu_resource) .head(crate::providers::playback_provider::douyu::head_douyu_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyin/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::douyin::get_resource) .head(crate::providers::playback_provider::douyin::head_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyin/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::douyin::get_hls_resource) .head(crate::providers::playback_provider::douyin::head_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyin/{version}/danmakus/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::douyin::watch_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/tiktok/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::tiktok::get_tiktok_resource) .head(crate::providers::playback_provider::tiktok::head_tiktok_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/tiktok/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::tiktok::get_tiktok_hls_resource) .head(crate::providers::playback_provider::tiktok::head_tiktok_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/tiktok/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::tiktok::get_tiktok_subtitle) .head(crate::providers::playback_provider::tiktok::head_tiktok_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyu/{version}/segments", get(crate::providers::playback_provider::douyu::get_douyu_segment) .head(crate::providers::playback_provider::douyu::head_douyu_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/douyu/{version}/danmakus/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::douyu::watch_douyu_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/acfun/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::acfun::get_acfun_resource) .head(crate::providers::playback_provider::acfun::head_acfun_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/acfun/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::acfun::get_acfun_hls_resource) .head(crate::providers::playback_provider::acfun::head_acfun_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/acfun/{version}/danmaku-files/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::acfun::get_acfun_danmaku_file) .head(crate::providers::playback_provider::acfun::head_acfun_danmaku_file) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/acfun/{version}/danmakus/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::acfun::watch_acfun_danmaku) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cctv/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::cctv::get_cctv_resource) .head(crate::providers::playback_provider::cctv::head_cctv_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cctv/{version}/segments", get(crate::providers::playback_provider::cctv::get_cctv_segment) .head(crate::providers::playback_provider::cctv::head_cctv_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/fnos/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::fnos::get_fnos_resource) .head(crate::providers::playback_provider::fnos::head_fnos_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/fnos/{version}/segments", get(crate::providers::playback_provider::fnos::get_fnos_segment) .head(crate::providers::playback_provider::fnos::head_fnos_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/fnos/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::fnos::get_fnos_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/fnos/{version}/thumbnail", get(crate::providers::playback_provider::fnos::get_fnos_thumbnail) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::qnap::get_qnap_resource) .head(crate::providers::playback_provider::qnap::head_qnap_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/{version}/hls-manifests/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::qnap::get_qnap_hls_manifest) .head(crate::providers::playback_provider::qnap::head_qnap_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::qnap::get_qnap_hls_resource) .head(crate::providers::playback_provider::qnap::head_qnap_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::qnap::get_qnap_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/qnap/{version}/thumbnail", get(crate::providers::playback_provider::qnap::get_qnap_thumbnail) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/synology/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::synology::get_synology_resource) .head(crate::providers::playback_provider::synology::head_synology_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cloudreve/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::cloudreve::get_cloudreve_resource) .head(crate::providers::playback_provider::cloudreve::head_cloudreve_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cloudreve/{version}/hls-manifests/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::cloudreve::get_cloudreve_hls_manifest) .head(crate::providers::playback_provider::cloudreve::head_cloudreve_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cloudreve/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::cloudreve::get_cloudreve_hls_resource) .head(crate::providers::playback_provider::cloudreve::head_cloudreve_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/cloudreve/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::cloudreve::get_cloudreve_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/nextcloud/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::nextcloud::get_nextcloud_resource) .head(crate::providers::playback_provider::nextcloud::head_nextcloud_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/nextcloud/{version}/hls-manifests/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::nextcloud::get_nextcloud_hls_manifest) .head(crate::providers::playback_provider::nextcloud::head_nextcloud_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/nextcloud/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::nextcloud::get_nextcloud_hls_resource) .head(crate::providers::playback_provider::nextcloud::head_nextcloud_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/nextcloud/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::nextcloud::get_nextcloud_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/seafile/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::seafile::get_seafile_resource) .head(crate::providers::playback_provider::seafile::head_seafile_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/seafile/{version}/hls-manifests/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::seafile::get_seafile_hls_manifest) .head(crate::providers::playback_provider::seafile::head_seafile_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/seafile/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::seafile::get_seafile_hls_resource) .head(crate::providers::playback_provider::seafile::head_seafile_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/seafile/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::seafile::get_seafile_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/truenas/{version}/resources/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::truenas::get_truenas_resource) .head(crate::providers::playback_provider::truenas::head_truenas_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/truenas/{version}/hls-manifests/{modeName}/{mediaIndex}", get(crate::providers::playback_provider::truenas::get_truenas_hls_manifest) .head(crate::providers::playback_provider::truenas::head_truenas_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/truenas/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::truenas::get_truenas_hls_resource) .head(crate::providers::playback_provider::truenas::head_truenas_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/truenas/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::truenas::get_truenas_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/synology/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::synology::get_synology_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/synology/{version}/segments", get(crate::providers::playback_provider::synology::get_synology_segment) .head(crate::providers::playback_provider::synology::head_synology_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/alist/{version}/files/{modeName}/{urlIndex}", get(crate::providers::playback_provider::alist::get_alist_file_stream) .head(crate::providers::playback_provider::alist::head_alist_file_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/alist/{version}/transcoded-hls-manifests/{modeName}/{urlIndex}", get(crate::providers::playback_provider::alist::get_alist_transcoded_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/alist/{version}/transcoded-hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::alist::get_alist_transcoded_hls_resource) .head(crate::providers::playback_provider::alist::head_alist_transcoded_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/alist/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::alist::get_alist_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/alist/{version}/thumbnail", get(crate::providers::playback_provider::alist::get_alist_thumbnail) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/emby/{version}/media-streams/{modeName}/{urlIndex}", get(crate::providers::playback_provider::emby::get_emby_media_stream) .head(crate::providers::playback_provider::emby::head_emby_media_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/emby/{version}/hls-manifests/{modeName}/{urlIndex}", get(crate::providers::playback_provider::emby::get_emby_hls_manifest) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/emby/{version}/hls-resources/{modeName}/{mediaIndex}/{resourceKind}", get(crate::providers::playback_provider::emby::get_emby_hls_resource) .head(crate::providers::playback_provider::emby::head_emby_hls_resource) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/emby/{version}/subtitles/{modeName}/{subtitleIndex}", get(crate::providers::playback_provider::emby::get_emby_subtitle) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/rtmp/{mediaId}/publish-key", post(room::create_room_publish_key), ) .route( "/api/playback-providers/{roomId}/rtmp/{version}/flv-stream", get(crate::providers::playback_provider::rtmp::get_rtmp_flv_stream) .head(crate::providers::playback_provider::rtmp::head_rtmp_flv_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/rtmp/{version}/hls-master", get(crate::providers::playback_provider::rtmp::get_rtmp_hls_master) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/rtmp/{version}/hls/{generationId}/index.m3u8", get(crate::providers::playback_provider::rtmp::get_rtmp_hls_playlist) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/rtmp/{version}/hls/{generationId}/{segmentName}", get(crate::providers::playback_provider::rtmp::get_rtmp_hls_segment) .head(crate::providers::playback_provider::rtmp::head_rtmp_hls_segment) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/live-proxy/{version}/flv-stream", get(crate::providers::playback_provider::live_proxy::get_live_proxy_flv_stream) .head(crate::providers::playback_provider::live_proxy::head_live_proxy_flv_stream) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/live-proxy/{version}/hls-master", get(crate::providers::playback_provider::live_proxy::get_live_proxy_hls_master) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/live-proxy/{version}/hls/{generationId}/index.m3u8", get(crate::providers::playback_provider::live_proxy::get_live_proxy_hls_playlist) .options(providers::playback_provider_options_preflight), ) .route( "/api/playback-providers/{roomId}/live-proxy/{version}/hls/{generationId}/{segmentName}", get(crate::providers::playback_provider::live_proxy::get_live_proxy_hls_segment) .head(crate::providers::playback_provider::live_proxy::head_live_proxy_hls_segment) .options(providers::playback_provider_options_preflight), ) .merge(register_websocket_routes()); router = router .merge(notifications::create_notification_read_router()) .merge(notifications::create_notification_write_router()); let email_routes = email::create_email_router(); router = router.merge(email_routes); router = router.merge( Router::new() .route( "/api/oauth2/{provider}/authorize", get(oauth2::get_authorize_url), ) .route( "/api/oauth2/providers", get(oauth2::list_available_providers), ), ); router } async fn redirect_to_project(State(state): State) -> Redirect { Redirect::temporary(&state.runtime_settings.server.project_url) } #[cfg(feature = "openapi")] fn openapi_router() -> Router { crate::openapi::router() } #[cfg(not(feature = "openapi"))] fn openapi_router() -> Router { Router::new() } fn register_provider_management_routes() -> Router { Router::new() .merge( Router::new() .nest( "/api/providers/bilibili", providers::bilibili::bilibili_auth_routes(), ) .nest( "/api/providers/alist", providers::alist::alist_auth_routes(), ) .nest( "/api/providers/cloudreve", providers::cloudreve::cloudreve_auth_routes(), ) .nest( "/api/providers/twitch", providers::twitch::twitch_auth_routes(), ) .nest( "/api/providers/youtube", providers::youtube::youtube_auth_routes(), ) .nest( "/api/providers/douyin", providers::douyin::douyin_auth_routes(), ) .nest( "/api/providers/tiktok", providers::tiktok::tiktok_auth_routes(), ) .nest("/api/providers/fnos", providers::fnos::fnos_auth_routes()) .nest("/api/providers/qnap", providers::qnap::qnap_auth_routes()) .nest( "/api/providers/synology", providers::synology::synology_auth_routes(), ) .nest( "/api/providers/nextcloud", providers::nextcloud::nextcloud_auth_routes(), ) .nest( "/api/providers/seafile", providers::seafile::seafile_auth_routes(), ) .nest( "/api/providers/truenas", providers::truenas::truenas_auth_routes(), ) .nest("/api/providers/emby", providers::emby::emby_auth_routes()), ) .merge( Router::new() .nest( "/api/providers/bilibili", providers::bilibili::bilibili_read_routes(), ) .nest( "/api/providers/alist", providers::alist::alist_read_routes(), ) .nest( "/api/providers/cloudreve", providers::cloudreve::cloudreve_read_routes(), ) .nest( "/api/providers/twitch", providers::twitch::twitch_read_routes(), ) .nest("/api/providers/huya", providers::huya::huya_routes()) .nest("/api/providers/douyu", providers::douyu::douyu_routes()) .nest("/api/providers/acfun", providers::acfun::acfun_routes()) .nest("/api/providers/cctv", providers::cctv::cctv_routes()) .nest( "/api/providers/youtube", providers::youtube::youtube_read_routes(), ) .nest( "/api/providers/douyin", providers::douyin::douyin_read_routes(), ) .nest( "/api/providers/tiktok", providers::tiktok::tiktok_read_routes(), ) .nest("/api/providers/fnos", providers::fnos::fnos_read_routes()) .nest("/api/providers/qnap", providers::qnap::qnap_read_routes()) .nest( "/api/providers/synology", providers::synology::synology_read_routes(), ) .nest( "/api/providers/nextcloud", providers::nextcloud::nextcloud_read_routes(), ) .nest( "/api/providers/seafile", providers::seafile::seafile_read_routes(), ) .nest( "/api/providers/truenas", providers::truenas::truenas_read_routes(), ) .nest("/api/providers/emby", providers::emby::emby_read_routes()), ) } /// Build CORS layer from API runtime settings. fn build_cors_layer( runtime_settings: &synctv_api_common::ApiRuntimeSettings, ) -> anyhow::Result { if runtime_settings.server.cors_allowed_origins.is_empty() { tracing::warn!( "CORS policy: DENY ALL cross-origin requests (no origins configured). \ Web frontends on different origins will fail to connect. \ To fix, set server.cors_allowed_origins to your frontend URL(s): \ SYNCTV_SERVER_CORS_ALLOWED_ORIGINS='[\"https://app.example.com\"]'" ); Ok(CorsLayer::new()) } else { let origins: Vec = runtime_settings .server .cors_allowed_origins .iter() .map(|origin| parse_configured_cors_origin(origin)) .collect::>>()?; tracing::info!( origins = ?origins, "CORS: Configured with {} allowed origin(s)", origins.len() ); Ok(CorsLayer::new() .allow_origin(origins) .allow_methods([ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS, ]) .allow_headers([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, axum::http::header::ACCEPT, axum::http::header::CONTENT_RANGE, axum::http::header::RANGE, axum::http::HeaderName::from_static("x-request-id"), axum::http::HeaderName::from_static(synctv_core::service::FILE_UPLOAD_TOKEN_HEADER), axum::http::HeaderName::from_static("traceparent"), axum::http::HeaderName::from_static("tracestate"), ]) .expose_headers([ axum::http::HeaderName::from_static("x-request-id"), axum::http::header::ACCEPT_RANGES, axum::http::header::CONTENT_RANGE, axum::http::HeaderName::from_static("x-synctv-content-manifest-sha256"), axum::http::HeaderName::from_static("x-synctv-upload-complete"), axum::http::HeaderName::from_static("x-synctv-uploaded-size-bytes"), axum::http::HeaderName::from_static("x-synctv-uploaded-parts"), ]) .vary([ axum::http::header::ORIGIN, axum::http::header::ACCESS_CONTROL_REQUEST_METHOD, axum::http::header::ACCESS_CONTROL_REQUEST_HEADERS, ])) } } fn parse_configured_cors_origin(origin: &str) -> anyhow::Result { synctv_api_common::validate_cors_origin(origin) .map_err(|error| anyhow::anyhow!("invalid CORS origin configured: {error}"))?; HeaderValue::from_str(origin) .map_err(|_| anyhow::anyhow!("invalid CORS origin configured: `{origin}`")) } fn forwarded_proto_is_https( server: &synctv_api_common::ApiServerSettings, headers: &HeaderMap, remote_addr: Option, ) -> AppResult { let Some(remote_addr) = remote_addr else { return Ok(false); }; if !server.is_trusted_proxy(&remote_addr) { return Ok(false); } let Some(value) = optional_header_str(headers, &X_FORWARDED_PROTO)? else { return Ok(false); }; Ok(value.eq_ignore_ascii_case("https")) } fn should_compress_json_response( _status: StatusCode, _version: axum::http::Version, headers: &HeaderMap, _extensions: &axum::http::Extensions, ) -> bool { headers .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(';').next()) .map(str::trim) .is_some_and(|media_type| { media_type.eq_ignore_ascii_case("application/json") || media_type.ends_with("+json") }) } /// Apply shared transport layers (CORS, body limit, security headers, HSTS, /// request ID propagation, and tracing) and bind state. fn apply_shared_http_layers( router: Router, cors: CorsLayer, server_config: synctv_api_common::ApiServerSettings, hsts_value: String, ) -> Router { router .layer(cors) .layer( CompressionLayer::new() .br(true) .gzip(true) .zstd(true) .compress_when(DefaultPredicate::default().and(should_compress_json_response)), ) .layer(axum::extract::DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(axum_middleware::from_fn(middleware::request_id_middleware)) .layer(axum_middleware::from_fn( middleware::security_headers_middleware, )) .layer(axum_middleware::from_fn( move |request: axum::extract::Request, next: axum::middleware::Next| { let hsts = hsts_value.clone(); let server_config = server_config.clone(); async move { let remote_addr = request .extensions() .get::>() .map(|ci| ci.0.ip()); let forwarded_proto_https = match forwarded_proto_is_https( &server_config, request.headers(), remote_addr, ) { Ok(value) => value, Err(error) => return error.into_response(), }; let mut response = next.run(request).await; if forwarded_proto_https { if let Ok(value) = axum::http::HeaderValue::from_str(&hsts) { response .headers_mut() .insert(axum::http::header::STRICT_TRANSPORT_SECURITY, value); } } else { response .headers_mut() .remove(axum::http::header::STRICT_TRANSPORT_SECURITY); } response } }, )) } fn apply_global_layers(router: Router, state: &AppState) -> anyhow::Result { let cors = build_cors_layer(&state.runtime_settings)?; let server_config = state.runtime_settings.server.clone(); let hsts_value = middleware::hsts_header(63_072_000, true, false); Ok( apply_shared_http_layers(router, cors, server_config, hsts_value) .layer(axum_middleware::from_fn(metrics_middleware::metrics_layer)) .layer(OnEarlyDropLayer::new(EarlyDropsAsFailures::new( DefaultOnFailure::default(), ))) .layer( TraceLayer::new_for_http() .make_span_with(DefaultMakeSpan::new().level(tracing::Level::DEBUG)) .on_request(DefaultOnRequest::new().level(tracing::Level::DEBUG)) .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), ) .with_state(state.clone()), ) } #[cfg(test)] mod tests;