Files
tuliprox/backend/src/api/endpoints/v1_api.rs
T
2025-08-26 23:34:04 +02:00

457 lines
21 KiB
Rust

use crate::api::endpoints::api_playlist_utils::{get_playlist, get_playlist_for_target};
use crate::api::endpoints::download_api;
use crate::api::endpoints::user_api::user_api_register;
use crate::api::model::AppState;
use crate::auth::create_access_token;
use crate::auth::validator_admin;
use crate::model::{InputSource, ProxyUserCredentials};
use crate::model::{ConfigInput, ConfigInputOptions};
use crate::processing::processor::playlist;
use crate::repository::user_repository::store_api_user;
use crate::utils::ip_checker::get_ips;
use crate::{utils, VERSION};
use crate::api::api_utils::try_unwrap_body;
use axum::response::IntoResponse;
use log::error;
use serde_json::json;
use shared::error::TuliproxError;
use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, InputType, IpCheckDto, PlaylistRequest, PlaylistRequestType, ProxyUserCredentialsDto, StatusCheck, WebplayerUrlRequest};
use shared::utils::{concat_path_leading_slash, sanitize_sensitive_info};
use std::collections::{BTreeMap};
use std::sync::Arc;
use crate::utils::{prepare_sources_batch, prepare_users};
use crate::utils::request::download_text_content;
fn intern_save_config_api_proxy(backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str) -> Option<TuliproxError> {
match utils::save_api_proxy(file_path, backup_dir, api_proxy) {
Ok(()) => {}
Err(err) => {
error!("Failed to save api_proxy.yml {err}");
return Some(err);
}
}
None
}
fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
match utils::save_main_config(file_path, backup_dir, cfg) {
Ok(()) => {}
Err(err) => {
error!("Failed to save config.yml {err}");
return Some(err);
}
}
None
}
async fn save_config_api_proxy_user(
method: axum::http::Method,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(target_name): axum::extract::Path<String>,
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
) -> impl axum::response::IntoResponse + Send {
credential.prepare();
if let Err(err) = credential.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
let is_update = method == axum::http::Method::PUT;
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut target_found = false;
for target_user in &api_proxy.user {
if target_user.target == target_name {
target_found = true;
}
for user in &target_user.credentials {
if !is_update && user.username == credential.username {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)}))).into_response();
}
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate token {c}")}))
).into_response();
}
}
}
}
if is_update && !target_found {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("Target not found {target_name}")}))).into_response();
}
for target in &mut api_proxy.user {
if target.target == target_name {
if is_update {
let mut updated = false;
for user in &mut target.credentials {
if user.username == credential.username {
*user = ProxyUserCredentials::from(&credential);
updated = true;
break;
}
}
if !updated {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User {} not found in target {target_name}", credential.username)}))).into_response();
}
} else {
target.credentials.push(ProxyUserCredentials::from(&credential));
}
}
}
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
}
axum::http::StatusCode::OK.into_response()
}
async fn delete_config_api_proxy_user(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
) -> impl axum::response::IntoResponse + Send {
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut modified = false;
for target_user in &mut api_proxy.user {
if target_user.target == target_name {
let count = target_user.credentials.len();
target_user.credentials.retain(|user| user.username != username);
modified = count != target_user.credentials.len();
break;
}
}
if modified {
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(&*new_api_proxy), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
} else {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": format!("User not found {username} in target {target_name}")}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
async fn save_config_main(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(cfg): axum::extract::Json<ConfigDto>,
) -> impl axum::response::IntoResponse + Send {
if cfg.is_valid() {
let paths = app_state.app_config.paths.load();
let file_path = paths.config_file_path.as_str();
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
if let Some(err) = intern_save_config_main(file_path, backup_dir.as_ref(), &cfg) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
axum::http::StatusCode::OK.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
}
}
async fn save_config_api_proxy_config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(mut req_api_proxy): axum::extract::Json<Vec<ApiProxyServerInfoDto>>,
) -> impl axum::response::IntoResponse + Send {
for server_info in &mut req_api_proxy {
if !server_info.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
}
}
// TODO if hot reload is on, loaded twice
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
api_proxy.server = req_api_proxy.iter().map(Into::into).collect();
let new_api_proxy = Arc::new(api_proxy);
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = intern_save_config_api_proxy(backup_dir.as_ref(), &ApiProxyConfigDto::from(new_api_proxy.as_ref()), paths.api_proxy_file_path.as_str()) {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
async fn playlist_update(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(targets): axum::extract::Json<Vec<String>>,
) -> impl axum::response::IntoResponse + Send {
let user_targets = if targets.is_empty() { None } else { Some(targets) };
let process_targets = app_state.app_config.sources.load().validate_targets(user_targets.as_ref());
match process_targets {
Ok(valid_targets) => {
let app_config = Arc::clone(&app_state.app_config);
let event_manager = Arc::clone(&app_state.event_manager);
tokio::spawn(playlist::exec_processing(Arc::clone(&app_state.http_client.load()), app_config, Arc::new(valid_targets), Some(event_manager)));
axum::http::StatusCode::OK.into_response()
}
Err(err) => {
error!("Failed playlist update {}", sanitize_sensitive_info(err.to_string().as_str()));
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
}
}
}
fn create_config_input_for_m3u(url: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("m3u_req"),
input_type: InputType::M3u,
url: String::from(url),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> ConfigInput {
ConfigInput {
id: 0,
name: String::from("xc_req"),
input_type: InputType::Xtream,
url: String::from(host),
username: Some(String::from(username)),
password: Some(String::from(password)),
enabled: true,
options: Some(ConfigInputOptions {
xtream_skip_live: false,
xtream_skip_vod: false,
xtream_skip_series: false,
xtream_live_stream_without_extension: false,
xtream_live_stream_use_prefix: true,
}),
..Default::default()
}
}
async fn playlist_content(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_req): axum::extract::Json<PlaylistRequest>,
) -> impl IntoResponse + Send {
let config = app_state.app_config.config.load();
match playlist_req.rtype {
PlaylistRequestType::Input => {
if let Some(source_id) = playlist_req.source_id {
get_playlist(Arc::clone(&app_state.http_client.load()), app_state.app_config.get_input_by_id(source_id).as_deref(), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid input"}))).into_response()
}
}
PlaylistRequestType::Target => {
if let Some(source_id) = playlist_req.source_id {
get_playlist_for_target(app_state.app_config.get_target_by_id(source_id).as_deref(), &app_state.app_config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid target"}))).into_response()
}
}
PlaylistRequestType::Xtream => {
if let (Some(url), Some(username), Some(password)) = (playlist_req.url.as_ref(), playlist_req.username.as_ref(), playlist_req.password.as_ref()) {
let input = create_config_input_for_xtream(username, password, url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
PlaylistRequestType::M3U => {
if let Some(url) = playlist_req.url.as_ref() {
let input = create_config_input_for_m3u(url);
get_playlist(Arc::clone(&app_state.http_client.load()), Some(&input), &config).await.into_response()
} else {
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid url"}))).into_response()
}
}
}
}
async fn playlist_webplayer(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(playlist_item): axum::extract::Json<WebplayerUrlRequest>,
) -> impl axum::response::IntoResponse + Send {
let access_token = create_access_token(&app_state.app_config.access_token_secret, 30);
let config = app_state.app_config.config.load();
let server_name = config.web_ui.as_ref().and_then(|web_ui| web_ui.player_server.as_ref()).map_or("default", |server_name| server_name.as_str());
let server_info = app_state.app_config.get_server_info(server_name);
let base_url = server_info.get_base_url();
format!("{base_url}/token/{access_token}/{}/{}/{}", playlist_item.target_id, playlist_item.cluster.as_stream_type(), playlist_item.virtual_id).into_response()
}
async fn config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
let paths = app_state.app_config.paths.load();
match utils::read_app_config_dto(&paths, true, false) {
Ok(mut app_config) => {
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false) {
error!("Failed to prepare sources batch: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config) {
error!("Failed to prepare users: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
} else {
axum::response::Json(app_config).into_response()
}
}
Err(err) => {
error!("Failed to read config files: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
async fn config_batch_content(
axum::extract::Path(input_id): axum::extract::Path<u16>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl axum::response::IntoResponse + Send {
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
// The url is changed at this point, we need the raw url for the batch file
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
return match download_text_content(Arc::clone(&app_state.http_client.load()), &input_source, None).await {
Ok((content, _path)) => {
// Return CSV with explicit content-type
try_unwrap_body!(axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(content))
}
Err(err) => {
error!("Failed to read batch file: {err}");
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
}
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))).into_response()
}
async fn create_ipinfo_check(app_state: &Arc<AppState>) -> Option<(Option<String>, Option<String>)> {
let config = app_state.app_config.config.load();
if let Some(ipcheck) = config.ipcheck.as_ref() {
if let Ok(check) = get_ips(&app_state.http_client.load(), ipcheck).await {
return Some(check);
}
}
None
}
pub async fn create_status_check(app_state: &Arc<AppState>) -> StatusCheck {
let cache = match app_state.cache.load().as_ref().as_ref() {
None => None,
Some(lock) => {
Some(lock.lock().await.get_size_text())
}
};
let (active_users, active_user_connections) = {
let active_user = &app_state.active_users;
(active_user.active_users().await, active_user.active_connections().await)
};
let active_provider_connections = app_state.active_provider.active_connections().await.map(|c| c.into_iter().collect::<BTreeMap<_, _>>());
StatusCheck {
status: "ok".to_string(),
version: VERSION.to_string(),
build_time: crate::api::api_utils::get_build_time(),
server_time: crate::api::api_utils::get_server_time(),
memory: crate::api::api_utils::get_memory_usage(),
active_users,
active_user_connections,
active_provider_connections,
cache,
}
}
async fn status(axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> axum::response::Response {
let status = create_status_check(&app_state).await;
match serde_json::to_string_pretty(&status) {
Ok(pretty_json) => try_unwrap_body!(axum::response::Response::builder().status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()).body(pretty_json)),
Err(_) => axum::Json(status).into_response(),
}
}
async fn ipinfo(axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> axum::response::Response {
if let Some((ipv4, ipv6)) = create_ipinfo_check(&app_state).await {
let ipcheck = IpCheckDto {
ipv4,
ipv6,
};
return match serde_json::to_string(&ipcheck) {
Ok(json) => try_unwrap_body!(axum::response::Response::builder().status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()).body(json)),
Err(_) => axum::Json(ipcheck).into_response(),
};
}
axum::http::StatusCode::BAD_REQUEST.into_response()
}
pub fn v1_api_register(web_auth_enabled: bool, app_state: Arc<AppState>, web_ui_path: &str) -> axum::Router<Arc<AppState>> {
let mut router = axum::Router::new();
router = router
.route("/status", axum::routing::get(status))
.route("/config", axum::routing::get(config))
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
.route("/config/main", axum::routing::post(save_config_main))
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
.route("/config/apiproxy", axum::routing::post(save_config_api_proxy_config))
.route("/playlist/webplayer", axum::routing::post(playlist_webplayer))
.route("/playlist/update", axum::routing::post(playlist_update))
.route("/playlist", axum::routing::post(playlist_content))
.route("/file/download", axum::routing::post(download_api::queue_download_file))
.route("/file/download/info", axum::routing::get(download_api::download_file_info));
let config = app_state.app_config.config.load();
if config.ipcheck.is_some() {
router = router.route("/ipinfo", axum::routing::get(ipinfo));
}
if web_auth_enabled {
router = router.route_layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_admin));
}
let mut base_router = axum::Router::new();
if config.web_ui.as_ref().is_none_or(|c| c.user_ui_enabled) {
base_router = base_router.merge(user_api_register(app_state));
}
base_router.nest(&concat_path_leading_slash(web_ui_path, "api/v1"), router)
}