diff --git a/Cargo.lock b/Cargo.lock index 77687f5d7..05d7e2938 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2418,9 +2418,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" dependencies = [ "twox-hash", ] diff --git a/Cargo.toml b/Cargo.toml index 2c7c4e6fd..9f0a157f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ bytes = "1.11.1" base64 = "0.22.1" blake3 = "1.8.3" fastrand = "2.3.0" -lz4_flex = "0.12.0" +lz4_flex = "0.13.0" indexmap = "2.13.0" dashmap = "6.1.0" arc-swap = "1.8.2" diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 9b5248959..5e5355dea 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -68,9 +68,12 @@ async fn m3u_api_get( } async fn m3u_api_post( + axum::extract::Query(api_query_req): axum::extract::Query, axum::extract::State(app_state): axum::extract::State>, - axum::extract::Form(api_req): axum::extract::Form, + api_form_req: Result, axum::extract::rejection::FormRejection>, ) -> impl IntoResponse + Send { + let form_req = api_form_req.as_ref().ok().map(|form| &form.0); + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req); m3u_api(&api_req, &app_state).await.into_response() } @@ -389,3 +392,43 @@ pub fn m3u_api_register() -> axum::Router> { axum::routing::get(m3u_api_resource), ) } + +#[cfg(test)] +mod tests { + use crate::api::model::UserApiRequest; + + #[test] + fn post_query_only_request_prefers_query_when_form_is_missing() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + content_type: String::from("m3u_plus"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.content_type, "m3u_plus"); + } + + #[test] + fn post_request_prefers_query_over_form() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + content_type: String::from("query-type"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + content_type: String::from("form-type"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.content_type, "query-type"); + } +} diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 072437984..2b146e13b 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -686,16 +686,6 @@ create_xtream_player_api_resource!(xtream_player_api_live_resource, ApiStreamCon create_xtream_player_api_resource!(xtream_player_api_series_resource, ApiStreamContext::Series); create_xtream_player_api_resource!(xtream_player_api_movie_resource, ApiStreamContext::Movie); -fn get_non_empty<'a>(first: &'a str, second: &'a str, third: &'a str) -> &'a str { - if !first.is_empty() { - first - } else if !second.is_empty() { - second - } else { - third - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] struct XtreamApiTimeShiftRequest { username: String, @@ -708,40 +698,51 @@ struct XtreamApiTimeShiftRequest { async fn xtream_player_api_timeshift_stream( fingerprint: Fingerprint, req_headers: HeaderMap, - axum::extract::Query(mut api_req): axum::extract::Query, + axum::extract::Query(api_query_req): axum::extract::Query, axum::extract::Path(timeshift_request): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, - axum::extract::Form(api_form_req): axum::extract::Form, + api_form_req: Result, axum::extract::rejection::FormRejection>, ) -> impl IntoResponse + Send { - let username = get_non_empty(×hift_request.username, &api_req.username, &api_form_req.username).to_string(); - let password = get_non_empty(×hift_request.password, &api_req.password, &api_form_req.password).to_string(); - let stream_id = - get_non_empty(×hift_request.stream_id, &api_req.stream_id, &api_form_req.stream_id).to_string(); - let duration = get_non_empty(×hift_request.duration, &api_req.duration, &api_form_req.duration); - let start_time = get_non_empty(×hift_request.start, &api_req.start, &api_form_req.start); + let form_req = api_form_req.as_ref().ok().map(|form| &form.0); + let query_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req); + let path_req = UserApiRequest { + username: timeshift_request.username, + password: timeshift_request.password, + duration: timeshift_request.duration, + start: timeshift_request.start, + stream_id: timeshift_request.stream_id, + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); let auth_status = app_state.app_config.get_auth_error_status(); let (user, target) = try_option_forbidden!( - get_user_target_by_credentials(&username, &password, &api_form_req, &app_state), + get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state), auth_status, false, - format!("Could not find any user {username}") + format!("Could not find any user {}", api_req.username) ); let epg_timeshift = parse_timeshift(user.epg_request_timeshift.as_deref()); - let start = apply_timeshift(start_time, &epg_timeshift); - let action_path = if start.is_empty() { format!("{duration}/{start_time}") } else { format!("{duration}/{start}") }; - - api_req.username.clone_from(&username); - api_req.password.clone_from(&password); - api_req.stream_id.clone_from(&stream_id); + let start = apply_timeshift(&api_req.start, &epg_timeshift); + let action_path = if start.is_empty() { + format!("{}/{}", api_req.duration, api_req.start) + } else { + format!("{}/{}", api_req.duration, start) + }; xtream_player_api_stream( &fingerprint, &req_headers, &app_state, &api_req, - ApiStreamRequest::from(ApiStreamContext::Timeshift, &username, &password, &stream_id, &action_path), + ApiStreamRequest::from( + ApiStreamContext::Timeshift, + &api_req.username, + &api_req.password, + &api_req.stream_id, + &action_path, + ), Some((user, target)), ) .await @@ -753,41 +754,48 @@ async fn xtream_player_api_timeshift_query_stream( req_headers: HeaderMap, axum::extract::Query(api_query_req): axum::extract::Query, axum::extract::State(app_state): axum::extract::State>, - axum::extract::Form(api_form_req): axum::extract::Form, + api_form_req: Result, axum::extract::rejection::FormRejection>, ) -> impl IntoResponse + Send { - let username = get_non_empty(&api_query_req.username, &api_form_req.username, ""); - let password = get_non_empty(&api_query_req.password, &api_form_req.password, ""); - let stream_id = get_non_empty(&api_query_req.stream, &api_form_req.stream, ""); - let duration = get_non_empty(&api_query_req.duration, &api_form_req.duration, ""); - let start_time = get_non_empty(&api_query_req.start, &api_form_req.start, ""); + let form_req = api_form_req.as_ref().ok().map(|form| &form.0); + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req); - if username.is_empty() - || password.is_empty() - || stream_id.is_empty() - || duration.is_empty() - || start_time.is_empty() + if api_req.username.is_empty() + || api_req.password.is_empty() + || api_req.stream.is_empty() + || api_req.duration.is_empty() + || api_req.start.is_empty() { return axum::http::StatusCode::BAD_REQUEST.into_response(); } let auth_status = app_state.app_config.get_auth_error_status(); let (user, target) = try_option_forbidden!( - get_user_target_by_credentials(username, password, &api_query_req, &app_state), + get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state), auth_status, false, - format!("Could not find any user {username}") + format!("Could not find any user {}", api_req.username) ); let epg_timeshift = parse_timeshift(user.epg_request_timeshift.as_deref()); - let start = apply_timeshift(start_time, &epg_timeshift); - let action_path = if start.is_empty() { format!("{duration}/{start_time}") } else { format!("{duration}/{start}") }; + let start = apply_timeshift(&api_req.start, &epg_timeshift); + let action_path = if start.is_empty() { + format!("{}/{}", api_req.duration, api_req.start) + } else { + format!("{}/{}", api_req.duration, start) + }; xtream_player_api_stream( &fingerprint, &req_headers, &app_state, - &api_query_req, - ApiStreamRequest::from(ApiStreamContext::Timeshift, username, password, stream_id, &action_path), + &api_req, + ApiStreamRequest::from( + ApiStreamContext::Timeshift, + &api_req.username, + &api_req.password, + &api_req.stream, + &action_path, + ), Some((user, target)), ) .await @@ -1305,8 +1313,11 @@ async fn xtream_player_api_get( async fn xtream_player_api_post( axum::extract::State(app_state): axum::extract::State>, - axum::extract::Form(api_req): axum::extract::Form, + axum::extract::Query(api_query_req): axum::extract::Query, + api_form_req: Result, axum::extract::rejection::FormRejection>, ) -> impl IntoResponse + Send { + let form_req = api_form_req.as_ref().ok().map(|form| &form.0); + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, form_req); xtream_player_api(api_req, &app_state).await } @@ -1398,3 +1409,167 @@ pub fn xtream_api_register() -> axum::Router> { ] ) } + +#[cfg(test)] +mod tests { + use crate::api::model::UserApiRequest; + use super::XtreamApiTimeShiftRequest; + + #[test] + fn post_query_only_request_prefers_query_when_form_is_missing() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + action: String::from("get_live_streams"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.action, "get_live_streams"); + } + + #[test] + fn post_request_prefers_query_over_form() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + action: String::from("query-action"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + action: String::from("form-action"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.action, "query-action"); + } + + #[test] + fn timeshift_query_request_prefers_query_when_form_is_missing() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.stream, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); + } + + #[test] + fn timeshift_query_request_prefers_query_over_form() { + let api_query_req = UserApiRequest { + username: String::from("query-user"), + stream: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + stream: String::from("99"), + duration: String::from("10"), + start: String::from("form-start"), + ..UserApiRequest::default() + }; + + let api_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.stream, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); + } + + #[test] + fn timeshift_path_request_prefers_query_when_form_is_missing() { + let timeshift_request = XtreamApiTimeShiftRequest { + username: String::new(), + password: String::new(), + duration: String::new(), + start: String::new(), + stream_id: String::new(), + }; + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream_id: String::from("42"), + duration: String::from("60"), + start: String::from("2024-01-01:00-00"), + ..UserApiRequest::default() + }; + let query_req = UserApiRequest::merge_query_over_form(&api_query_req, None); + let path_req = UserApiRequest { + username: timeshift_request.username, + password: timeshift_request.password, + duration: timeshift_request.duration, + start: timeshift_request.start, + stream_id: timeshift_request.stream_id, + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); + + assert_eq!(api_req.username, "query-user"); + assert_eq!(api_req.password, "query-pass"); + assert_eq!(api_req.stream_id, "42"); + assert_eq!(api_req.duration, "60"); + assert_eq!(api_req.start, "2024-01-01:00-00"); + } + + #[test] + fn timeshift_path_request_prefers_path_over_query_and_form() { + let timeshift_request = XtreamApiTimeShiftRequest { + username: String::from("path-user"), + password: String::from("path-pass"), + duration: String::from("120"), + start: String::from("path-start"), + stream_id: String::from("7"), + }; + let api_query_req = UserApiRequest { + username: String::from("query-user"), + password: String::from("query-pass"), + stream_id: String::from("42"), + duration: String::from("60"), + start: String::from("query-start"), + ..UserApiRequest::default() + }; + let form_req = UserApiRequest { + username: String::from("form-user"), + password: String::from("form-pass"), + stream_id: String::from("99"), + duration: String::from("10"), + start: String::from("form-start"), + ..UserApiRequest::default() + }; + + let query_req = UserApiRequest::merge_query_over_form(&api_query_req, Some(&form_req)); + let path_req = UserApiRequest { + username: timeshift_request.username, + password: timeshift_request.password, + duration: timeshift_request.duration, + start: timeshift_request.start, + stream_id: timeshift_request.stream_id, + ..UserApiRequest::default() + }; + let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req); + + assert_eq!(api_req.username, "path-user"); + assert_eq!(api_req.password, "path-pass"); + assert_eq!(api_req.stream_id, "7"); + assert_eq!(api_req.duration, "120"); + assert_eq!(api_req.start, "path-start"); + } +} diff --git a/backend/src/api/model/request.rs b/backend/src/api/model/request.rs index d9eb7ee52..c538d14ac 100644 --- a/backend/src/api/model/request.rs +++ b/backend/src/api/model/request.rs @@ -1,4 +1,4 @@ -#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] pub struct UserApiRequest { #[serde(default)] pub username: String, @@ -31,6 +31,37 @@ pub struct UserApiRequest { } impl UserApiRequest { + pub fn merge_prefer_primary(primary: &Self, fallback: &Self) -> Self { + fn pick(primary: &str, fallback: &str) -> String { + if primary.trim().is_empty() { + fallback.to_string() + } else { + primary.to_string() + } + } + + Self { + username: pick(&primary.username, &fallback.username), + password: pick(&primary.password, &fallback.password), + token: pick(&primary.token, &fallback.token), + action: pick(&primary.action, &fallback.action), + series_id: pick(&primary.series_id, &fallback.series_id), + vod_id: pick(&primary.vod_id, &fallback.vod_id), + stream_id: pick(&primary.stream_id, &fallback.stream_id), + category_id: pick(&primary.category_id, &fallback.category_id), + limit: pick(&primary.limit, &fallback.limit), + start: pick(&primary.start, &fallback.start), + end: pick(&primary.end, &fallback.end), + stream: pick(&primary.stream, &fallback.stream), + duration: pick(&primary.duration, &fallback.duration), + content_type: pick(&primary.content_type, &fallback.content_type), + } + } + + pub fn merge_query_over_form(query: &Self, form: Option<&Self>) -> Self { + form.map_or_else(|| query.clone(), |form_req| Self::merge_prefer_primary(query, form_req)) + } + pub fn get_limit(&self) -> u32 { if self.limit.is_empty() { 0 @@ -39,3 +70,104 @@ impl UserApiRequest { } } } + +#[cfg(test)] +mod tests { + use super::UserApiRequest; + + #[test] + fn merge_prefer_primary_uses_fallback_for_empty_fields() { + let primary = UserApiRequest { + username: String::new(), + password: String::new(), + action: String::from("get_live_categories"), + ..UserApiRequest::default() + }; + let fallback = UserApiRequest { + username: String::from("xtr"), + password: String::from("xtr"), + ..UserApiRequest::default() + }; + + let merged = UserApiRequest::merge_prefer_primary(&primary, &fallback); + + assert_eq!(merged.username, "xtr"); + assert_eq!(merged.password, "xtr"); + assert_eq!(merged.action, "get_live_categories"); + } + + #[test] + fn merge_prefer_primary_uses_fallback_for_whitespace_only_fields() { + let primary = UserApiRequest { + username: String::from(" "), + password: String::from("\t"), + token: String::from("\n"), + action: String::from(" "), + series_id: String::from(" "), + vod_id: String::from(" "), + stream_id: String::from(" "), + category_id: String::from(" "), + limit: String::from(" "), + start: String::from(" "), + end: String::from(" "), + stream: String::from(" "), + duration: String::from(" "), + content_type: String::from(" "), + }; + let fallback = UserApiRequest { + username: String::from("user"), + password: String::from("pass"), + token: String::from("token"), + action: String::from("action"), + series_id: String::from("series"), + vod_id: String::from("vod"), + stream_id: String::from("stream"), + category_id: String::from("category"), + limit: String::from("10"), + start: String::from("100"), + end: String::from("200"), + stream: String::from("300"), + duration: String::from("60"), + content_type: String::from("m3u_plus"), + }; + + let merged = UserApiRequest::merge_prefer_primary(&primary, &fallback); + + assert_eq!(merged.username, "user"); + assert_eq!(merged.password, "pass"); + assert_eq!(merged.token, "token"); + assert_eq!(merged.action, "action"); + assert_eq!(merged.series_id, "series"); + assert_eq!(merged.vod_id, "vod"); + assert_eq!(merged.stream_id, "stream"); + assert_eq!(merged.category_id, "category"); + assert_eq!(merged.limit, "10"); + assert_eq!(merged.start, "100"); + assert_eq!(merged.end, "200"); + assert_eq!(merged.stream, "300"); + assert_eq!(merged.duration, "60"); + assert_eq!(merged.content_type, "m3u_plus"); + } + + #[test] + fn merge_query_over_form_prefers_query_fields() { + let query = UserApiRequest { + username: String::from("query-user"), + token: String::from("query-token"), + action: String::from("query-action"), + ..UserApiRequest::default() + }; + let form = UserApiRequest { + username: String::from("form-user"), + token: String::from("form-token"), + action: String::from("form-action"), + ..UserApiRequest::default() + }; + + let merged = UserApiRequest::merge_query_over_form(&query, Some(&form)); + + assert_eq!(merged.username, "query-user"); + assert_eq!(merged.token, "query-token"); + assert_eq!(merged.action, "query-action"); + } +}