use crate::{ api::{ api_utils::{internal_server_error, try_unwrap_body}, auth_middleware::permission_layer, config_file::ConfigFile, model::AppState, }, auth::{verify_token, AuthBearer}, config_loader::{ persist_messaging_templates, plans_file_path, prepare_sources_batch, prepare_users, read_api_proxy_file_with_templates, read_plans_file, save_plans, }, iptv::xtream::{get_xtream_stream_url_base, xtream_login}, model::{ recording_reload_outcome, validate_library_paths_from_dto, ApiProxyConfig, InputSource, RecordingReloadOutcome, UserPlan, }, utils::request::download_text_content, }; use axum::{ http::{header::IF_MATCH, HeaderMap, HeaderName, HeaderValue, StatusCode}, response::IntoResponse, Router, }; use log::error; use serde_json::json; use shared::{ error::TuliproxError, model::{ permission::{Permission, PermissionSet}, ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, ConfigTargetDto, InputFetchMethod, PlansConfigDto, SourcesConfigDto, XtreamLoginRequest, }, utils::{ parse_provider_scheme_url_parts, HEADER_CONFIG_API_PROXY_REVISION, HEADER_CONFIG_MAIN_REVISION, HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH, PROVIDER_SCHEME_PREFIX, }, }; use std::{collections::HashMap, path::Path, sync::Arc}; fn file_revision_from_bytes(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() } /// Target-bouquet file updates derived from a sources configuration change. struct BouquetMutationBatch { deletions: Vec, } /// Collects bouquet deletions required by the new configuration. /// Target IDs are runtime-local and therefore cannot establish persisted identity. fn build_bouquet_mutation_batch(app_state: &Arc, sources: &SourcesConfigDto) -> BouquetMutationBatch { let old_sources = app_state.app_config.sources.load(); let mut old_target_names = std::collections::HashSet::new(); for source in &old_sources.sources { for target in &source.targets { old_target_names.insert(target.name.clone()); } } let mut new_target_names = std::collections::HashSet::new(); for source_dto in &sources.sources { for target_dto in &source_dto.targets { new_target_names.insert(target_dto.name.trim().to_string()); } } let deletions: Vec = old_target_names.difference(&new_target_names).cloned().collect(); BouquetMutationBatch { deletions } } /// Applies bouquet mutations under their shared lock. async fn apply_bouquet_mutation_batch( app_state: &Arc, batch: &BouquetMutationBatch, ) -> Result<(), TuliproxError> { tuliprox_repository::apply_target_bouquet_mutations_locked(&app_state.app_config, &[], &batch.deletions).await } async fn restore_sources_file(path: &Path, content: Option<&[u8]>) -> Result<(), std::io::Error> { if let Some(content) = content { return tuliprox_core::utils::write_file_atomic(path, content).await; } match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err), } } async fn read_optional_file(path: &Path) -> Result>, std::io::Error> { match tokio::fs::read(path).await { Ok(content) => Ok(Some(content)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err), } } async fn snapshot_template_file( app_state: &Arc, should_persist: bool, ) -> Result>)>, std::io::Error> { if !should_persist { return Ok(None); } let path = { let config = app_state.app_config.config.load(); let paths = app_state.app_config.paths.load(); tuliprox_core::utils::resolve_template_persist_file_path( paths.template_file_path.as_deref().or(config.template_path.as_deref()), &paths.config_path, ) }; let content = read_optional_file(Path::new(&path)).await?; Ok(Some((path, content))) } async fn roll_back_sources_after_update_failure( app_state: &Arc, sources_file_path: &Path, original_sources_file: Option<&[u8]>, template_snapshot: Option<(&Path, Option<&[u8]>)>, update_err: &TuliproxError, ) -> axum::response::Response { error!("Failed to complete sources configuration update: {update_err}"); if let Err(rollback_err) = restore_sources_file(sources_file_path, original_sources_file).await { error!("Failed to roll back source.yml after target bouquet mutation failure: {rollback_err}"); return ( StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({ "error": format!( "Updating sources failed: {update_err}; rolling back source.yml also failed: {rollback_err}" ) })), ) .into_response(); } if let Some((template_file_path, original_template_file)) = template_snapshot { if let Err(rollback_err) = restore_sources_file(template_file_path, original_template_file).await { error!("Failed to roll back template config after target bouquet mutation failure: {rollback_err}"); return ( StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({ "error": format!( "Updating sources failed: {update_err}; rolling back the template config also failed: {rollback_err}" ) })), ) .into_response(); } } if let Err(reload_err) = ConfigFile::load_sources(app_state).await { error!("source.yml was rolled back, but reloading the previous runtime configuration failed: {reload_err}"); return ( StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({ "error": format!( "Updating sources failed: {update_err}; source.yml was restored but runtime reload failed: {reload_err}" ) })), ) .into_response(); } ( StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({ "error": format!("Updating sources failed and persisted configuration was rolled back: {update_err}") })), ) .into_response() } async fn read_file_revision(path: &str) -> Result { 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 { 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 } fn has_any_permission(permissions: PermissionSet, required: &[Permission]) -> bool { required.iter().any(|permission| permissions.contains(*permission)) } /// The permissions this token may act with *right now*. /// /// The claim alone is a snapshot from mint time, so a revoked group permission /// kept filtering config in until the token expired. Intersecting with the live /// grant makes a revocation immediate without ever widening a token beyond what /// it was issued with. fn decode_permissions(app_state: &AppState, token: &str) -> Option { let config = app_state.app_config.config.load(); let web_auth = config.web_ui.as_ref()?.auth.as_ref()?; let claims = verify_token(token, web_auth.secret.as_bytes(), &web_auth.issuer)?.claims; Some( web_auth .resolve_permissions_if_known(&claims.username) .map_or(claims.permissions, |live| claims.permissions & live), ) } /// Reduces a server entry to the name a user record may reference. /// /// User records reference a server by name only, so this is all a caller /// handling users needs. Protocol, host, port, path and timezone describe /// internal infrastructure and must not leave the server without `ConfigRead`. fn sanitize_server_names_only(server: &ApiProxyServerInfoDto) -> ApiProxyServerInfoDto { ApiProxyServerInfoDto { name: server.name.clone(), ..Default::default() } } fn filter_api_proxy_by_permissions(api_proxy: &mut ApiProxyConfigDto, permissions: PermissionSet) { if !permissions.contains(Permission::ConfigRead) { if permissions.contains(Permission::UserRead) || permissions.contains(Permission::UserWrite) { api_proxy.server = api_proxy.server.iter().map(sanitize_server_names_only).collect(); } else { api_proxy.server.clear(); } api_proxy.use_user_db = false; api_proxy.auth_error_status = ApiProxyConfigDto::default().auth_error_status; } if !permissions.contains(Permission::UserRead) { api_proxy.user.clear(); } } /// Reduces a target to the identity a user record may reference. /// /// User records reference a target by name; the id is kept so the UI can key /// rows. Every other field (filter, output, mappings, ...) is target /// configuration and must not leave the server without `SourceRead`. fn sanitize_target_identity_only(target: &ConfigTargetDto) -> ConfigTargetDto { ConfigTargetDto { id: target.id, name: target.name.clone(), ..Default::default() } } fn filter_app_config_by_permissions(app_config: &mut shared::model::AppConfigDto, permissions: Option) { // Unconditional: `config.yml` goes out in full to anyone with // `ConfigRead`, which included the Telegram bot token, the Pushover // credentials and any `Authorization` header on the REST channel. // `save_config_main` puts a returned mask back, so the round-trip is // lossless. if let Some(messaging) = app_config.config.messaging.as_mut() { messaging.redact_secrets(); } if let Some(permissions) = permissions { if !permissions.contains(Permission::ConfigRead) { app_config.config = ConfigDto::default(); } if !permissions.contains(Permission::SourceRead) { app_config.mappings = None; app_config.templates = None; let can_read_targets = permissions.contains(Permission::UserRead) || permissions.contains(Permission::UserWrite) || permissions.contains(Permission::PlaylistRead) || permissions.contains(Permission::PlaylistWrite); if can_read_targets { app_config.sources.inputs.clear(); app_config.sources.provider = None; app_config.sources.templates = None; for source in &mut app_config.sources.sources { source.inputs.clear(); source.targets = source.targets.iter().map(sanitize_target_identity_only).collect(); } } else { app_config.sources = SourcesConfigDto::default(); } } if let Some(api_proxy) = app_config.api_proxy.as_mut() { filter_api_proxy_by_permissions(api_proxy, permissions); } } } pub(in crate::api::endpoints) async fn intern_save_config_api_proxy( backup_dir: &str, api_proxy: &ApiProxyConfigDto, file_path: &str, ) -> Option { match crate::config_loader::save_api_proxy(file_path, backup_dir, api_proxy).await { Ok(()) => {} Err(err) => { error!("Failed to save api-proxy.yml {err}"); return Some(err); } } None } async fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option { match crate::config_loader::save_main_config(file_path, backup_dir, cfg).await { Ok(()) => {} Err(err) => { error!("Failed to save config.yml {err}"); return Some(err); } } None } /// Decide what an incoming config does to the recording block. /// /// Counting the queue is the root check: an entry only exists while a /// recording it points at is expected to be found. async fn recording_reload_for(app_state: &Arc, incoming: &ConfigDto) -> RecordingReloadOutcome { let current = app_state.app_config.config.load(); let Some(current_recording) = current.recording() else { return RecordingReloadOutcome::Unchanged; }; let incoming_recording = incoming.video.as_ref().and_then(|video| video.recording.as_ref()); let Some(incoming_recording) = incoming_recording else { return RecordingReloadOutcome::Unchanged; }; let mut incoming_recording = tuliprox_core::model::RecordingConfig::from(incoming_recording); // The running config holds the prepared (home-resolved) directory, the // incoming DTO the raw configured one; compare like with like. incoming_recording.directory = tuliprox_core::model::resolve_recording_directory( &incoming_recording.directory, &app_state.app_config.paths.load().home_path, ); let existing = app_state.recordings.committed_snapshot().await.1.len(); recording_reload_outcome(current_recording, &incoming_recording, existing) } async fn save_config_main( axum::extract::State(app_state): axum::extract::State>, headers: HeaderMap, axum::extract::Json(mut cfg): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { 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; } // A client that echoes a redacted secret back means "keep what is // stored". Without this the round-trip would write the mask over the // real token and silently break the channel. if let Some(incoming) = cfg.messaging.as_mut() { let stored = app_state.app_config.config.load(); if let Some(current) = stored.messaging.as_ref() { incoming.restore_redacted_secrets(&shared::model::MessagingConfigDto::from(current)); } } 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() { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response(); } let reload = recording_reload_for(&app_state, &cfg).await; if let RecordingReloadOutcome::Refused(refusal) = &reload { return (axum::http::StatusCode::CONFLICT, axum::Json(json!({"error": refusal.to_string()}))).into_response(); } 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 { if let Err(err) = persist_messaging_templates(&app_state.app_config, &mut cfg).await { error!("Failed to persist messaging templates: {err}"); return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } if let Some(err) = intern_save_config_main(&file_path, &backup_dir, &cfg).await { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } 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!(); } }; // A setting that will not take effect until a restart has to say so, // or the operator reads the 200 as "applied" and waits forever. let pending: Vec = match &reload { RecordingReloadOutcome::Applied { restart_required, .. } => { restart_required.iter().map(|field| format!("{field:?}")).collect() } _ => Vec::new(), }; let body = if pending.is_empty() { StatusCode::OK.into_response() } else { (StatusCode::OK, axum::Json(json!({"restart_required": pending}))).into_response() }; response_with_revision_header(body, HEADER_CONFIG_MAIN_REVISION, &updated_revision) } } async fn save_config_sources( axum::extract::State(app_state): axum::extract::State>, headers: HeaderMap, axum::extract::Json(sources): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { let sources_file_path = app_state.app_config.paths.load().sources_file_path.clone(); let _source_lock = app_state.app_config.file_locks.write_lock(Path::new(&sources_file_path)).await; let _bouquet_mutation_lock = app_state.app_config.file_locks.write_lock_str(tuliprox_repository::TARGET_BOUQUET_MUTATION_LOCK).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; } let original_sources_file = match read_optional_file(Path::new(&sources_file_path)).await { Ok(content) => content, Err(err) => { error!("Failed to snapshot source.yml '{sources_file_path}' before save: {err}"); return internal_server_error!(); } }; let templates_to_persist = match crate::config_loader::validate_source_config_for_persist(&app_state.app_config, &sources).await { Ok(value) => value, Err(err) => { error!("Failed to validate source.yml {err}"); return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))) .into_response(); } }; let template_snapshot = match snapshot_template_file(&app_state, templates_to_persist.is_some()).await { Ok(snapshot) => snapshot, Err(err) => { error!("Failed to snapshot template config before save: {err}"); return internal_server_error!(); } }; if let Some(template_definition) = templates_to_persist.as_ref() { if let Err(err) = crate::config_loader::persist_templates_config(&app_state.app_config, template_definition).await { error!("Failed to save template config {err}"); return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } } // Derive lifecycle changes from the currently loaded configuration before it is replaced. let bouquet_mutations = build_bouquet_mutation_batch(&app_state, &sources); match crate::config_loader::replace_source_config_from_user_edit(&app_state.app_config, None, sources).await { Ok(_) => {} Err(err) => { error!("Failed to persist source.yml {err}"); if let Some((template_file_path, original_template_file)) = template_snapshot.as_ref() { if let Err(rollback_err) = restore_sources_file(Path::new(template_file_path), original_template_file.as_deref()).await { error!("Failed to roll back template config after source.yml save failure: {rollback_err}"); } } return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } } // Reload before deleting bouquets so a failed reload can restore the persisted // configuration without having touched target-local state. if let Err(reload_err) = ConfigFile::load_sources(&app_state).await { return roll_back_sources_after_update_failure( &app_state, Path::new(&sources_file_path), original_sources_file.as_deref(), template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())), &reload_err, ) .await; } // The mutation lock prevents concurrent bouquet writes from observing a partial lifecycle update. if let Err(mutation_err) = apply_bouquet_mutation_batch(&app_state, &bouquet_mutations).await { return roll_back_sources_after_update_failure( &app_state, Path::new(&sources_file_path), original_sources_file.as_deref(), template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())), &mutation_err, ) .await; } app_state.active_provider.update_config(&app_state.app_config); 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) } async fn get_config_api_proxy_config_public( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let paths = app_state.app_config.paths.load(); 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_with_templates( api_proxy_file_path.as_str(), true, app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()), ) { Ok(Some(mut api_proxy_dto)) => { filter_api_proxy_by_permissions(&mut api_proxy_dto, PermissionSet::new()); let response = axum::response::Json(api_proxy_dto).into_response(); return response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision); } Ok(None) => { error!("Failed to read api proxy config"); } Err(err) => { error!("Failed to read api proxy config: {err}"); } } internal_server_error!() } async fn save_config_api_proxy_config( axum::extract::State(app_state): axum::extract::State>, headers: HeaderMap, axum::extract::Json(mut req_api_proxy): axum::extract::Json, ) -> impl IntoResponse + Send { 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; } for server_info in &mut req_api_proxy.server { if !server_info.validate() { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))) .into_response(); } } // TODO if hot reload is on, it is loaded twice, avoid this // Build the updated config without mutating global state yet let base = app_state.app_config.api_proxy.load().as_deref().cloned().unwrap_or_default(); let updated_api_proxy = ApiProxyConfig { use_user_db: req_api_proxy.use_user_db, server: req_api_proxy.server.iter().map(Into::into).collect(), auth_error_status: req_api_proxy.auth_error_status, ..base }; // Full-config validation: catches duplicate server names, duplicate usernames/tokens // and users referencing missing servers, which per-row validate() cannot see let stored_plans = updated_api_proxy.plans.clone(); let mut updated_api_proxy_dto = ApiProxyConfigDto::from(&updated_api_proxy); if let Err(err) = updated_api_proxy_dto.prepare_with_templates(updated_api_proxy.templates.as_deref()) { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } if let Some(err) = intern_save_config_api_proxy(&backup_dir, &updated_api_proxy_dto, &api_proxy_file_path).await { return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } // Persist succeeded — now update in‑memory state with the prepared config. // Plans live in plans.yml, so re-inject them (the DTO round-trip drops them). let mut stored_api_proxy = ApiProxyConfig::from_dto_with_templates(&updated_api_proxy_dto, updated_api_proxy.templates.clone()); stored_api_proxy.set_plans(stored_plans); app_state.app_config.api_proxy.store(Some(Arc::new(stored_api_proxy))); 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) } async fn get_config_common(app_state: &Arc, permissions: Option) -> axum::response::Response { 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(); crate::config_loader::read_app_config_dto(&paths, true, false).await }; match read_result { Ok(mut app_config) => { if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await { error!("Failed to prepare sources batch: {err}"); internal_server_error!() } else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await { error!("Failed to prepare users: {err}"); internal_server_error!() } else { filter_app_config_by_permissions(&mut app_config, permissions); 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) } } Err(err) => { error!("Failed to read config files: {err}"); internal_server_error!() } } } async fn config_unprotected( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { get_config_common(&app_state, None).await } async fn config( AuthBearer(token): AuthBearer, axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let Some(permissions) = decode_permissions(&app_state, &token) else { return axum::http::StatusCode::UNAUTHORIZED.into_response(); }; if !has_any_permission( permissions, &[ Permission::ConfigRead, Permission::SourceRead, Permission::UserRead, Permission::UserWrite, Permission::PlaylistRead, Permission::PlaylistWrite, ], ) { return axum::http::StatusCode::FORBIDDEN.into_response(); } get_config_common(&app_state, Some(permissions)).await } async fn get_config_api_proxy_config( AuthBearer(token): AuthBearer, axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let Some(permissions) = decode_permissions(&app_state, &token) else { return axum::http::StatusCode::UNAUTHORIZED.into_response(); }; if !has_any_permission(permissions, &[Permission::ConfigRead, Permission::UserRead, Permission::UserWrite]) { return axum::http::StatusCode::FORBIDDEN.into_response(); } let paths = app_state.app_config.paths.load(); 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_with_templates( api_proxy_file_path.as_str(), true, app_state.app_config.api_proxy.load().as_ref().and_then(|config| config.templates.as_deref()), ) { Ok(Some(mut api_proxy_dto)) => { filter_api_proxy_by_permissions(&mut api_proxy_dto, permissions); let response = axum::response::Json(api_proxy_dto).into_response(); response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision) } Ok(None) => { error!("Failed to read api proxy config"); internal_server_error!() } Err(err) => { error!("Failed to read api proxy config: {err}"); internal_server_error!() } } } async fn config_batch_content( axum::extract::Path(input_id): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, ) -> impl 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( &app_state.app_config, &app_state.http_clients.default.load(), &input_source, None, None, false, ) .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}"); internal_server_error!() } }; } } (axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"}))) .into_response() } async fn get_xtream_login_info( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(request): axum::extract::Json, ) -> 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 providers = { let sources = app_state.app_config.sources.load(); sources.provider.clone() }; let input_source = match build_xtream_login_input_source(&request, &providers) { Ok(input_source) => input_source, Err(err) => { error!("Failed to prepare xtream login request: {err}"); return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } }; let http_client = app_state.http_clients.default.load(); match xtream_login(&app_state.app_config, &http_client, &app_state.event_manager, &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() } } } fn build_xtream_login_input_source( request: &XtreamLoginRequest, providers: &[Arc], ) -> Result { let url = request.url.trim(); let provider = if url.starts_with(PROVIDER_SCHEME_PREFIX) { let (provider_name, _) = parse_provider_scheme_url_parts(url)?; let request_provider = request.providers.as_ref().and_then(|request_providers| { request_providers .iter() .find(|provider| provider.name.as_ref() == provider_name) .map(crate::model::ConfigProvider::from) .map(Arc::new) }); Some( request_provider .into_iter() .chain(providers.iter().cloned()) .find(|provider| provider.name.as_ref() == provider_name) .ok_or_else(|| { TuliproxError::ConfigInput(format!("Provider config for '{provider_name}' not found")) })?, ) } else { None }; Ok(InputSource { name: "xtream_login".into(), url: get_xtream_stream_url_base(url, &request.username, &request.password), provider, username: Some(request.username.clone()), password: Some(request.password.clone()), method: InputFetchMethod::GET, headers: HashMap::new(), }) } fn get_config_plans_dto(app_state: &Arc) -> Result { let plans_path = { let paths = app_state.app_config.paths.load(); plans_file_path(paths.api_proxy_file_path.as_str()) }; let plans_path_str = plans_path.to_string_lossy().to_string(); match read_plans_file(&plans_path_str, true) { Ok(Some(dto)) => Ok(dto), Ok(None) => Ok(PlansConfigDto::default()), Err(err) => { error!("Failed to read plans config: {err}"); Err(err) } } } async fn get_config_plans( AuthBearer(token): AuthBearer, axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let Some(permissions) = decode_permissions(&app_state, &token) else { return axum::http::StatusCode::UNAUTHORIZED.into_response(); }; if !has_any_permission(permissions, &[Permission::ConfigRead, Permission::UserRead, Permission::UserWrite]) { return axum::http::StatusCode::FORBIDDEN.into_response(); } match get_config_plans_dto(&app_state) { Ok(dto) => axum::response::Json(dto).into_response(), Err(_) => internal_server_error!(), } } async fn get_config_plans_unprotected( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { match get_config_plans_dto(&app_state) { Ok(dto) => axum::response::Json(dto).into_response(), Err(_) => internal_server_error!(), } } async fn save_config_plans( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(mut req_plans): axum::extract::Json, ) -> impl IntoResponse + Send { if let Err(err) = req_plans.prepare() { return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } let (plans_path_str, backup_dir) = { let paths = app_state.app_config.paths.load(); let config = app_state.app_config.config.load(); ( plans_file_path(paths.api_proxy_file_path.as_str()).to_string_lossy().to_string(), config.get_backup_dir().to_string(), ) }; let _lock = app_state.app_config.file_locks.write_lock(Path::new(&plans_path_str)).await; if let Err(err) = save_plans(&plans_path_str, &backup_dir, &req_plans).await { return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); } // Re-resolve users against the new plans without a restart. if let Some(api_proxy) = app_state.app_config.api_proxy.load().as_deref() { let mut updated = api_proxy.clone(); let plans = req_plans.plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect(); updated.set_plans(plans); app_state.app_config.api_proxy.store(Some(Arc::new(updated))); } StatusCode::OK.into_response() } pub fn v1_api_config_register(router: Router>) -> axum::Router> { router .route("/config", axum::routing::get(config_unprotected)) .route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content)) .route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info)) .route("/config/main", axum::routing::post(save_config_main)) .route("/config/sources", axum::routing::post(save_config_sources)) .route( "/config/apiproxy", axum::routing::get(get_config_api_proxy_config_public).put(save_config_api_proxy_config), ) .route("/config/plans", axum::routing::get(get_config_plans_unprotected).put(save_config_plans)) } pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Router> { let base_read = Router::new() .route("/config", axum::routing::get(config)) .route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config)) .route("/config/plans", axum::routing::get(get_config_plans)); // 2. Source Domain (Read & Write) let source_read = Router::new() .route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content)) .route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info)) .layer(permission_layer!(app_state, Permission::SourceRead)); let source_write = Router::new() .route("/config/sources", axum::routing::post(save_config_sources)) .layer(permission_layer!(app_state, Permission::SourceWrite)); let config_write = Router::new() .route("/config/messaging/test", axum::routing::post(test_messaging)) .route("/config/main", axum::routing::post(save_config_main)) .route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config)) .route("/config/plans", axum::routing::put(save_config_plans)) .layer(permission_layer!(app_state, Permission::ConfigWrite)); Router::new().merge(base_read).merge(source_read).merge(source_write).merge(config_write) } /// Request body for `POST /config/messaging/test`. #[derive(serde::Deserialize)] pub struct MessagingTestRequest { /// Event id to simulate. Defaults to `system.info`. #[serde(default)] pub event: Option, /// Restrict to one channel by its stable id (`telegram`, `discord`, ...). /// Absent means every configured channel. #[serde(default)] pub channel: Option, /// Render only, send nothing. Lets a template be iterated without /// spamming a channel. #[serde(default)] pub preview: bool, } /// What one channel did with the test event. #[derive(serde::Serialize)] pub struct MessagingTestChannelResult { pub channel: String, /// `delivered`, `skipped`, `retry`, `permanent`, or `preview`. pub outcome: String, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, /// Exactly what the channel was asked to send. pub rendered: String, /// Whether an operator template produced `rendered`. pub templated: bool, } #[derive(serde::Serialize)] pub struct MessagingTestResponse { pub event: String, pub severity: String, pub results: Vec, } /// Send (or render) a test notification. /// /// Messaging config previously had no feedback loop shorter than "save it /// and wait for something to break". `preview` renders without sending, so a /// template can be iterated safely. async fn test_messaging( axum::extract::State(app_state): axum::extract::State>, axum::Json(request): axum::Json, ) -> impl axum::response::IntoResponse { let requested = request.event.as_deref().unwrap_or("system.info"); let Some(event_id) = shared::model::notification::EventId::from_wire(requested) else { return ( axum::http::StatusCode::BAD_REQUEST, axum::Json(serde_json::json!({ "error": format!("unknown event `{requested}`"), })), ) .into_response(); }; let event = tuliprox_messaging::test_event(event_id); let client = app_state.http_clients.default.load(); let results = tuliprox_messaging::render_and_send_test( &app_state.app_config, &client, &event, request.channel.as_deref(), request.preview, ) .await; let response = MessagingTestResponse { event: event.id.to_string(), severity: event.severity.to_string(), results: results .into_iter() .map(|outcome| MessagingTestChannelResult { channel: outcome.channel, outcome: outcome.outcome, reason: outcome.reason, rendered: outcome.rendered, templated: outcome.templated, }) .collect(), }; (axum::http::StatusCode::OK, axum::Json(response)).into_response() } #[cfg(test)] mod tests { use super::{ build_xtream_login_input_source, filter_api_proxy_by_permissions, filter_app_config_by_permissions, require_matching_revision, }; use crate::model::ConfigProvider; use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, }; use shared::{ model::{ ApiProxyConfigDto, ApiProxyServerInfoDto, AppConfigDto, ConfigDto, ConfigProviderDto, Permission, PermissionSet, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, XtreamLoginRequest, }, utils::{HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH}, }; use std::sync::Arc; #[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"); } fn make_test_api_proxy() -> ApiProxyConfigDto { ApiProxyConfigDto { server: vec![ApiProxyServerInfoDto { name: String::from("main"), protocol: String::from("http"), host: String::from("localhost"), port: None, timezone: String::from("UTC"), message: String::from("hello"), path: None, }], user: vec![TargetUserDto { target: String::from("target-a"), credentials: vec![] }], use_user_db: true, auth_error_status: 401, } } #[test] fn filter_app_config_clears_unauthorized_sections() { let permissions: PermissionSet = Permission::ConfigRead.into(); let mut app_config = AppConfigDto { config: ConfigDto { storage_dir: Some(String::from("storage")), ..ConfigDto::default() }, sources: SourcesConfigDto { inputs: vec![], sources: vec![], provider: Some(vec![]), templates: Some(vec![]), }, mappings: None, templates: Some(TemplateDefinitionDto::default()), api_proxy: Some(make_test_api_proxy()), }; filter_app_config_by_permissions(&mut app_config, Some(permissions)); assert_eq!(app_config.config.storage_dir.as_deref(), Some("storage")); assert_eq!(app_config.sources, SourcesConfigDto::default()); assert!(app_config.templates.is_none()); let api_proxy = app_config.api_proxy.expect("api proxy should remain present"); assert_eq!(api_proxy.server.len(), 1); assert_eq!(api_proxy.user, [] as [shared::model::TargetUserDto; 0]); assert!(api_proxy.use_user_db); } #[test] fn filter_api_proxy_keeps_user_and_sanitized_server_section_with_user_read() { let permissions: PermissionSet = Permission::UserRead.into(); let mut api_proxy = make_test_api_proxy(); filter_api_proxy_by_permissions(&mut api_proxy, permissions); assert_eq!(api_proxy.server.len(), 1); assert_eq!(api_proxy.server[0].name, "main"); assert!(api_proxy.server[0].protocol.is_empty()); assert!(api_proxy.server[0].host.is_empty()); assert!(api_proxy.server[0].port.is_none()); assert!(api_proxy.server[0].timezone.is_empty()); assert!(api_proxy.server[0].message.is_empty()); assert!(api_proxy.server[0].path.is_none()); assert_eq!(api_proxy.user.len(), 1); assert!(!api_proxy.use_user_db); assert_eq!(api_proxy.auth_error_status, 403); } #[test] fn filter_api_proxy_keeps_sanitized_server_section_with_user_write() { let permissions: PermissionSet = Permission::UserWrite.into(); let mut api_proxy = make_test_api_proxy(); filter_api_proxy_by_permissions(&mut api_proxy, permissions); assert_eq!(api_proxy.server.len(), 1); assert_eq!(api_proxy.server[0].name, "main"); assert!(api_proxy.server[0].host.is_empty()); assert!(api_proxy.user.is_empty()); } #[test] fn filter_api_proxy_clears_all_for_unrelated_permission() { let permissions: PermissionSet = Permission::SystemRead.into(); let mut api_proxy = make_test_api_proxy(); filter_api_proxy_by_permissions(&mut api_proxy, permissions); assert!(api_proxy.server.is_empty()); assert!(api_proxy.user.is_empty()); assert!(!api_proxy.use_user_db); assert_eq!(api_proxy.auth_error_status, 403); } #[test] fn filter_app_config_keeps_targets_and_clears_sources_for_user_read_without_source_read() { let permissions: PermissionSet = Permission::UserRead.into(); let mut app_config = AppConfigDto { config: ConfigDto { storage_dir: Some(String::from("storage")), ..ConfigDto::default() }, sources: SourcesConfigDto { inputs: vec![shared::model::ConfigInputDto { name: Arc::from("secret-provider"), url: String::from("http://secret-provider/get.php?username=foo&password=bar"), ..Default::default() }], sources: vec![shared::model::ConfigSourceDto { inputs: vec![Arc::from("secret-provider")], targets: vec![shared::model::ConfigTargetDto { id: 1, enabled: false, name: String::from("Default"), curation: Some( serde_json::from_value( serde_json::json!({"tmdb": {"api": {"access_token": "discovery-secret"}}}), ) .unwrap(), ), output: vec![shared::model::TargetOutputDto::M3u(shared::model::M3uTargetOutputDto { filename: Some(String::from("secret.m3u")), ..Default::default() })], filter: shared::model::ConfigTargetFilterDto { processing: Some(String::from("group ~ 'secret'")), ..Default::default() }, watch: Some(vec![shared::model::ConfigWatchDto::channels("watch-expr")]), mapping: Some(vec![String::from("mapping-name")]), ..Default::default() }], }], provider: Some(vec![]), templates: Some(vec![]), }, mappings: None, templates: Some(TemplateDefinitionDto::default()), api_proxy: Some(make_test_api_proxy()), }; filter_app_config_by_permissions(&mut app_config, Some(permissions)); assert_eq!(app_config.config.storage_dir, None); assert!(app_config.sources.inputs.is_empty()); assert!(app_config.sources.provider.is_none()); assert!(app_config.sources.templates.is_none()); assert!(app_config.mappings.is_none()); assert!(app_config.templates.is_none()); assert_eq!(app_config.sources.sources.len(), 1); assert!(app_config.sources.sources[0].inputs.is_empty()); assert_eq!(app_config.sources.sources[0].targets.len(), 1); let target = &app_config.sources.sources[0].targets[0]; assert_eq!(target.name, "Default"); assert_eq!(target.id, 1); assert_eq!(target.enabled, shared::model::ConfigTargetDto::default().enabled); assert!(target.output.is_empty()); assert!(target.curation.is_none(), "identity-only access must not disclose discovery credentials"); assert!(target.filter.is_empty()); assert!(target.options.is_none()); assert!(target.sort.is_none()); assert!(target.rename.is_none()); assert!(target.mapping.is_none()); assert!(target.favourites.is_none()); assert!(target.watch.is_none()); let api_proxy = app_config.api_proxy.expect("api proxy should remain present"); assert_eq!(api_proxy.server.len(), 1); assert_eq!(api_proxy.server[0].name, "main"); assert!(api_proxy.server[0].host.is_empty()); assert_eq!(api_proxy.user.len(), 1); } #[test] fn build_xtream_login_input_source_preserves_provider_context_for_provider_urls() { let provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { name: "b1g".into(), urls: vec!["http://48392071.xyz".into(), "http://48392244.xyz".into()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, })); let request = XtreamLoginRequest { url: "provider://b1g".to_string(), username: "demo".to_string(), password: "secret".to_string(), providers: None, }; let input_source = build_xtream_login_input_source(&request, std::slice::from_ref(&provider)) .expect("provider url should resolve against runtime providers"); assert_eq!(input_source.url, "provider://b1g/player_api.php?username=demo&password=secret"); assert_eq!(input_source.provider.as_ref().map(|provider| provider.name.as_ref()), Some("b1g")); } #[test] fn build_xtream_login_input_source_uses_request_providers_when_runtime_providers_are_missing() { let request = XtreamLoginRequest { url: "provider://strong".to_string(), username: "bubble".to_string(), password: "gum".to_string(), providers: Some(vec![ConfigProviderDto { name: "strong".into(), urls: vec!["http://strong.example".into()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }]), }; let input_source = build_xtream_login_input_source(&request, &[]).expect("request-scoped provider should resolve"); assert_eq!(input_source.url, "provider://strong/player_api.php?username=bubble&password=gum"); assert_eq!(input_source.provider.as_ref().map(|provider| provider.name.as_ref()), Some("strong")); } #[test] fn build_xtream_login_input_source_prefers_request_provider_when_names_collide() { let runtime_provider = Arc::new(ConfigProvider::from(&ConfigProviderDto { name: "strong".into(), urls: vec!["http://runtime.example".into()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, })); let request = XtreamLoginRequest { url: "provider://strong".to_string(), username: "bubble".to_string(), password: "gum".to_string(), providers: Some(vec![ConfigProviderDto { name: "strong".into(), urls: vec!["http://request.example".into()], provider_url_selection_policy: shared::model::ProviderUrlSelectionPolicy::default(), dns: None, }]), }; let input_source = build_xtream_login_input_source(&request, &[runtime_provider]) .expect("request-scoped provider should take precedence over runtime provider"); assert_eq!(input_source.url, "provider://strong/player_api.php?username=bubble&password=gum"); assert_eq!(input_source.provider.as_ref().map(|provider| provider.name.as_ref()), Some("strong")); assert_eq!( input_source.provider.as_ref().and_then(|provider| provider.urls.first()).map(std::convert::AsRef::as_ref), Some("http://request.example") ); } #[tokio::test] async fn save_config_sources_uses_names_and_does_not_deadlock_on_mutation_lock() { use crate::api::model::create_test_app_state; use std::time::Duration; use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().to_string_lossy().to_string(); let sources_file = temp_dir.path().join("source.yml"); let input_dto = shared::model::ConfigInputDto { name: "input1".into(), url: "http://example.com/playlist.m3u".to_string(), ..Default::default() }; let curation: shared::model::CurationConfigDto = serde_json::from_value(serde_json::json!({"enabled": false, "catalog_selection": "curated", "tmdb": {"api": {"access_token": "discovery-test-token"}, "trending": [{"kind": "movie", "time_window": "week", "limit": 37, "create_xtream_category": false}]}})).unwrap(); let initial_sources = SourcesConfigDto { inputs: vec![input_dto.clone()], sources: vec![shared::model::ConfigSourceDto { inputs: vec!["input1".into()], targets: vec![shared::model::ConfigTargetDto { id: 1, name: "target_1".to_string(), curation: Some(curation.clone()), output: vec![shared::model::TargetOutputDto::M3u(shared::model::M3uTargetOutputDto::default())], ..Default::default() }], }], ..Default::default() }; let yaml = serde_saphyr::to_string(&initial_sources).unwrap(); tokio::fs::write(&sources_file, &yaml).await.unwrap(); let test_config = crate::model::Config { backup_dir: Some(temp_dir.path().join("backup").to_string_lossy().to_string()), ..crate::model::Config::default() }; let app_state = create_test_app_state(test_config); app_state.app_config.paths.store(Arc::new(shared::model::ConfigPaths { home_path: config_path.clone(), config_path: config_path.clone(), storage_path: config_path.clone(), config_file_path: String::new(), sources_file_path: sources_file.to_string_lossy().to_string(), mapping_file_path: None, mapping_files_used: None, template_file_path: None, template_files_used: None, api_proxy_file_path: String::new(), custom_stream_response_path: None, })); super::ConfigFile::load_sources(&app_state).await.unwrap(); tuliprox_repository::save_target_bouquet( &app_state.app_config, "target_1", shared::model::TargetBouquetDto::new( shared::model::TargetBouquetMode::Whitelist, shared::model::PlaylistClusterBouquetDto { live: Some(vec!["News".to_string()]), vod: None, series: None, }, ), ) .await .unwrap(); let updated_sources = SourcesConfigDto { inputs: vec![input_dto], sources: vec![shared::model::ConfigSourceDto { inputs: vec!["input1".into()], targets: vec![shared::model::ConfigTargetDto { id: 1, name: "target_renamed".to_string(), curation: Some(curation.clone()), output: vec![shared::model::TargetOutputDto::M3u(shared::model::M3uTargetOutputDto::default())], ..Default::default() }], }], ..Default::default() }; let rev = super::read_file_revision(sources_file.to_str().unwrap()).await.unwrap(); let mut headers = HeaderMap::new(); headers.insert(HEADER_IF_MATCH, HeaderValue::from_str(&rev).unwrap()); let save_future = super::save_config_sources( axum::extract::State(Arc::clone(&app_state)), headers, axum::Json(updated_sources), ); let response = tokio::time::timeout(Duration::from_secs(5), save_future) .await .expect("save_config_sources must not deadlock"); assert_eq!(response.into_response().status(), StatusCode::OK); assert!(!tuliprox_repository::target_bouquet_exists(temp_dir.path(), "target_1").await); assert!(!tuliprox_repository::target_bouquet_exists(temp_dir.path(), "target_renamed").await); let runtime_sources = app_state.app_config.sources.load(); assert_eq!(runtime_sources.sources[0].targets[0].name, "target_renamed"); assert_eq!( shared::model::CurationConfigDto::from(runtime_sources.sources[0].targets[0].curation.as_ref().unwrap()), curation ); let saved: SourcesConfigDto = serde_saphyr::from_str(&tokio::fs::read_to_string(&sources_file).await.unwrap()).unwrap(); assert_eq!(saved.sources[0].targets[0].curation.as_ref(), Some(&curation)); } }