mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-28 12:02:09 +02:00
Epg resource rewrite
This commit is contained in:
Generated
+3
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "3.2.7"
|
||||
version = "3.2.8"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
Reference in New Issue
Block a user