Epg resource rewrite

This commit is contained in:
euzu
2025-11-21 12:28:56 +01:00
parent 21807dd95b
commit dd4f3beb56
8 changed files with 162 additions and 59 deletions
Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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
+108 -17
View File
@@ -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<i32> {
}
async fn serve_epg(
app_state: &Arc<AppState>,
epg_path: &Path,
user: &ProxyUserCredentials,
target: &Arc<ConfigTarget>,
) -> 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<UserApiRequest>,
axum::extract::Path((username, password, resource)): axum::extract::Path<(
String,
String,
String,
)>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> 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<Arc<AppState>> {
.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)]
+7 -6
View File
@@ -226,6 +226,13 @@ fn concat_text(t1: &String, t2: &str) -> String {
}
}
pub fn get_attr_value(attr: &quick_xml::events::attributes::Attribute) -> Option<String> {
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<R: AsyncRead + Send + Unpin>(reader: R) -> Result<EpgTv, TuliproxError> {
@@ -246,12 +253,6 @@ async fn parse_xmltv_for_web_ui<R: AsyncRead + Send + Unpin>(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 {
+1
View File
@@ -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";
+39 -29
View File
@@ -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<String, String> {
}
}
pub fn encrypt_text(secret: &[u8; 16], text: &str) -> Result<String, TuliproxError> {
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<String, TuliproxError> {
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<String, TuliproxError> {
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<String, TuliproxError> {
// 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);
}
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "3.2.7"
version = "3.2.8"
edition = "2021"
[dependencies]