Files
tuliprox/backend/src/utils/network/request.rs
T

654 lines
29 KiB
Rust

use futures::{StreamExt, TryStreamExt};
use log::{debug, error, log_enabled, trace, Level};
use reqwest::header::CONTENT_ENCODING;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::collections::{HashMap, HashSet};
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio_util::io::StreamReader;
use url::Url;
use crate::model::{format_elapsed_time, AppConfig, InputSource, ReverseProxyDisabledHeaderConfig};
use crate::model::ConfigInput;
use crate::repository::storage::get_input_storage_path;
use crate::repository::storage_const;
use crate::utils::compression::compression_utils::{is_deflate, is_gzip};
use crate::utils::{async_file_reader, async_file_writer, debug_if_enabled, IO_BUFFER_SIZE};
use crate::utils::{get_file_path, persist_file};
use shared::error::create_tuliprox_error_result;
use shared::error::{str_to_io_error, TuliproxError, TuliproxErrorKind};
use shared::model::{InputFetchMethod, DEFAULT_USER_AGENT};
use shared::utils::{filter_request_header, human_readable_byte_size, sanitize_sensitive_info, short_hash, ENCODING_DEFLATE, ENCODING_GZIP};
use crate::api::model::persist_pipe_stream::tee_dyn_reader;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MimeCategory {
Unknown,
Video,
M3U8,
Image,
Json,
Xml,
Text,
Unclassified,
}
pub fn classify_content_type(headers: &[(String, String)]) -> MimeCategory {
headers.iter()
.find_map(|(k, v)| {
(k == axum::http::header::CONTENT_TYPE.as_str()).then_some(v)
})
.map_or(MimeCategory::Unknown, |v| match v.to_lowercase().as_str() {
v if v.starts_with("video/") || v == "application/octet-stream" => MimeCategory::Video,
v if v.contains("mpegurl") => MimeCategory::M3U8,
v if v.starts_with("image/") => MimeCategory::Image,
v if v.starts_with("application/json") || v.ends_with("+json") => MimeCategory::Json,
v if v.starts_with("application/xml") || v.ends_with("+xml") || v == "text/xml" => MimeCategory::Xml,
v if v.starts_with("text/") => MimeCategory::Text,
_ => MimeCategory::Unclassified,
})
}
pub async fn get_input_epg_content_as_file(client: &reqwest::Client, input: &ConfigInput, working_dir: &str, url_str: &str, persist_filepath: Option<PathBuf>) -> Result<PathBuf, TuliproxError> {
debug_if_enabled!("getting input epg content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(url_str));
if url_str.parse::<url::Url>().is_ok() {
match download_epg_content_as_file(client, input, url_str, working_dir, persist_filepath).await {
Ok(content) => Ok(content),
Err(e) => {
error!("cant download input {} epg url: {} => {}", input.name, sanitize_sensitive_info(url_str), sanitize_sensitive_info(e.to_string().as_str()));
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download")
}
}
} else {
let result = match get_file_path(working_dir, Some(PathBuf::from(url_str))) {
Some(filepath) => {
if filepath.exists() {
if let Some(persist_file_value) = persist_filepath {
let to_file = &persist_file_value;
if let Err(e) = tokio::fs::copy(&filepath, to_file).await {
error!("cant persist to: {} => {}", to_file.to_str().unwrap_or("?"), e);
return create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to persist: {} => {}", to_file.to_str().unwrap_or("?"), e);
}
}
if filepath.exists() {
Some(filepath)
} else {
return create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed: file does not exists {filepath:?}");
}
} else {
None
}
}
None => None
};
result.map_or_else(|| {
let msg = format!("cant read input url: {}", sanitize_sensitive_info(url_str));
error!("{msg}");
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "{msg}")
}, Ok)
}
}
pub async fn get_input_text_content(client: &reqwest::Client, input: &InputSource, working_dir: &str, persist_filepath: Option<PathBuf>) -> Result<String, TuliproxError> {
debug_if_enabled!("getting input text content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(&input.url));
if input.url.parse::<url::Url>().is_ok() {
match download_text_content(client, None, input, None, persist_filepath).await {
Ok((content, _response_url)) => Ok(content),
Err(e) => {
error!("Failed to download input '{}': {}", &input.name, sanitize_sensitive_info(e.to_string().as_str()));
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download")
}
}
} else {
let result = match get_file_path(working_dir, Some(PathBuf::from(&input.url))) {
Some(filepath) => {
if filepath.exists() {
if let Some(persist_file_value) = persist_filepath {
let to_file = &persist_file_value;
if let Err(e) = tokio::fs::copy(&filepath, to_file).await {
error!("cant persist to: {} => {}", to_file.to_str().unwrap_or("?"), e);
return create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to persist: {} => {}", to_file.to_str().unwrap_or("?"), e);
}
}
match get_local_file_content(&filepath).await {
Ok(content) => Some(content),
Err(err) => {
return create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed : {}", err);
}
}
} else {
None
}
}
None => None
};
result.map_or_else(|| {
let msg = format!("cant read input url: {}", sanitize_sensitive_info(&input.url));
error!("{msg}");
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "{msg}")
}, Ok)
}
}
pub async fn get_input_text_content_as_stream(client: &reqwest::Client, input: &InputSource, working_dir: &str, persist_filepath: Option<PathBuf>) -> Result<DynReader, TuliproxError> {
debug_if_enabled!("getting input text content working_dir: {}, url: {}", working_dir, sanitize_sensitive_info(&input.url));
if input.url.parse::<url::Url>().is_ok() {
match download_text_content_as_stream(client, None, input, None, persist_filepath).await {
Ok((content, _response_url)) => Ok(content),
Err(e) => {
error!("Failed to download input '{}': {}", &input.name, sanitize_sensitive_info(e.to_string().as_str()));
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed to download")
}
}
} else {
let result = match get_file_path(working_dir, Some(PathBuf::from(&input.url))) {
Some(filepath) => {
if filepath.exists() {
match get_local_file_content_as_stream(&filepath).await {
Ok(content) => {
if persist_filepath.is_some() {
let tee_reader: DynReader = if let Some(path) = persist_filepath {
let tee = tee_dyn_reader(content, &path, Some(Arc::new(|size| {
debug_if_enabled!("Persisted {} bytes", human_readable_byte_size(size as u64));
}))).await;
Box::pin(tee)
} else {
content
};
Some(tee_reader)
} else {
Some(content)
}
},
Err(err) => {
return create_tuliprox_error_result!(TuliproxErrorKind::Notify, "Failed : {}", err);
}
}
} else {
None
}
}
None => None
};
result.map_or_else(|| {
let msg = format!("cant read input url: {}", sanitize_sensitive_info(&input.url));
error!("{msg}");
create_tuliprox_error_result!(TuliproxErrorKind::Notify, "{msg}")
}, Ok)
}
}
pub fn get_client_request<S: ::std::hash::BuildHasher + Default>
(client: &reqwest::Client,
method: InputFetchMethod,
headers: Option<&HashMap<String, String, S>>,
url: &Url,
custom_headers: Option<&HashMap<String, Vec<u8>, S>>,
disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> reqwest::RequestBuilder {
let request = match method {
InputFetchMethod::GET => client.get(url.clone()),
InputFetchMethod::POST => {
// let base_url = url[..url::Position::BeforePath].to_string() + url.path();
let mut params: HashMap<String, String, S> = HashMap::default();
for (key, value) in url.query_pairs() {
params.insert(key.to_string(), value.to_string());
}
// we could cut the params but we leave them as query and add them as form.
client.post(url.clone()).form(&params)
}
};
let headers = get_request_headers(headers, custom_headers, disabled_headers);
request.headers(headers)
}
pub fn get_request_headers<S: ::std::hash::BuildHasher + Default>(request_headers: Option<&HashMap<String, String, S>>, custom_headers: Option<&HashMap<String, Vec<u8>, S>>, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> HeaderMap {
let mut headers = HeaderMap::default();
if let Some(req_headers) = request_headers {
for (key, value) in req_headers {
if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value.as_bytes())) {
if filter_request_header(key.as_str()) {
if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key.as_str())) {
continue;
}
headers.insert(key, value);
}
}
}
}
if let Some(custom) = custom_headers {
let header_keys: HashSet<String> = headers.keys().map(|k| k.as_str().to_lowercase()).collect();
for (key, value) in custom {
let key_lc = key.to_lowercase();
if filter_request_header(key_lc.as_str()) {
if disabled_headers.as_ref().is_some_and(|d| d.should_remove(key)) {
continue;
}
if header_keys.contains(key_lc.as_str()) {
// debug_if_enabled!("Ignoring request header '{}={}'", key_lc, String::from_utf8_lossy(value));
} else if let (Ok(key), Ok(value)) = (HeaderName::from_bytes(key.as_bytes()), HeaderValue::from_bytes(value)) {
headers.insert(key, value);
}
}
}
}
if log_enabled!(Level::Trace) {
let he: HashMap<String, String> = headers.iter().map(|(k, v)| (k.to_string(), String::from_utf8_lossy(v.as_bytes()).to_string())).collect();
if !he.is_empty() {
trace!("Request headers {he:?}");
}
}
if !headers.contains_key(axum::http::header::USER_AGENT) {
headers.insert(axum::http::header::USER_AGENT, HeaderValue::from_static(DEFAULT_USER_AGENT));
}
headers
}
pub async fn get_local_file_content(file_path: &Path) -> Result<String, std::io::Error> {
// Datei öffnen
let file = File::open(file_path).await.map_err(|err| {
std::io::Error::new(ErrorKind::NotFound, format!("Failed to open file: {}, {err:?}", file_path.display()))
})?;
let mut buf_reader = async_file_reader(file);
// Peek die ersten 2 Bytes, um gzip zu erkennen
let buffer = buf_reader.fill_buf().await?;
let is_gzipped = buffer.len() >= 2 && is_gzip(&buffer[0..2]);
let mut decoded = String::new();
if is_gzipped {
// Async Gzip Decoder verwenden
let mut gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader);
gzip_decoder.read_to_string(&mut decoded).await.map_err(|e| {
std::io::Error::other(format!("Failed to decode gzip content: {e}"))
})?;
} else {
// Plaintext lesen
buf_reader.read_to_string(&mut decoded).await.map_err(|e| {
std::io::Error::other(format!("Failed to read file: {e}"))
})?;
}
Ok(decoded)
}
pub async fn get_local_file_content_as_stream(file_path: &Path) -> Result<DynReader, std::io::Error> {
// open file
let file = File::open(file_path).await.map_err(|err| {
std::io::Error::new(ErrorKind::NotFound, format!("Failed to open file: {}, {err:?}", file_path.display()))
})?;
let mut buf_reader = async_file_reader(file);
// Peek first 2 Bytes, for gzip detection
let buffer = buf_reader.fill_buf().await?;
let is_gzipped = buffer.len() >= 2 && is_gzip(&buffer[0..2]);
if is_gzipped {
// use Async Gzip Decoder
Ok(Box::pin(async_compression::tokio::bufread::GzipDecoder::new(buf_reader)))
} else {
Ok(Box::pin(buf_reader))
}
}
// pub fn get_local_file_content_blocking(file_path: &PathBuf) -> Result<String, Error> {
// match fs::read(file_path) {
// Ok(content) => decode_local_file_bytes(content).await,
// Err(_) => Err(local_file_not_found(file_path)),
// }
// }
async fn get_remote_content_as_file(client: &reqwest::Client, input: &ConfigInput, url: &Url, file_path: &Path) -> Result<PathBuf, std::io::Error> {
let start_time = Instant::now();
let request = get_client_request(client, input.method, Some(&input.headers), url, None, None);
match request.send().await {
Ok(response) => {
if response.status().is_success() {
// Open a file in write mode
let mut writer = async_file_writer(File::create(file_path).await?);
let mut write_counter = 0;
// Stream the response body in chunks
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
write_counter += bytes.len();
writer.write_all(&bytes).await?;
if write_counter >= IO_BUFFER_SIZE {
writer.flush().await?;
write_counter = 0;
}
}
Err(err) => {
let _ = writer.flush().await;
let _ = writer.shutdown().await;
return Err(str_to_io_error(&format!("Failed to read chunk: {err}")));
}
}
}
writer.flush().await?;
writer.shutdown().await?;
let elapsed = start_time.elapsed().as_secs();
debug!("File downloaded successfully to {}, took:{}", file_path.display(), format_elapsed_time(elapsed));
Ok(file_path.to_path_buf())
} else {
Err(str_to_io_error(&format!("Request failed with status {} {}", response.status(), sanitize_sensitive_info(url.as_str()))))
}
}
Err(err) => Err(str_to_io_error(&format!("Request failed: {} {err}", sanitize_sensitive_info(url.as_str())))),
}
}
pub type DynReader = Pin<Box<dyn AsyncRead + Send>>;
#[allow(clippy::implicit_hasher)]
pub async fn get_remote_content_as_stream(
client: &reqwest::Client,
input: &InputSource,
headers: Option<&HeaderMap>,
url: &Url,
disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>,
) -> Result<(DynReader, String), Error> {
let custom_headers = headers.map(|h| {
h.iter().map(|(k, v)| (k.as_str().to_string(), v.as_bytes().to_vec())).collect::<HashMap<_, _>>()
});
let merged = get_request_headers(Some(&input.headers), custom_headers.as_ref(), disabled_headers);
let headers: HashMap<String, String> = merged.iter().map(|(k, v)| (k.as_str().to_string(), String::from_utf8_lossy(v.as_bytes()).to_string())).collect();
let request = get_client_request(client, input.method, Some(&headers), url, None, None);
let response = request.send().await.map_err(std::io::Error::other)?;
if !response.status().is_success() {
return Err(str_to_io_error(&format!("Request failed with status {} {}", response.status(), sanitize_sensitive_info(url.as_str()))));
}
let response_url = response.url().to_string();
let headers = response.headers();
let header_value = headers.get(CONTENT_ENCODING);
let mut encoding = header_value.and_then(|encoding_header| encoding_header.to_str().map_or(None, |value| Some(value.to_string())));
let stream_reader = StreamReader::new(
response.bytes_stream().map_err(std::io::Error::other),
);
let mut buf_reader = async_file_reader(stream_reader);
let peek = buf_reader.fill_buf().await?;
if peek.len() >= 2 {
if is_gzip(&peek[0..2]) {
encoding = Some(ENCODING_GZIP.to_string());
} else if is_deflate(&peek[0..2]) {
encoding = Some(ENCODING_DEFLATE.to_string());
}
}
let reader: DynReader = if encoding.as_ref().is_some_and(|e| e.eq_ignore_ascii_case(ENCODING_GZIP)) {
Box::pin(async_compression::tokio::bufread::GzipDecoder::new(buf_reader))
} else if encoding.as_ref().is_some_and(|e| e.eq_ignore_ascii_case(ENCODING_DEFLATE)) {
Box::pin(async_compression::tokio::bufread::ZlibDecoder::new(buf_reader))
} else {
Box::pin(buf_reader)
};
Ok((reader, response_url))
}
async fn get_remote_content(client: &reqwest::Client, input: &InputSource, headers: Option<&HeaderMap>, url: &Url, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>) -> Result<(String, String), Error> {
let start_time = Instant::now();
let (mut stream, response_url) = get_remote_content_as_stream(client, input, headers, url, disabled_headers).await.map_err(|e| str_to_io_error(&format!("Failed to read content: {e}")))?;
let mut content = String::new();
stream.read_to_string(&mut content).await.map_err(|e| str_to_io_error(&format!("Failed to read content: {e}")))?;
debug_if_enabled!("Request took: {} {}", format_elapsed_time(start_time.elapsed().as_secs()), sanitize_sensitive_info(url.as_str()));
Ok((content, response_url))
}
async fn download_epg_content_as_file(client: &reqwest::Client, input: &ConfigInput, url_str: &str, working_dir: &str, persist_filepath: Option<PathBuf>) -> Result<PathBuf, Error> {
if let Ok(url) = url_str.parse::<url::Url>() {
if url.scheme() == "file" {
url.to_file_path().map_or_else(|()| Err(Error::new(ErrorKind::Unsupported, format!("Unknown file {}", sanitize_sensitive_info(url_str)))), |file_path| if file_path.exists() {
Ok(file_path)
} else {
Err(Error::new(ErrorKind::NotFound, format!("Unknown file {}", file_path.display())))
})
} else {
let file_path = persist_filepath.map_or_else(|| match get_input_storage_path(&input.name, working_dir) {
Ok(download_path) => {
Ok(download_path.join(format!("{}_{}", short_hash(url_str), storage_const::FILE_EPG)))
}
Err(err) => Err(err)
}, Ok);
match file_path {
Ok(persist_path) => get_remote_content_as_file(client, input, &url, &persist_path).await,
Err(err) => Err(err)
}
}
} else {
Err(std::io::Error::new(ErrorKind::Unsupported, format!("Malformed URL {}", sanitize_sensitive_info(url_str))))
}
}
pub async fn download_text_content(
client: &reqwest::Client,
disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>,
input: &InputSource,
headers: Option<&HeaderMap>,
persist_filepath: Option<PathBuf>,
) -> Result<(String, String), Error> {
if let Ok(url) = input.url.parse::<url::Url>() {
let result = if url.scheme() == "file" {
match url.to_file_path() {
Ok(file_path) => get_local_file_content(&file_path).await.map(|c| (c, url.to_string())),
Err(()) => Err(str_to_io_error(&format!(
"Unknown file {}",
sanitize_sensitive_info(&input.url)
))),
}
} else {
get_remote_content(client, input, headers, &url, disabled_headers).await
};
match result {
Ok((content, response_url)) => {
if persist_filepath.is_some() {
persist_file(persist_filepath, &content).await;
}
Ok((content, response_url))
}
Err(err) => Err(err),
}
} else {
Err(str_to_io_error(&format!("Malformed URL {}",sanitize_sensitive_info(&input.url))))
}
}
pub async fn download_text_content_as_stream(
client: &reqwest::Client,
disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>,
input: &InputSource,
headers: Option<&HeaderMap>,
persist_filepath: Option<PathBuf>,
) -> Result<(DynReader, String), Error> {
if let Ok(url) = input.url.parse::<url::Url>() {
let result = if url.scheme() == "file" {
match url.to_file_path() {
Ok(file_path) => get_local_file_content_as_stream(&file_path).await.map(|c| (c, url.to_string())),
Err(()) => Err(str_to_io_error(&format!(
"Unknown file {}",
sanitize_sensitive_info(&input.url)
))),
}
} else {
get_remote_content_as_stream(client, input, headers, &url, disabled_headers).await
};
match result {
Ok((content, response_url)) => {
if persist_filepath.is_some() {
let tee_reader: DynReader = if let Some(path) = persist_filepath {
let tee = tee_dyn_reader(content, &path, Some(Arc::new(|size| {
debug!("Persisted {size} bytes");
}))).await;
Box::pin(tee)
} else {
content
};
Ok((tee_reader, response_url))
} else {
Ok((content, response_url))
}
}
Err(err) => Err(err),
}
} else {
Err(str_to_io_error(&format!("Malformed URL {}", sanitize_sensitive_info(&input.url))))
}
}
async fn download_json_content(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<serde_json::Value, Error> {
debug_if_enabled!("downloading json content from {}", sanitize_sensitive_info(&input.url));
match download_text_content(client, disabled_headers, input, None, persist_filepath).await {
Ok((content, _response_url)) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => Ok(value),
Err(err) => Err(str_to_io_error(&format!("Failed to parse json {err}")))
}
}
Err(err) => Err(err)
}
}
pub async fn get_input_json_content(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<serde_json::Value, TuliproxError> {
match download_json_content(client, disabled_headers, input, persist_filepath).await {
Ok(content) => Ok(content),
Err(e) => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "cant download input {}, url: {} => {}", input.name, sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str()))
}
}
async fn download_json_content_as_stream(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<DynReader, Error> {
debug_if_enabled!("downloading json content from {}", sanitize_sensitive_info(&input.url));
match download_text_content_as_stream(client, disabled_headers, input, None, persist_filepath).await {
Ok((reader, _response_url)) => Ok(reader),
Err(err) => Err(err)
}
}
pub async fn get_input_json_content_as_stream(client: &reqwest::Client, disabled_headers: Option<&ReverseProxyDisabledHeaderConfig>, input: &InputSource, persist_filepath: Option<PathBuf>) -> Result<DynReader, TuliproxError> {
match download_json_content_as_stream(client, disabled_headers, input, persist_filepath).await {
Ok(stream) => Ok(stream),
Err(e) => create_tuliprox_error_result!(TuliproxErrorKind::Notify, "cant download input {}, url: {} => {}", input.name, sanitize_sensitive_info(&input.url), sanitize_sensitive_info(e.to_string().as_str()))
}
}
pub fn create_client(cfg: &AppConfig) -> reqwest::ClientBuilder {
let config = cfg.config.load();
let mut client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.pool_idle_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.danger_accept_invalid_certs(config.accept_insecure_ssl_certificates);
if let Some(proxy_cfg) = config.proxy.as_ref() {
match Url::parse(&proxy_cfg.url) {
Ok(mut url) => {
let scheme = url.scheme().to_ascii_lowercase();
match scheme.as_str() {
"socks5" | "socks5h" => {
if let Some(user) = &proxy_cfg.username {
let _ = url.set_username(user);
}
if let Some(pass) = &proxy_cfg.password {
let _ = url.set_password(Some(pass));
}
match reqwest::Proxy::all(url.as_str()) {
Ok(p) => { client = client.proxy(p); }
Err(err) => error!("Failed to create SOCKS proxy {url}: {err}"),
}
}
"http" | "https" => {
match reqwest::Proxy::all(url.as_str()) {
Ok(p) => {
if let (Some(username), Some(password)) =
(&proxy_cfg.username, &proxy_cfg.password)
{
client = client.proxy(p.basic_auth(username, password));
} else {
client = client.proxy(p);
}
}
Err(err) => error!("Failed to create HTTP proxy {url}: {err}"),
}
}
_ => {
error!("Unsupported proxy scheme '{scheme}' in URL: {url}");
}
}
}
Err(e) => {
error!("Invalid proxy URL '{}': {e}", &proxy_cfg.url);
}
}
}
if let Some(rp_config) = config.reverse_proxy.as_ref() {
if rp_config
.disabled_header
.as_ref()
.is_some_and(|d| d.referer_header)
{
client = client.referer(false);
}
}
client
}
#[cfg(test)]
mod tests {
use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info};
#[test]
fn test_url_mask() {
// Replace with "***"
let query = "https://bubblegum.tv/live/username/password/2344";
let masked = sanitize_sensitive_info(query);
println!("{masked}");
}
#[test]
fn test_replace_ext() {
let tests = [
("http://hello.world.com", "http://hello.world.com"),
("http://hello.world.com/123", "http://hello.world.com/123.mp4"),
("http://hello.world.com/123.ts?hello=world", "http://hello.world.com/123.mp4?hello=world"),
("http://hello.world.com/123?hello=world", "http://hello.world.com/123.mp4?hello=world"),
("http://hello.world.com/123#hello=world", "http://hello.world.com/123.mp4#hello=world")
];
for (test, expect) in &tests {
assert_eq!(replace_url_extension(test, ".mp4"), *expect);
}
}
#[test]
fn tes_base_url() {
let url = "http://my.provider.com:8080/xmltv?username=hello";
let expected = "http://my.provider.com:8080";
assert_eq!(get_base_url_from_str(url).unwrap(), expected);
}
}