2026-02-26 20:36:01 +01:00
|
|
|
|
use crate::{
|
|
|
|
|
|
api::{
|
|
|
|
|
|
api_utils::{internal_server_error, try_unwrap_body},
|
|
|
|
|
|
config_file::ConfigFile,
|
|
|
|
|
|
model::AppState,
|
|
|
|
|
|
},
|
|
|
|
|
|
model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource},
|
|
|
|
|
|
utils,
|
|
|
|
|
|
utils::{
|
|
|
|
|
|
persist_messaging_templates, prepare_sources_batch, prepare_users, read_api_proxy_file,
|
|
|
|
|
|
request::download_text_content,
|
2026-03-15 23:32:09 +01:00
|
|
|
|
xtream::{get_xtream_stream_url_base, xtream_login},
|
2026-02-26 20:36:01 +01:00
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-03-03 19:35:31 +01:00
|
|
|
|
use axum::{
|
|
|
|
|
|
http::{header::IF_MATCH, HeaderMap, HeaderName, HeaderValue, StatusCode},
|
|
|
|
|
|
response::IntoResponse,
|
|
|
|
|
|
Router,
|
|
|
|
|
|
};
|
2026-03-06 17:29:42 +01:00
|
|
|
|
use log::error;
|
2025-09-26 14:13:30 +02:00
|
|
|
|
use serde_json::json;
|
2026-02-26 20:36:01 +01:00
|
|
|
|
use shared::{
|
|
|
|
|
|
error::TuliproxError,
|
2026-03-15 23:32:09 +01:00
|
|
|
|
model::{ApiProxyConfigDto, ConfigDto, SourcesConfigDto, XtreamLoginRequest},
|
2026-03-03 19:35:31 +01:00
|
|
|
|
utils::{
|
|
|
|
|
|
HEADER_CONFIG_API_PROXY_REVISION, HEADER_CONFIG_MAIN_REVISION, HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH,
|
|
|
|
|
|
},
|
2026-02-26 20:36:01 +01:00
|
|
|
|
};
|
2026-03-15 23:32:09 +01:00
|
|
|
|
use shared::model::InputFetchMethod;
|
|
|
|
|
|
use std::collections::HashMap;
|
2026-03-03 19:35:31 +01:00
|
|
|
|
use std::path::Path;
|
2025-09-26 14:13:30 +02:00
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
2026-03-03 19:35:31 +01:00
|
|
|
|
fn file_revision_from_bytes(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() }
|
|
|
|
|
|
|
|
|
|
|
|
async fn read_file_revision(path: &str) -> Result<String, std::io::Error> {
|
|
|
|
|
|
match tokio::fs::read(path).await {
|
|
|
|
|
|
Ok(bytes) => Ok(file_revision_from_bytes(&bytes)),
|
|
|
|
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("missing".to_string()),
|
|
|
|
|
|
Err(err) => Err(err),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn response_with_revision_header(
|
|
|
|
|
|
mut response: axum::response::Response,
|
|
|
|
|
|
revision_header: &'static str,
|
|
|
|
|
|
revision: &str,
|
|
|
|
|
|
) -> axum::response::Response {
|
|
|
|
|
|
let Ok(header_name) = HeaderName::try_from(revision_header) else {
|
|
|
|
|
|
return response;
|
|
|
|
|
|
};
|
|
|
|
|
|
let Ok(header_value) = HeaderValue::from_str(revision) else {
|
|
|
|
|
|
return response;
|
|
|
|
|
|
};
|
|
|
|
|
|
response.headers_mut().insert(header_name, header_value);
|
|
|
|
|
|
response
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn require_matching_revision(
|
|
|
|
|
|
headers: &HeaderMap,
|
|
|
|
|
|
current_revision: &str,
|
|
|
|
|
|
revision_header: &'static str,
|
|
|
|
|
|
file_label: &str,
|
|
|
|
|
|
) -> Option<axum::response::Response> {
|
|
|
|
|
|
let if_match = headers.get(IF_MATCH).and_then(|value| value.to_str().ok()).map(str::trim);
|
|
|
|
|
|
let Some(if_match) = if_match.filter(|value| !value.is_empty()) else {
|
|
|
|
|
|
let response = (
|
|
|
|
|
|
StatusCode::PRECONDITION_REQUIRED,
|
|
|
|
|
|
axum::Json(json!({
|
|
|
|
|
|
"error": format!("Missing required '{}' header for {file_label}", HEADER_IF_MATCH)
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
return Some(response_with_revision_header(response, revision_header, current_revision));
|
|
|
|
|
|
};
|
|
|
|
|
|
if if_match != current_revision {
|
|
|
|
|
|
let response = (
|
|
|
|
|
|
StatusCode::CONFLICT,
|
|
|
|
|
|
axum::Json(json!({
|
|
|
|
|
|
"error": format!("{file_label} changed on server. Reload configuration and retry save."),
|
|
|
|
|
|
})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response();
|
|
|
|
|
|
return Some(response_with_revision_header(response, revision_header, current_revision));
|
|
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 20:36:01 +01:00
|
|
|
|
pub(in crate::api::endpoints) async fn intern_save_config_api_proxy(
|
|
|
|
|
|
backup_dir: &str,
|
|
|
|
|
|
api_proxy: &ApiProxyConfigDto,
|
|
|
|
|
|
file_path: &str,
|
|
|
|
|
|
) -> Option<TuliproxError> {
|
2025-11-19 15:13:33 -06:00
|
|
|
|
match utils::save_api_proxy(file_path, backup_dir, api_proxy).await {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
Ok(()) => {}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to save api_proxy.yml {err}");
|
|
|
|
|
|
return Some(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 15:13:33 -06:00
|
|
|
|
async 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).await {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
Ok(()) => {}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to save config.yml {err}");
|
|
|
|
|
|
return Some(err);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn save_config_main(
|
|
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
2026-03-03 19:35:31 +01:00
|
|
|
|
headers: HeaderMap,
|
2026-01-25 12:41:18 +01:00
|
|
|
|
axum::extract::Json(mut cfg): axum::extract::Json<ConfigDto>,
|
2025-09-26 14:13:30 +02:00
|
|
|
|
) -> impl axum::response::IntoResponse + Send {
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let (file_path, backup_dir) = {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
|
|
|
|
|
let config = app_state.app_config.config.load();
|
|
|
|
|
|
(paths.config_file_path.clone(), config.get_backup_dir().to_string())
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let _lock = app_state.app_config.file_locks.write_lock(Path::new(&file_path)).await;
|
|
|
|
|
|
let current_revision = match read_file_revision(&file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for config.yml '{file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(response) =
|
|
|
|
|
|
require_matching_revision(&headers, ¤t_revision, HEADER_CONFIG_MAIN_REVISION, "config.yml")
|
|
|
|
|
|
{
|
|
|
|
|
|
return response;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 11:46:21 +01:00
|
|
|
|
if let Err(err) = cfg.prepare(false) {
|
|
|
|
|
|
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
if !cfg.is_valid() {
|
|
|
|
|
|
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response()
|
|
|
|
|
|
} else if let Err(err) = validate_library_paths_from_dto(&cfg) {
|
|
|
|
|
|
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
|
|
|
|
|
|
} else {
|
2026-01-25 12:41:18 +01:00
|
|
|
|
if let Err(err) = persist_messaging_templates(&app_state, &mut cfg).await {
|
|
|
|
|
|
error!("Failed to persist messaging templates: {err}");
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
|
|
|
|
|
.into_response();
|
2026-01-25 12:41:18 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 19:35:31 +01:00
|
|
|
|
if let Some(err) = intern_save_config_main(&file_path, &backup_dir, &cfg).await {
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
|
|
|
|
|
.into_response();
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let updated_revision = match read_file_revision(&file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read updated revision for config.yml '{file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_MAIN_REVISION, &updated_revision)
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 19:31:07 +01:00
|
|
|
|
async fn save_config_sources(
|
|
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
2026-03-03 19:35:31 +01:00
|
|
|
|
headers: HeaderMap,
|
2026-03-06 17:29:42 +01:00
|
|
|
|
axum::extract::Json(sources): axum::extract::Json<SourcesConfigDto>,
|
2025-12-17 19:31:07 +01:00
|
|
|
|
) -> impl axum::response::IntoResponse + Send {
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let sources_file_path = {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
|
|
|
|
|
paths.sources_file_path.clone()
|
|
|
|
|
|
};
|
|
|
|
|
|
let _lock = app_state.app_config.file_locks.write_lock(Path::new(&sources_file_path)).await;
|
|
|
|
|
|
|
|
|
|
|
|
let current_revision = match read_file_revision(&sources_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for source.yml '{sources_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(response) =
|
|
|
|
|
|
require_matching_revision(&headers, ¤t_revision, HEADER_CONFIG_SOURCES_REVISION, "source.yml")
|
|
|
|
|
|
{
|
|
|
|
|
|
return response;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 12:24:05 +01:00
|
|
|
|
let templates_to_persist = match utils::validate_source_config_for_persist(&app_state, &sources).await {
|
2026-01-02 16:29:05 +01:00
|
|
|
|
Ok(value) => value,
|
2025-12-17 19:31:07 +01:00
|
|
|
|
Err(err) => {
|
2026-02-23 17:00:11 +01:00
|
|
|
|
error!("Failed to validate source.yml {err}");
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()})))
|
|
|
|
|
|
.into_response();
|
2025-12-17 19:31:07 +01:00
|
|
|
|
}
|
2026-01-02 16:29:05 +01:00
|
|
|
|
};
|
2025-12-17 19:31:07 +01:00
|
|
|
|
|
2026-02-23 17:00:11 +01:00
|
|
|
|
if let Some(template_definition) = templates_to_persist.as_ref() {
|
|
|
|
|
|
if let Err(err) = utils::persist_templates_config(&app_state, template_definition).await {
|
|
|
|
|
|
error!("Failed to save template config {err}");
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
2026-02-23 17:00:11 +01:00
|
|
|
|
.into_response();
|
2025-12-17 19:31:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-23 17:00:11 +01:00
|
|
|
|
|
|
|
|
|
|
match utils::persist_source_config(&app_state, None, sources).await {
|
|
|
|
|
|
Ok(_) => {}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to persist source.yml {err}");
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
2026-02-23 17:00:11 +01:00
|
|
|
|
.into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Reload from disk so runtime always uses fully prepared sources/mappings/templates.
|
|
|
|
|
|
if let Err(err) = ConfigFile::load_sources(&app_state).await {
|
|
|
|
|
|
error!("Failed to reload prepared sources after save {err}");
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
2026-02-23 17:00:11 +01:00
|
|
|
|
.into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
app_state.active_provider.update_config(&app_state.app_config).await;
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let updated_revision = match read_file_revision(&sources_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read updated revision for source.yml '{sources_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_SOURCES_REVISION, &updated_revision)
|
2025-12-17 19:31:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 15:29:32 +01:00
|
|
|
|
async fn get_config_api_proxy_config(
|
2026-02-26 20:36:01 +01:00
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
2026-01-31 15:29:32 +01:00
|
|
|
|
) -> impl IntoResponse + Send {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let api_proxy_file_path = paths.api_proxy_file_path.clone();
|
|
|
|
|
|
let revision = match read_file_revision(&api_proxy_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
match read_api_proxy_file(api_proxy_file_path.as_str(), true) {
|
2026-01-31 15:29:32 +01:00
|
|
|
|
Ok(Some(mut api_proxy_dto)) => {
|
|
|
|
|
|
api_proxy_dto.user = vec![];
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let response = axum::response::Json(api_proxy_dto).into_response();
|
|
|
|
|
|
return response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision);
|
2026-01-31 15:29:32 +01:00
|
|
|
|
}
|
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
|
error!("Failed to read api proxy config");
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read api proxy config: {err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
internal_server_error!()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 14:13:30 +02:00
|
|
|
|
async fn save_config_api_proxy_config(
|
|
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
2026-03-03 19:35:31 +01:00
|
|
|
|
headers: HeaderMap,
|
2026-01-31 15:29:32 +01:00
|
|
|
|
axum::extract::Json(mut req_api_proxy): axum::extract::Json<ApiProxyConfigDto>,
|
|
|
|
|
|
) -> impl IntoResponse + Send {
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let (api_proxy_file_path, backup_dir) = {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
|
|
|
|
|
let config = app_state.app_config.config.load();
|
|
|
|
|
|
(paths.api_proxy_file_path.clone(), config.get_backup_dir().to_string())
|
|
|
|
|
|
};
|
|
|
|
|
|
let _lock = app_state.app_config.file_locks.write_lock(Path::new(&api_proxy_file_path)).await;
|
|
|
|
|
|
|
|
|
|
|
|
let current_revision = match read_file_revision(&api_proxy_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(response) = require_matching_revision(
|
|
|
|
|
|
&headers,
|
|
|
|
|
|
¤t_revision,
|
|
|
|
|
|
HEADER_CONFIG_API_PROXY_REVISION,
|
|
|
|
|
|
"api-proxy.yml",
|
|
|
|
|
|
) {
|
|
|
|
|
|
return response;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 15:29:32 +01:00
|
|
|
|
for server_info in &mut req_api_proxy.server {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
if !server_info.validate() {
|
2026-02-26 20:36:01 +01:00
|
|
|
|
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"})))
|
|
|
|
|
|
.into_response();
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-29 15:39:27 +02:00
|
|
|
|
// TODO if hot reload is on, it is loaded twice, avoid this
|
2025-09-30 10:54:30 +02:00
|
|
|
|
// Build the updated config without mutating global state yet
|
2026-02-26 20:36:01 +01:00
|
|
|
|
let base = app_state.app_config.api_proxy.load().as_deref().cloned().unwrap_or_default();
|
2025-09-30 10:54:30 +02:00
|
|
|
|
let updated_api_proxy = ApiProxyConfig {
|
2026-01-31 15:29:32 +01:00
|
|
|
|
use_user_db: req_api_proxy.use_user_db,
|
|
|
|
|
|
server: req_api_proxy.server.iter().map(Into::into).collect(),
|
2025-09-30 10:54:30 +02:00
|
|
|
|
..base
|
2025-09-29 15:39:27 +02:00
|
|
|
|
};
|
2025-09-30 10:54:30 +02:00
|
|
|
|
|
2026-02-26 20:36:01 +01:00
|
|
|
|
if let Some(err) = intern_save_config_api_proxy(
|
2026-03-03 19:35:31 +01:00
|
|
|
|
&backup_dir,
|
2026-02-26 20:36:01 +01:00
|
|
|
|
&ApiProxyConfigDto::from(&updated_api_proxy),
|
2026-03-03 19:35:31 +01:00
|
|
|
|
&api_proxy_file_path,
|
2026-02-26 20:36:01 +01:00
|
|
|
|
)
|
|
|
|
|
|
.await
|
|
|
|
|
|
{
|
|
|
|
|
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
|
|
|
|
|
.into_response();
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
2025-09-30 10:54:30 +02:00
|
|
|
|
// Persist succeeded — now update in‑memory state
|
2026-02-26 20:36:01 +01:00
|
|
|
|
app_state.app_config.api_proxy.store(Some(Arc::new(updated_api_proxy)));
|
2025-09-30 10:54:30 +02:00
|
|
|
|
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let updated_revision = match read_file_revision(&api_proxy_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read updated revision for api-proxy.yml '{api_proxy_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_API_PROXY_REVISION, &updated_revision)
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 20:36:01 +01:00
|
|
|
|
async fn config(axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> impl IntoResponse + Send {
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let (config_file_path, sources_file_path, api_proxy_file_path) = {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
|
|
|
|
|
(
|
|
|
|
|
|
paths.config_file_path.clone(),
|
|
|
|
|
|
paths.sources_file_path.clone(),
|
|
|
|
|
|
paths.api_proxy_file_path.clone(),
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let main_revision = match read_file_revision(&config_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for config.yml '{config_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let sources_revision = match read_file_revision(&sources_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for source.yml '{sources_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let api_proxy_revision = match read_file_revision(&api_proxy_file_path).await {
|
|
|
|
|
|
Ok(revision) => revision,
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}");
|
|
|
|
|
|
return internal_server_error!();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let read_result = {
|
|
|
|
|
|
let paths = app_state.app_config.paths.load();
|
|
|
|
|
|
utils::read_app_config_dto(&paths, true, false).await
|
|
|
|
|
|
};
|
|
|
|
|
|
match read_result {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
Ok(mut app_config) => {
|
2025-11-14 16:32:18 +01:00
|
|
|
|
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
error!("Failed to prepare sources batch: {err}");
|
2026-01-20 10:09:58 +01:00
|
|
|
|
internal_server_error!()
|
2025-11-14 09:49:48 +01:00
|
|
|
|
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
error!("Failed to prepare users: {err}");
|
2026-01-20 10:09:58 +01:00
|
|
|
|
internal_server_error!()
|
2025-09-26 14:13:30 +02:00
|
|
|
|
} else {
|
2026-03-03 19:35:31 +01:00
|
|
|
|
let response = axum::response::Json(app_config).into_response();
|
|
|
|
|
|
let response = response_with_revision_header(response, HEADER_CONFIG_MAIN_REVISION, &main_revision);
|
|
|
|
|
|
let response = response_with_revision_header(response, HEADER_CONFIG_SOURCES_REVISION, &sources_revision);
|
|
|
|
|
|
response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &api_proxy_revision)
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to read config files: {err}");
|
2026-01-20 10:09:58 +01:00
|
|
|
|
internal_server_error!()
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn config_batch_content(
|
|
|
|
|
|
axum::extract::Path(input_id): axum::extract::Path<u16>,
|
|
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
2026-01-31 15:29:32 +01:00
|
|
|
|
) -> impl IntoResponse + Send {
|
2025-09-26 14:13:30 +02:00
|
|
|
|
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());
|
2026-01-20 18:51:46 +01:00
|
|
|
|
return match download_text_content(
|
2026-01-22 13:30:59 +01:00
|
|
|
|
&app_state.app_config,
|
2026-01-20 18:51:46 +01:00
|
|
|
|
&app_state.http_client.load(),
|
|
|
|
|
|
&input_source,
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
false,
|
|
|
|
|
|
)
|
2026-02-26 20:36:01 +01:00
|
|
|
|
.await
|
2026-01-20 18:51:46 +01:00
|
|
|
|
{
|
2025-09-26 14:13:30 +02:00
|
|
|
|
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}");
|
2026-01-20 10:09:58 +01:00
|
|
|
|
internal_server_error!()
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-26 20:36:01 +01:00
|
|
|
|
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"})))
|
|
|
|
|
|
.into_response()
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 23:32:09 +01:00
|
|
|
|
async fn get_xtream_login_info(
|
|
|
|
|
|
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
|
|
|
|
|
axum::extract::Json(request): axum::extract::Json<XtreamLoginRequest>,
|
|
|
|
|
|
) -> impl IntoResponse + Send {
|
|
|
|
|
|
if request.url.trim().is_empty() {
|
|
|
|
|
|
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "URL is required"}))).into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
if request.username.trim().is_empty() {
|
|
|
|
|
|
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Username is required"}))).into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
if request.password.trim().is_empty() {
|
|
|
|
|
|
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Password is required"}))).into_response();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let base_url = get_xtream_stream_url_base(&request.url, &request.username, &request.password);
|
|
|
|
|
|
let input_source = InputSource {
|
|
|
|
|
|
name: "xtream_login".into(),
|
|
|
|
|
|
url: base_url,
|
|
|
|
|
|
provider: None,
|
|
|
|
|
|
username: Some(request.username.clone()),
|
|
|
|
|
|
password: Some(request.password.clone()),
|
|
|
|
|
|
method: InputFetchMethod::GET,
|
|
|
|
|
|
headers: HashMap::new(),
|
|
|
|
|
|
};
|
|
|
|
|
|
let http_client = app_state.http_client.load();
|
|
|
|
|
|
match xtream_login(&app_state.app_config, &http_client, &input_source, &request.username).await {
|
|
|
|
|
|
Ok(login_info) => axum::Json(login_info.unwrap_or_default()).into_response(),
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
error!("Failed to get xtream login info: {err}");
|
|
|
|
|
|
(
|
|
|
|
|
|
StatusCode::BAD_GATEWAY,
|
|
|
|
|
|
axum::Json(json!({"error": "Failed to get Xtream login info"})),
|
|
|
|
|
|
)
|
|
|
|
|
|
.into_response()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-26 14:13:30 +02:00
|
|
|
|
pub fn v1_api_config_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
|
|
|
|
|
|
router
|
|
|
|
|
|
.route("/config", axum::routing::get(config))
|
|
|
|
|
|
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
|
2026-03-15 23:32:09 +01:00
|
|
|
|
.route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info))
|
2025-09-26 14:13:30 +02:00
|
|
|
|
.route("/config/main", axum::routing::post(save_config_main))
|
2025-12-17 19:31:07 +01:00
|
|
|
|
.route("/config/sources", axum::routing::post(save_config_sources))
|
2026-01-31 15:29:32 +01:00
|
|
|
|
.route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config))
|
|
|
|
|
|
.route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config))
|
2025-09-26 14:13:30 +02:00
|
|
|
|
}
|
2026-03-02 12:24:05 +01:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
2026-03-06 17:29:42 +01:00
|
|
|
|
use super::require_matching_revision;
|
2026-03-03 19:35:31 +01:00
|
|
|
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
|
|
|
|
|
use shared::utils::{HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH};
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn require_matching_revision_rejects_missing_if_match_header() {
|
|
|
|
|
|
let headers = HeaderMap::new();
|
|
|
|
|
|
let response = require_matching_revision(
|
|
|
|
|
|
&headers,
|
|
|
|
|
|
"rev-a",
|
|
|
|
|
|
HEADER_CONFIG_SOURCES_REVISION,
|
|
|
|
|
|
"source.yml",
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("missing if-match header must fail");
|
|
|
|
|
|
assert_eq!(response.status(), StatusCode::PRECONDITION_REQUIRED);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn require_matching_revision_accepts_exact_match() {
|
|
|
|
|
|
let mut headers = HeaderMap::new();
|
|
|
|
|
|
headers.insert(
|
|
|
|
|
|
HEADER_IF_MATCH,
|
|
|
|
|
|
HeaderValue::from_str("rev-a").expect("header value should be valid"),
|
|
|
|
|
|
);
|
|
|
|
|
|
let result = require_matching_revision(
|
|
|
|
|
|
&headers,
|
|
|
|
|
|
"rev-a",
|
|
|
|
|
|
HEADER_CONFIG_SOURCES_REVISION,
|
|
|
|
|
|
"source.yml",
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(result.is_none(), "exact revision match should be accepted");
|
|
|
|
|
|
}
|
2026-03-02 12:24:05 +01:00
|
|
|
|
}
|