From dd4f3beb562ca4cfbac170c8dcd6079fb2ba7fc4 Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 21 Nov 2025 12:28:34 +0100 Subject: [PATCH] Epg resource rewrite --- Cargo.lock | 6 +- backend/Cargo.toml | 2 +- backend/src/api/endpoints/xmltv_api.rs | 125 ++++++++++++++++++++---- backend/src/model/xmltv.rs | 13 +-- backend/src/repository/storage_const.rs | 1 + backend/src/utils/crypto_utils.rs | 68 +++++++------ frontend/Cargo.toml | 4 +- shared/Cargo.toml | 2 +- 8 files changed, 162 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13cc292a9..99a8dabd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.7" +version = "3.2.8" dependencies = [ "anyhow", "base64", @@ -3765,7 +3765,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.7" +version = "3.2.8" dependencies = [ "base64", "bitflags 2.10.0", @@ -4314,7 +4314,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.7" +version = "3.2.8" dependencies = [ "arc-swap", "async-compression", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 05b54b764..e99f5f7e0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.7" +version = "3.2.8" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index a3031d2c3..626b074bb 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -1,22 +1,25 @@ -use crate::api::api_utils::try_unwrap_body; use crate::api::api_utils::{get_user_target, serve_file}; +use crate::api::api_utils::{get_user_target_by_credentials, resource_response, try_unwrap_body}; use crate::api::model::AppState; use crate::api::model::UserApiRequest; -use crate::model::{Config, EPG_TAG_PROGRAMME}; +use crate::model::{get_attr_value, Config, EPG_TAG_ICON, EPG_TAG_PROGRAMME}; use crate::model::{ConfigTarget, ProxyUserCredentials, TargetOutput}; -use crate::repository::m3u_repository::m3u_get_epg_file_path; use crate::repository::storage::get_target_storage_path; +use crate::repository::storage_const; use crate::repository::xtream_repository::{xtream_get_epg_file_path, xtream_get_storage_path}; use crate::utils; +use crate::utils::{deobscure_text, obscure_text}; use axum::response::IntoResponse; use chrono::{DateTime, Duration, FixedOffset, NaiveDateTime, Offset, TimeZone, Utc}; use chrono_tz::Tz; use log::{error, trace}; use quick_xml::events::{BytesStart, Event}; +use shared::model::{PlaylistItemType}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::io::AsyncWriteExt; use tokio_util::io::ReaderStream; +use crate::repository::m3u_repository::m3u_get_epg_file_path; pub fn get_empty_epg_response() -> axum::response::Response { try_unwrap_body!(axum::response::Response::builder() @@ -143,31 +146,50 @@ fn parse_timeshift(time_shift: Option<&String>) -> Option { } async fn serve_epg( + app_state: &Arc, epg_path: &Path, user: &ProxyUserCredentials, + target: &Arc, ) -> axum::response::Response { - match tokio::fs::try_exists(epg_path).await { - Ok(exists) => { - if exists { - match parse_timeshift(user.epg_timeshift.as_ref()) { - None => serve_file(epg_path, mime::TEXT_XML).await.into_response(), - Some(duration) => serve_epg_with_timeshift(epg_path, duration).await, - } + if let Ok(exists) = tokio::fs::try_exists(epg_path).await { + if exists { + let rewrite_resources = app_state.app_config.is_reverse_proxy_resource_rewrite_enabled(); + + // If redirect is true → rewrite_urls = false → keep original + // If redirect is false and rewrite_resources is true → rewrite_urls = true → rewriting allowed + // If redirect is false and rewrite_resources is false → rewrite_urls = false → no rewriting + let redirect = user.proxy.is_redirect(PlaylistItemType::Live) || target.is_force_redirect(PlaylistItemType::Live); + let rewrite_urls = !redirect && rewrite_resources; + + // Use 0 for timeshift if None + let timeshift = parse_timeshift(user.epg_timeshift.as_ref()).unwrap_or(0); + + return if timeshift != 0 || rewrite_urls { + let server_info = app_state.app_config.get_user_server_info(user); + let base_url = format!("{}/{}/{}/{}/", server_info.get_base_url(), + storage_const::EPG_RESOURCE_PATH, &user.username, &user.password); + // Apply timeshift and/or rewrite URLs + serve_epg_with_rewrites(epg_path, timeshift, rewrite_urls, &app_state.app_config.encrypt_secret, &base_url).await } else { - get_empty_epg_response() - } + // Neither timeshift nor rewrite needed, serve original file + serve_file(epg_path, mime::TEXT_XML).await.into_response() + }; } - Err(_) => get_empty_epg_response(), } + get_empty_epg_response() } -async fn serve_epg_with_timeshift( +#[allow(clippy::too_many_lines)] +async fn serve_epg_with_rewrites( epg_path: &Path, offset_minutes: i32, + rewrite_urls: bool, + secret: &[u8; 16], + base_url: &str, ) -> axum::response::Response { match tokio::fs::try_exists(epg_path).await { Ok(exists) => { - if ! exists { + if !exists { return axum::http::StatusCode::NOT_FOUND.into_response(); } } @@ -177,6 +199,8 @@ async fn serve_epg_with_timeshift( } } + let encrypt_secret = *secret; + let rewrite_base_url = base_url.to_owned(); match tokio::fs::File::open(epg_path).await { Ok(file) => { let reader = tokio::io::BufReader::new(file); @@ -190,7 +214,7 @@ async fn serve_epg_with_timeshift( loop { match xml_reader.read_event_into_async(&mut buf).await { - Ok(Event::Start(ref e)) if e.name().as_ref() == b"programme" => { + Ok(Event::Start(ref e)) if offset_minutes != 0 && e.name().as_ref() == b"programme" => { // Modify the attributes let mut elem = BytesStart::new(EPG_TAG_PROGRAMME); for attr in e.attributes() { @@ -228,6 +252,43 @@ async fn serve_epg_with_timeshift( break; } } + Ok(Event::Start(ref e)) if rewrite_urls && e.name().as_ref() == b"icon" => { + // Modify the attributes + let mut elem = BytesStart::new(EPG_TAG_ICON); + for attr in e.attributes() { + match attr { + Ok(attr) if attr.key.as_ref() == b"src" => { + if let Some(icon) = get_attr_value(&attr) { + if icon.is_empty() { + elem.push_attribute(attr); + } else { + let rewritten_url = if let Ok(encrypted) = obscure_text(&encrypt_secret, &icon) { + format!("{rewrite_base_url}{encrypted}") + } else { + icon + }; + elem.push_attribute(("src", rewritten_url.as_str())); + } + } else { + elem.push_attribute(attr); + } + } + Ok(attr) => { + // Copy any other attributes as they are + elem.push_attribute(attr); + } + Err(e) => { + error!("Error parsing attribute: {e}"); + } + } + } + + // Write the modified icon event + if let Err(e) = xml_writer.write_event_async(Event::Start(elem)).await { + error!("Failed to write Start event: {e}"); + break; + } + } Ok(Event::Eof) => break, // End of file Ok(event) => { // Write any other event as is @@ -293,7 +354,34 @@ async fn xmltv_api( return get_empty_epg_response(); }; - serve_epg(&epg_path, &user).await + serve_epg(&app_state, &epg_path, &user, &target).await +} + +#[axum::debug_handler] +async fn epg_api_resource( + req_headers: axum::http::HeaderMap, + axum::extract::Query(api_req): axum::extract::Query, + axum::extract::Path((username, password, resource)): axum::extract::Path<( + String, + String, + String, + )>, + axum::extract::State(app_state): axum::extract::State>, +) -> impl IntoResponse + Send { + let Some((user, _target)) = + get_user_target_by_credentials(&username, &password, &api_req, &app_state) + else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + }; + if user.permission_denied(&app_state) { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + if let Ok(resource_url) = deobscure_text(&app_state.app_config.encrypt_secret, &resource) { + resource_response(&app_state, &resource_url, &req_headers, None).await.into_response() + } else { + axum::http::StatusCode::BAD_REQUEST.into_response() + } } /// Registers the XMLTV EPG API routes for handling HTTP GET requests. @@ -311,6 +399,9 @@ pub fn xmltv_api_register() -> axum::Router> { .route("/xmltv.php", axum::routing::get(xmltv_api)) .route("/update/epg.php", axum::routing::get(xmltv_api)) .route("/epg", axum::routing::get(xmltv_api)) + .route(&format!("/{}/{{username}}/{{password}}/{{resource}}", storage_const::EPG_RESOURCE_PATH), + axum::routing::get(epg_api_resource), + ) } #[cfg(test)] diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index 62643ce6f..130c458c1 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -226,6 +226,13 @@ fn concat_text(t1: &String, t2: &str) -> String { } } +pub fn get_attr_value(attr: &quick_xml::events::attributes::Attribute) -> Option { + if let Ok(value) = attr.unescape_value() { + return Some(value.to_string()); + } + None +} + #[allow(clippy::too_many_lines)] async fn parse_xmltv_for_web_ui(reader: R) -> Result { @@ -246,12 +253,6 @@ async fn parse_xmltv_for_web_ui(reader: R) -> Resul - chrono::Duration::days(1); let threshold_ts = yesterday_start.timestamp(); - let get_attr_value = |attr: &quick_xml::events::attributes::Attribute| { - if let Ok(value) = attr.unescape_value() { - return Some(value.to_string()); - } - None - }; loop { match reader.read_event_into_async(&mut buf).await { diff --git a/backend/src/repository/storage_const.rs b/backend/src/repository/storage_const.rs index 64bf27d25..000eeb816 100644 --- a/backend/src/repository/storage_const.rs +++ b/backend/src/repository/storage_const.rs @@ -8,6 +8,7 @@ pub(in crate::repository) const FILE_M3U: &str = "m3u"; pub const FILE_SUFFIX_WAL: &str = "wal"; pub const M3U_STREAM_PATH: &str = "m3u-stream"; pub const M3U_RESOURCE_PATH: &str = "resource/m3u"; +pub const EPG_RESOURCE_PATH: &str = "resource/epg"; pub const COL_CAT_LIVE: &str = "cat_live"; pub const COL_CAT_SERIES: &str = "cat_series"; diff --git a/backend/src/utils/crypto_utils.rs b/backend/src/utils/crypto_utils.rs index 8e3716aab..d33d4c544 100644 --- a/backend/src/utils/crypto_utils.rs +++ b/backend/src/utils/crypto_utils.rs @@ -1,8 +1,7 @@ -use shared::error::{TuliproxError, TuliproxErrorKind}; use base64::{engine::general_purpose, Engine as _}; use openssl::symm::{Cipher, Crypter, Mode}; -use rand::Rng; - +use rand::{RngCore, rngs::OsRng, TryRngCore}; +use shared::error::{TuliproxError, TuliproxErrorKind}; fn encode_base64_string(input: &[u8]) -> String { general_purpose::URL_SAFE_NO_PAD.encode(input) @@ -32,45 +31,56 @@ pub fn deobfuscate_text(secret: &[u8], text: &str) -> Result { } } -pub fn encrypt_text(secret: &[u8; 16], text: &str) -> Result { - let iv: [u8; 16] = rand::rng().random(); // Random IV (AES-CBC 16 Bytes) - let cipher = Cipher::aes_128_cbc(); - let mut crypter = Crypter::new(cipher, Mode::Encrypt, secret, Some(&iv)).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - let mut ciphertext = vec![0; text.len() + cipher.block_size()]; - let mut count = crypter.update(text.as_bytes(), &mut ciphertext).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - count += crypter.finalize(&mut ciphertext[count..]).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - ciphertext.truncate(count); +pub fn obscure_text(secret: &[u8;16], url: &str) -> Result { + let mut iv = [0u8; 16]; + if OsRng.try_fill_bytes(&mut iv).is_err() { + rand::rng().fill_bytes(&mut iv); + } - // IV + Ciphertext - let mut out = iv.to_vec(); - out.extend(ciphertext); + // AES-CTR + let cipher = Cipher::aes_128_ctr(); + let mut crypter = Crypter::new(cipher, Mode::Encrypt, secret, Some(&iv)).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't create cipher".to_string()))?; + let mut buf = vec![0u8; url.len() + cipher.block_size()]; + let mut count = crypter.update(url.as_bytes(), &mut buf).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't update encryption".to_string()))?; + count += crypter.finalize(&mut buf[count..]).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't finalize encryption".to_string()))?; + buf.truncate(count); + + // IV + Ciphertext → URL-safe Base64 + let mut out = Vec::with_capacity(iv.len() + buf.len()); + out.extend_from_slice(&iv); + out.extend_from_slice(&buf); Ok(general_purpose::URL_SAFE_NO_PAD.encode(out)) } -pub fn decrypt_text(secret: &[u8; 16], encrypted_text: &str) -> Result { - let data = general_purpose::URL_SAFE_NO_PAD.decode(encrypted_text).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - let (iv, ciphertext) = data.split_at(16); // first 16 bytes IV - let cipher = Cipher::aes_128_cbc(); - let mut crypter = Crypter::new(cipher, Mode::Decrypt, secret, Some(iv)).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - let mut decrypted = vec![0; ciphertext.len() + cipher.block_size()]; - let mut count = crypter.update(ciphertext, &mut decrypted).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - count += crypter.finalize(&mut decrypted[count..]).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string()))?; - decrypted.truncate(count); - String::from_utf8(decrypted).map_err(|err| TuliproxError::new(TuliproxErrorKind::Info, err.to_string())) +pub fn deobscure_text(secret: &[u8;16], encoded: &str) -> Result { + // Base64 decode + let data = general_purpose::URL_SAFE_NO_PAD.decode(encoded).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't decode base64".to_string()))?; + + let (iv, ciphertext) = data.split_at(16); + + // AES-CTR Decryption + let cipher = Cipher::aes_128_ctr(); + let mut crypter = Crypter::new(cipher, Mode::Decrypt, secret, Some(iv)).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't create decrypt cipher".to_string()))?; + let mut buf = vec![0u8; ciphertext.len() + cipher.block_size()]; + let mut count = crypter.update(ciphertext, &mut buf).map_err(|_errerr| TuliproxError::new(TuliproxErrorKind::Info, "Can't decrypt".to_string()))?; + count += crypter.finalize(&mut buf[count..]).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't finalize decrypt".to_string()))?; + buf.truncate(count); + + String::from_utf8(buf).map_err(|_err| TuliproxError::new(TuliproxErrorKind::Info, "Can't create uf8 string from decrypted".to_string())) } #[cfg(test)] mod tests { - use crate::utils::crypto_utils::{decrypt_text, deobfuscate_text, encrypt_text, obfuscate_text}; - use rand::Rng; + use crate::utils::crypto_utils::{obscure_text, deobscure_text, deobfuscate_text, obfuscate_text}; + use rand::{Rng}; #[test] - fn test_encrypt() { + fn test_obscure() { let secret: [u8; 16] = rand::rng().random(); // Random IV (AES-CBC 16 Bytes) let plain = "hello world"; - let encrypted = encrypt_text(&secret, plain); - let decrypted = decrypt_text(&secret, &encrypted.unwrap()).unwrap(); + let encrypted = obscure_text(&secret, plain).unwrap(); + let decrypted = deobscure_text(&secret, &encrypted).unwrap(); assert_eq!(decrypted, plain); } diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index aa9e39291..bc7a924b7 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "frontend" -version = "3.2.7" +version = "3.2.8" edition = "2021" [dependencies] -shared = { version = "3.2.7", path = "../shared" } +shared = { version = "3.2.8", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/shared/Cargo.toml b/shared/Cargo.toml index fb516e9ed..238ab14f6 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.7" +version = "3.2.8" edition = "2021" [dependencies]