Files
tuliprox/backend/config-loader/src/lib.rs
T
euzuandGitHub 0bd087d288 fix(provider): exclude and persist banned/expired accounts (#891)
* fix(provider): exclude and persist banned/expired accounts

- Store terminal account states in source.yml/CSV; keep Pending runtime-only
- On 401/403, only flag the account for the throttled expiry worker
- Lift stored bans on panel renewal or credential update
- Persist per file in one batch; back up only when content changes
- Use the first usable alias for playlists when the root is blocked

* fix(provider): exclude and persist banned/expired accounts

- Store terminal account states in source.yml/CSV; keep Pending runtime-only
- On 401/403, only flag the account for the throttled expiry worker
- Lift stored bans on panel renewal or credential update
- Persist per file in one batch; back up only when content changes
- Use the first usable alias for playlists when the root is blocked
2026-10-01 14:24:02 +02:00

1803 lines
68 KiB
Rust

pub mod runtime_config_report;
pub mod source_patch;
use arc_swap::{ArcSwap, ArcSwapAny};
use chrono::Local;
use log::{error, info, warn};
use serde::Serialize;
use shared::{
concat_string,
defaults::{generate_default_access_secret, generate_default_encrypt_secret, TEMPLATE_FILE},
error::TuliproxError,
foundation::prepare_templates,
model::{
ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, HdHomeRunDeviceOverview,
InputType, PatternTemplate, PlansConfigDto, Prepare, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto,
UserPlanDto,
},
utils::PROVIDER_SCHEME_PREFIX,
};
use std::{
collections::{HashMap, HashSet},
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
};
use tokio::fs;
use tuliprox_core::{
model::{ApiProxyConfig, AppConfig, Config, MediaToolCapabilities, SourcesConfig, UserPlan},
utils,
utils::{
config_file_reader, exit, file_exists_async, open_file, read_mappings_file_unprepared, read_templates_file,
request::is_uri, resolve_env_var, FileLockManager,
},
};
use tuliprox_repository::{
backup_api_user_db_file, csv_read_inputs, csv_write_inputs, get_api_user_db_path, is_csv_file, load_api_user,
merge_api_user,
};
use url::Url;
/// Options controlling how configuration files are parsed.
///
/// Prefer constructing this struct over passing individual `bool` literals to
/// `read_config_file`, `read_sources_file_from_path`, etc. — bare `true`/`false`
/// arguments at call sites are hard to read without an IDE hover.
///
/// # Example
/// ```ignore
/// // Before (opaque):
/// read_sources_file_from_path(&path, true, true, hdhr).await?;
///
/// // After (self-documenting):
/// let opts = ReadConfigOptions::resolve_and_compute();
/// read_sources_file_from_path(&path, opts.resolve_env, opts.include_computed, hdhr).await?;
/// ```
#[derive(Debug, Clone, Copy)]
pub struct ReadConfigOptions {
// When `true`, `${ENV_VAR}` placeholders in config files are expanded.
pub resolve_env: bool,
// When `true`, computed / derived fields (e.g. auto EPG URLs) are filled in.
pub include_computed: bool,
}
impl ReadConfigOptions {
// Expand environment variables and include all computed fields — the typical
// production default.
#[must_use]
pub const fn resolve_and_compute() -> Self { Self { resolve_env: true, include_computed: true } }
// Parse the raw YAML without any variable expansion or computed fields —
// useful when you need the literal, unexpanded content (e.g. before saving).
#[must_use]
pub const fn raw() -> Self { Self { resolve_env: false, include_computed: false } }
}
pub struct PreparedTemplateBundle {
pub definition: Option<TemplateDefinitionDto>,
pub prepared: Option<Vec<PatternTemplate>>,
pub files_used: Option<Vec<String>>,
}
// `Read`-Trait for Either
pub async fn read_api_proxy_config(
config: &AppConfig,
resolve_env: bool,
) -> Result<Option<ApiProxyConfig>, TuliproxError> {
let paths = config.paths.load();
let api_proxy_file_path = paths.api_proxy_file_path.as_str();
let templates = read_user_filter_templates(config).await?;
if let Some(api_proxy_dto) =
read_api_proxy_file_with_templates(api_proxy_file_path, resolve_env, templates.as_deref())?
{
let mut errors = vec![];
let mut api_proxy = ApiProxyConfig::from_dto_with_templates(&api_proxy_dto, templates.map(Arc::from));
apply_authoritative_plans(config, &mut api_proxy, resolve_env).await;
migrate_api_user(&mut api_proxy, config, &mut errors).await;
if !errors.is_empty() {
for error in errors {
error!("{error}");
}
}
Ok(Some(api_proxy))
} else {
warn!("can't read api_proxy_config file: {api_proxy_file_path}");
Ok(None)
}
}
async fn parse_sources_file_from_path(
sources_file: &Path,
resolve_env: bool,
) -> Result<SourcesConfigDto, TuliproxError> {
parse_sources_file_with_revision(sources_file, resolve_env).await.map(|(sources, _)| sources)
}
/// Parses the sources file and returns the revision of exactly the bytes that were parsed.
async fn parse_sources_file_with_revision(
sources_file: &Path,
resolve_env: bool,
) -> Result<(SourcesConfigDto, blake3::Hash), TuliproxError> {
let read_error = |err: &dyn std::fmt::Display| {
TuliproxError::ConfigSource(format!("Can't read the sources-config file: {}: {err}", sources_file.display()))
};
let content = fs::read(sources_file).await.map_err(|err| read_error(&err))?;
let revision = blake3::hash(&content);
let parsed = tokio::task::spawn_blocking(move || {
let reader = utils::file_reader(std::io::Cursor::new(content));
if resolve_env {
serde_saphyr::from_reader::<_, SourcesConfigDto>(utils::EnvResolvingReader::new(reader))
} else {
serde_saphyr::from_reader::<_, SourcesConfigDto>(reader)
}
})
.await
.map_err(|join_err| TuliproxError::ConfigSource(format!("Failed to read sources-config file: {join_err}")))?;
parsed.map(|sources| (sources, revision)).map_err(|err| read_error(&err))
}
pub fn resolve_template_and_mapping_paths(
paths: &ConfigPaths,
template_path: Option<&str>,
mapping_path: Option<&str>,
) -> (String, String) {
let effective_template_path = utils::resolve_template_file_path(
paths.config_path.as_str(),
paths.template_file_path.as_deref().or(template_path),
);
let effective_mapping_path = utils::resolve_mapping_file_path(
paths.config_path.as_str(),
paths.mapping_file_path.as_deref().or(mapping_path),
);
(effective_template_path, effective_mapping_path)
}
pub async fn read_sources_file_from_path_with_templates(
sources_file: &Path,
resolve_env: bool,
include_computed: bool,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
prepared_templates: Option<&[shared::model::PatternTemplate]>,
) -> Result<SourcesConfigDto, TuliproxError> {
read_sources_file_with_revision(sources_file, resolve_env, include_computed, hdhr_config, prepared_templates)
.await
.map(|(sources, _)| sources)
}
/// Like [`read_sources_file_from_path_with_templates`], plus the revision of the parsed bytes.
pub async fn read_sources_file_with_revision(
sources_file: &Path,
resolve_env: bool,
include_computed: bool,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
prepared_templates: Option<&[shared::model::PatternTemplate]>,
) -> Result<(SourcesConfigDto, blake3::Hash), TuliproxError> {
let (mut sources, revision) = parse_sources_file_with_revision(sources_file, resolve_env).await?;
if resolve_env {
if let Err(err) = sources.prepare(include_computed, hdhr_config, prepared_templates) {
return Err(TuliproxError::Config(format!(
"Can't read the sources-config file: {}: {err}",
sources_file.display()
)));
}
}
Ok((sources, revision))
}
pub async fn read_sources_file_from_path_with_options(
sources_file: &Path,
opts: ReadConfigOptions,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
prepared_templates: Option<&[shared::model::PatternTemplate]>,
) -> Result<SourcesConfigDto, TuliproxError> {
read_sources_file_from_path_with_templates(
sources_file,
opts.resolve_env,
opts.include_computed,
hdhr_config,
prepared_templates,
)
.await
}
pub async fn read_sources_file_from_path(
sources_file: &Path,
resolve_env: bool,
include_computed: bool,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
) -> Result<SourcesConfigDto, TuliproxError> {
read_sources_file_from_path_with_options(
sources_file,
ReadConfigOptions { resolve_env, include_computed },
hdhr_config,
None,
)
.await
}
pub async fn read_sources_file_with_options(
sources_file: &str,
opts: ReadConfigOptions,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
prepared_templates: Option<&[shared::model::PatternTemplate]>,
) -> Result<SourcesConfigDto, TuliproxError> {
read_sources_file_from_path_with_options(&PathBuf::from(sources_file), opts, hdhr_config, prepared_templates).await
}
pub async fn read_sources_file(
sources_file: &str,
resolve_env: bool,
include_computed: bool,
hdhr_config: Option<&HdHomeRunDeviceOverview>,
prepared_templates: Option<&[shared::model::PatternTemplate]>,
) -> Result<SourcesConfigDto, TuliproxError> {
read_sources_file_with_options(
sources_file,
ReadConfigOptions { resolve_env, include_computed },
hdhr_config,
prepared_templates,
)
.await
}
pub fn read_config_file_with_options(config_file: &str, opts: ReadConfigOptions) -> Result<ConfigDto, TuliproxError> {
match open_file(&std::path::PathBuf::from(config_file)) {
Ok(file) => {
let maybe_config: Result<ConfigDto, _> =
serde_saphyr::from_reader(config_file_reader(file, opts.resolve_env));
match maybe_config {
Ok(mut config) => {
if opts.resolve_env {
config.prepare(opts.include_computed)?;
}
Ok(config)
}
Err(err) => Err(TuliproxError::Config(format!("Can't read the config file: {config_file}: {err}"))),
}
}
Err(err) => Err(TuliproxError::Config(format!("Can't read the config file: {config_file}: {err}"))),
}
}
pub fn read_config_file(
config_file: &str,
resolve_env: bool,
include_computed: bool,
) -> Result<ConfigDto, TuliproxError> {
read_config_file_with_options(config_file, ReadConfigOptions { resolve_env, include_computed })
}
#[allow(clippy::too_many_lines)]
pub fn read_templates(
template_file_path: Option<&str>,
resolve_env: bool,
sources_inline_templates: Option<&[PatternTemplate]>,
mappings_inline_templates: Option<&[PatternTemplate]>,
) -> Result<PreparedTemplateBundle, TuliproxError> {
struct TemplateNameUsage {
count: usize,
sources: HashSet<String>,
}
fn track_template_name(usage: &mut HashMap<String, TemplateNameUsage>, template_name: &str, source: &str) {
let entry = usage
.entry(template_name.to_string())
.or_insert_with(|| TemplateNameUsage { count: 0, sources: HashSet::new() });
entry.count = entry.count.saturating_add(1);
entry.sources.insert(source.to_string());
}
let mut loaded_template_files: Vec<String> = Vec::new();
let mut merged_templates: Vec<PatternTemplate> = Vec::new();
let mut template_name_usage: HashMap<String, TemplateNameUsage> = HashMap::new();
if let Some(path) = template_file_path {
if let Some((template_paths, definition)) = read_templates_file(path, resolve_env)? {
let file_source_label = if template_paths.is_empty() {
format!("template file: {path}")
} else if template_paths.len() == 1 {
format!("template file: {}", template_paths[0].display())
} else {
format!(
"template files: {}",
template_paths
.iter()
.map(|template_path| template_path.display().to_string())
.collect::<Vec<String>>()
.join(", ")
)
};
for template in &definition.templates {
track_template_name(&mut template_name_usage, template.name.as_str(), file_source_label.as_str());
}
merged_templates.extend(definition.templates);
loaded_template_files.extend(template_paths.iter().map(|p| p.display().to_string()));
}
}
if let Some(templates) = sources_inline_templates {
for template in templates {
track_template_name(&mut template_name_usage, template.name.as_str(), "source.yml inline templates");
}
merged_templates.extend(templates.iter().cloned());
}
if let Some(templates) = mappings_inline_templates {
for template in templates {
track_template_name(&mut template_name_usage, template.name.as_str(), "mapping.yml inline templates");
}
merged_templates.extend(templates.iter().cloned());
}
let mut duplicate_templates = template_name_usage
.iter()
.filter_map(|(name, usage)| {
if usage.count > 1 {
let mut sources = usage.sources.iter().cloned().collect::<Vec<String>>();
sources.sort();
Some(format!("{name} [{}]", sources.join(", ")))
} else {
None
}
})
.collect::<Vec<String>>();
if !duplicate_templates.is_empty() {
duplicate_templates.sort();
return Err(TuliproxError::Config(format!(
"Duplicate template names found across merged template sources: {}",
duplicate_templates.join("; ")
)));
}
let files_used = if loaded_template_files.is_empty() { None } else { Some(loaded_template_files) };
if merged_templates.is_empty() {
return Ok(PreparedTemplateBundle { definition: None, prepared: None, files_used });
}
let mut prepared_templates = merged_templates.clone();
prepare_templates(&mut prepared_templates)?;
Ok(PreparedTemplateBundle {
definition: Some(TemplateDefinitionDto { templates: merged_templates }),
prepared: Some(prepared_templates),
files_used,
})
}
pub async fn read_app_config_dto(
paths: &ConfigPaths,
resolve_env: bool,
include_computed: bool,
) -> Result<AppConfigDto, TuliproxError> {
let config_file = paths.config_file_path.as_str();
let sources_file = paths.sources_file_path.as_str();
let api_proxy_file = paths.api_proxy_file_path.as_str();
let config = read_config_file(config_file, resolve_env, include_computed)?;
// Resolve effective paths for templates and mappings (with robust fallbacks)
let (effective_template_path, effective_mapping_path) =
resolve_template_and_mapping_paths(paths, config.template_path.as_deref(), config.mapping_path.as_deref());
let mut sources = parse_sources_file_from_path(&PathBuf::from(sources_file), resolve_env).await?;
let mut mappings = read_mappings_file_unprepared(&effective_mapping_path, resolve_env)?.map(|(_, mapping)| mapping);
let template_bundle = read_templates(
Some(&effective_template_path),
resolve_env,
sources.templates.as_deref(),
mappings.as_ref().and_then(|mapping| mapping.mappings.templates.as_deref()),
)?;
let templates = template_bundle.definition;
let prepared_templates = template_bundle.prepared;
if resolve_env {
sources.prepare(include_computed, config.get_hdhr_device_overview().as_ref(), prepared_templates.as_deref())?;
if let Some(mapping) = mappings.as_mut() {
mapping.mappings.prepare(prepared_templates.as_deref())?;
}
}
// Keep templates centralized in AppConfigDto.templates.
sources.templates = None;
if let Some(mapping) = mappings.as_mut() {
mapping.mappings.templates = None;
}
let api_proxy = match read_api_proxy_file_with_templates(api_proxy_file, resolve_env, prepared_templates.as_deref())
{
Ok(api_proxy) => api_proxy,
Err(err) => {
// Surface the fault instead of silently returning a config without api_proxy
log::warn!("Failed to read api-proxy config '{api_proxy_file}': {err}");
None
}
};
Ok(AppConfigDto { config, sources, mappings, templates, api_proxy })
}
fn apply_prepared_mappings(
app_config: &mut AppConfig,
paths: &mut ConfigPaths,
mappings_file: &str,
mapping_paths: Option<&Vec<PathBuf>>,
mapping: &mut shared::model::MappingsDto,
prepared_templates: Option<&[PatternTemplate]>,
) {
match mapping.mappings.prepare(prepared_templates) {
Ok(()) => match tuliprox_core::model::CompiledMappings::try_from(&*mapping) {
Ok(mappings) => {
app_config.set_mappings(mappings_file, &mappings);
paths.mapping_files_used =
mapping_paths.map(|items| items.iter().map(|p| p.display().to_string()).collect::<Vec<String>>());
}
Err(err) => {
error!("Failed to compile mapping '{mappings_file}': {err}. Skipping mapping registration.");
paths.mapping_files_used = None;
}
},
Err(err) => {
error!("Failed to prepare mapping '{mappings_file}': {err}. Skipping mapping registration.");
paths.mapping_files_used = None;
}
}
}
pub async fn capture_batch_revisions(sources: &SourcesConfigDto) -> Vec<(PathBuf, blake3::Hash)> {
let mut revisions = Vec::new();
for input in &sources.inputs {
if input.input_type.is_batch() {
if let Ok(path) = tuliprox_repository::get_csv_file_path(&input.url) {
if let Ok(content) = fs::read(&path).await {
revisions.push((path, blake3::hash(&content)));
}
}
}
}
revisions
}
pub async fn prepare_sources_batch(
sources: &mut SourcesConfigDto,
include_computed: bool,
) -> Result<(), TuliproxError> {
let mut current_index = 0;
let max_id_in_source = sources
.inputs
.iter()
.flat_map(|item| {
std::iter::once(item.id).chain(item.aliases.as_ref().into_iter().flatten().map(|alias| alias.id))
})
.max()
.unwrap_or(0);
current_index = std::cmp::max(current_index, max_id_in_source);
for input in &mut sources.inputs {
// Normalize batch type from URL before deciding whether aliases must be loaded from CSV.
input.prepare_type()?;
match get_batch_aliases(input.input_type, input.url.as_str()).await {
Ok(Some((_, aliases))) => {
if let Some(idx) = input.prepare_batch(aliases, current_index)? {
current_index = idx;
}
}
Ok(None) => {}
Err(err) => {
error!("Failed to read config files aliases: {err}");
return Err(err);
}
}
// we need to prepare epg after alias, because epg `auto` depends on the first input url.
input.prepare_epg(include_computed)?;
}
Ok(())
}
pub async fn get_batch_aliases(
input_type: InputType,
url: &str,
) -> Result<Option<(PathBuf, Vec<ConfigInputAliasDto>)>, TuliproxError> {
if matches!(input_type, InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch) {
if url.starts_with(PROVIDER_SCHEME_PREFIX) {
return Err(TuliproxError::Config(format!(
"Batch input type '{input_type}' does not support provider:// URLs. \
Use a batch:// URL or a local CSV path (absolute/relative)."
)));
}
return match csv_read_inputs(input_type, url).await {
Ok((file_path, batch_aliases)) => Ok(Some((file_path, batch_aliases))),
Err(err) => Err(TuliproxError::Config(format!("{err}"))),
};
}
Ok(None)
}
pub async fn prepare_users(app_config_dto: &mut AppConfigDto, app_config: &AppConfig) -> Result<(), TuliproxError> {
let use_user_db = app_config_dto.api_proxy.as_ref().is_some_and(|p| p.use_user_db);
if use_user_db {
let user_db_path = get_api_user_db_path(app_config);
if file_exists_async(&user_db_path).await {
match load_api_user(app_config).await {
Ok(stored_users) => {
if let Some(api_proxy) = app_config_dto.api_proxy.as_mut() {
api_proxy.user.extend(stored_users.iter().map(TargetUserDto::from));
}
}
Err(err) => {
warn!("Failed to load users from DB at {}: {err}", user_db_path.display());
}
}
}
}
Ok(())
}
pub async fn read_initial_app_config(
paths: &mut ConfigPaths,
resolve_env: bool,
include_computed: bool,
server_mode: bool,
) -> Result<AppConfig, TuliproxError> {
let config_path = paths.config_path.as_str();
let config_file = paths.config_file_path.as_str();
let sources_file = paths.sources_file_path.as_str();
let config_dto = read_config_file(config_file, resolve_env, include_computed)?;
let configured_mapping_path = paths
.mapping_file_path
.as_deref()
.or(config_dto.mapping_path.as_deref())
.map(|path| if resolve_env { resolve_env_var(path) } else { path.to_string() });
let configured_template_path = paths
.template_file_path
.as_deref()
.or(config_dto.template_path.as_deref())
.map(|path| if resolve_env { resolve_env_var(path) } else { path.to_string() });
paths.mapping_file_path = Some(utils::resolve_mapping_file_path(config_path, configured_mapping_path.as_deref()));
paths.template_file_path =
Some(utils::resolve_template_file_path(config_path, configured_template_path.as_deref()));
let (mut sources_dto, source_revision) =
parse_sources_file_with_revision(&PathBuf::from(sources_file), resolve_env).await?;
let mut loaded_revisions = capture_batch_revisions(&sources_dto).await;
loaded_revisions.push((PathBuf::from(sources_file), source_revision));
let (mapping_paths, mut mappings_dto) = if let Some(mappings_file) = &paths.mapping_file_path {
match read_mappings_file_unprepared(mappings_file.as_str(), resolve_env) {
Ok(Some((mapping_paths, mappings))) => (Some(mapping_paths), Some(mappings)),
Ok(None) => (None, None),
Err(err) => return Err(err),
}
} else {
(None, None)
};
let template_bundle = read_templates(
paths.template_file_path.as_deref(),
resolve_env,
sources_dto.templates.as_deref(),
mappings_dto.as_ref().and_then(|mapping| mapping.mappings.templates.as_deref()),
)?;
let prepared_templates = template_bundle.prepared;
let template_files_used = template_bundle.files_used;
prepare_sources_batch(&mut sources_dto, include_computed).await?;
if resolve_env {
sources_dto.prepare(
include_computed,
config_dto.get_hdhr_device_overview().as_ref(),
prepared_templates.as_deref(),
)?;
}
sources_dto.templates = None;
let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?;
let mut config: Config = Config::from(config_dto);
config.prepare(config_path, paths.home_path.as_str())?;
paths.storage_path.clone_from(&config.storage_dir);
config.update_runtime();
let mut app_config = AppConfig {
config: Arc::new(ArcSwap::from_pointee(config)),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapAny::default()),
api_proxy: Arc::new(ArcSwapAny::default()),
paths: Arc::new(ArcSwap::from_pointee(paths.clone())),
file_locks: Arc::new(FileLockManager::default()),
custom_stream_response: Arc::new(ArcSwapAny::default()),
access_token_secret: generate_default_access_secret()?,
encrypt_secret: generate_default_encrypt_secret()?,
media_tools: Arc::new(MediaToolCapabilities::new()),
};
app_config.prepare(include_computed)?;
for (path, revision) in loaded_revisions {
app_config.file_locks.record_loaded_revision(&path, revision).await;
}
//print_info(&app_config);
if let Some(mappings_file) = paths.mapping_file_path.clone() {
if let Some(mapping) = mappings_dto.as_mut() {
apply_prepared_mappings(
&mut app_config,
paths,
mappings_file.as_str(),
mapping_paths.as_ref(),
mapping,
prepared_templates.as_deref(),
);
} else {
info!("Mapping file: not used");
}
}
let current_paths = app_config.paths.load_full();
let mut merged_paths = current_paths.as_ref().clone();
merged_paths.mapping_files_used = paths.mapping_files_used.clone();
merged_paths.template_files_used = template_files_used;
app_config.paths.store(Arc::new(merged_paths));
if server_mode {
match read_api_proxy_config(&app_config, resolve_env).await {
Ok(Some(api_proxy)) => app_config.set_api_proxy(api_proxy)?,
Ok(None) => info!("Api-Proxy file: not used"),
Err(err) => exit!("{err}"),
}
}
Ok(app_config)
}
pub async fn read_user_filter_templates(config: &AppConfig) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
let paths = config.paths.load();
let cfg = config.config.load();
load_prepared_global_templates(&paths, &cfg).await
}
pub async fn load_prepared_global_templates(
paths: &ConfigPaths,
config: &Config,
) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
let sources_inline_templates =
parse_sources_file_from_path(Path::new(&paths.sources_file_path), true).await?.templates;
// Use robust fallbacks for mapping and template paths
let (effective_template_path, effective_mapping_path) =
resolve_template_and_mapping_paths(paths, config.template_path.as_deref(), config.mapping_path.as_deref());
let mapping_inline_templates = read_mappings_file_unprepared(effective_mapping_path.as_ref(), true)?
.map(|(_, mapping)| mapping)
.and_then(|mapping| mapping.mappings.templates);
let template_bundle = read_templates(
Some(effective_template_path.as_ref()),
true,
sources_inline_templates.as_deref(),
mapping_inline_templates.as_deref(),
)?;
Ok(template_bundle.prepared)
}
pub fn read_api_proxy_file(
api_proxy_file: &str,
resolve_env: bool,
) -> Result<Option<ApiProxyConfigDto>, TuliproxError> {
read_api_proxy_file_with_templates(api_proxy_file, resolve_env, None)
}
pub fn read_api_proxy_file_with_templates(
api_proxy_file: &str,
resolve_env: bool,
templates: Option<&[PatternTemplate]>,
) -> Result<Option<ApiProxyConfigDto>, TuliproxError> {
open_file(&std::path::PathBuf::from(api_proxy_file)).map_or(Ok(None), |file| {
let maybe_api_proxy: Result<ApiProxyConfigDto, _> =
serde_saphyr::from_reader(config_file_reader(file, resolve_env));
match maybe_api_proxy {
Ok(mut api_proxy_dto) => {
if resolve_env {
// A recoverable error keeps the last good config alive during hot reload
if let Err(err) = api_proxy_dto.prepare_with_templates(templates) {
return Err(TuliproxError::Config(format!("can't read api-proxy-config file: {err}")));
}
}
Ok(Some(api_proxy_dto))
}
Err(err) => Err(TuliproxError::Config(format!("can't read api-proxy-config file: {err}"))),
}
})
}
pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Result<Option<ApiProxyConfig>, TuliproxError> {
read_api_proxy_config(config, resolve_env).await
}
async fn write_config_file<T>(
file_path: &str,
backup_dir: &str,
config: &T,
default_name: &str,
) -> Result<(), TuliproxError>
where
T: ?Sized + Serialize,
{
let mut serialized = String::new();
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
serde_saphyr::to_fmt_writer_with_options(&mut serialized, &config, options)
.map_err(|err| TuliproxError::Config(format!("Could not serialize config: {err}")))?;
write_config_text_file(file_path, backup_dir, &serialized, default_name, None).await?;
Ok(())
}
/// Atomically writes raw text content to a config file with backup.
///
/// Returns `Ok(true)` when the file was written, `Ok(false)` when the content was
/// already identical to the existing file (no-op).
///
/// When `expected_revision` is provided, the destination file is re-read and its
/// blake3 hash is compared before writing. A mismatch produces a conflict error
/// and the file is left untouched.
pub async fn write_config_text_file(
file_path: &str,
backup_dir: &str,
content: &str,
default_name: &str,
expected_revision: Option<blake3::Hash>,
) -> Result<bool, TuliproxError> {
let path = PathBuf::from(file_path);
let revision_content = if let Some(expected) = expected_revision {
let current = fs::read(&path).await.map_err(|err| {
TuliproxError::Config(format!(
"Could not re-read {} before applying an internal patch: {err}",
path.to_str().unwrap_or("?")
))
})?;
if blake3::hash(&current) != expected {
return Err(TuliproxError::Config(
"source.yml changed while an internal patch was being prepared; retrying later".to_string(),
));
}
Some(current)
} else {
None
};
let destination_exists = if let Some(current) = revision_content.as_deref() {
if current == content.as_bytes() {
return Ok(false);
}
true
} else {
let exists = file_exists_async(&path).await;
if exists && fs::read_to_string(&path).await.is_ok_and(|existing| existing == content) {
return Ok(false);
}
exists
};
if destination_exists {
fs::create_dir_all(backup_dir)
.await
.map_err(|err| TuliproxError::Config(format!("Could not create backup directory {backup_dir}: {err}")))?;
tuliprox_core::utils::backup_config_file(&path, Path::new(backup_dir))
.await
.map_err(|err| TuliproxError::Config(format!("Could not back up {}: {err}", path.display())))?;
info!("Saving file to {}", path.to_str().unwrap_or("?"));
}
let parent_dir = path.parent().ok_or_else(|| {
TuliproxError::Config(format!(
"Could not write file {}: missing parent directory",
path.to_str().unwrap_or("?")
))
})?;
let dest_file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or(default_name);
let mut tmp_path = parent_dir.to_path_buf();
tmp_path.push(format!(
".{dest_file_name}.tmp-{}-{}",
std::process::id(),
Local::now().timestamp_nanos_opt().unwrap_or_default()
));
let result = replace_with_temp_file(&path, &tmp_path, content).await;
if destination_exists {
if let Err(err) = tuliprox_core::utils::prune_config_backups(&path, Path::new(backup_dir)).await {
warn!("Could not prune configuration backups: {err}");
}
}
result
}
/// Writes `content` to `tmp_path` and atomically moves it over `path`.
async fn replace_with_temp_file(path: &Path, tmp_path: &Path, content: &str) -> Result<bool, TuliproxError> {
if let Err(err) = fs::write(tmp_path, content).await {
let _ = fs::remove_file(tmp_path).await;
return Err(TuliproxError::Config(format!(
"Could not write temp file {}: {err}",
tmp_path.to_str().unwrap_or("?")
)));
}
match fs::rename(tmp_path, path).await {
Ok(()) => Ok(true),
Err(err) => {
// Windows doesn't allow overwriting an existing file via rename.
#[cfg(windows)]
{
if replace_file_windows(tmp_path, path).is_ok() {
return Ok(true);
}
}
// Best-effort cleanup; if the temp file can't be removed, ignore it.
let _ = fs::remove_file(tmp_path).await;
Err(TuliproxError::Config(format!(
"Could not replace file {} with {}: {err}",
path.to_str().unwrap_or("?"),
tmp_path.to_str().unwrap_or("?")
)))
}
}
}
#[cfg(windows)]
fn replace_file_windows(source: &Path, target: &Path) -> std::io::Result<()> {
use std::{io, os::windows::ffi::OsStrExt};
use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH};
let mut source_wide: Vec<u16> = source.as_os_str().encode_wide().collect();
source_wide.push(0);
let mut target_wide: Vec<u16> = target.as_os_str().encode_wide().collect();
target_wide.push(0);
// SAFETY: both paths are NUL-terminated UTF-16 buffers that remain alive for
// the call. `MOVEFILE_REPLACE_EXISTING` leaves the target in place on error.
let ok = unsafe {
MoveFileExW(source_wide.as_ptr(), target_wide.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub async fn save_api_proxy(
file_path: &str,
backup_dir: &str,
config: &ApiProxyConfigDto,
) -> Result<(), TuliproxError> {
write_config_file(file_path, backup_dir, config, "api-proxy.yml").await
}
/// Path of the standalone plans file, sibling to api-proxy.yml.
pub fn plans_file_path(api_proxy_file_path: &str) -> PathBuf {
let parent = Path::new(api_proxy_file_path).parent();
let candidates = ["plans.yml", "plans.yaml", "plan.yml", "plan.yaml"];
if let Some(dir) = parent {
for candidate in candidates {
let p = dir.join(candidate);
if p.exists() {
return p;
}
}
dir.join("plans.yml")
} else {
for candidate in candidates {
let p = PathBuf::from(candidate);
if p.exists() {
return p;
}
}
PathBuf::from("plans.yml")
}
}
pub fn read_plans_file(plans_file: &str, resolve_env: bool) -> Result<Option<PlansConfigDto>, TuliproxError> {
open_file(&PathBuf::from(plans_file)).map_or(Ok(None), |file| {
let parsed: Result<PlansConfigDto, _> = serde_saphyr::from_reader(config_file_reader(file, resolve_env));
match parsed {
Ok(mut dto) => {
if resolve_env {
if let Err(err) = dto.prepare() {
return Err(TuliproxError::Config(format!("can't read plans file: {err}")));
}
}
Ok(Some(dto))
}
Err(err) => Err(TuliproxError::Config(format!("can't read plans file: {err}"))),
}
})
}
pub async fn save_plans(file_path: &str, backup_dir: &str, config: &PlansConfigDto) -> Result<(), TuliproxError> {
write_config_file(file_path, backup_dir, config, "plans.yml").await
}
/// Load plans from the standalone plans.yml onto the runtime api-proxy config
/// and re-resolve users. Migrates legacy plans embedded in api-proxy.yml to
/// plans.yml the first time it runs.
async fn apply_authoritative_plans(config: &AppConfig, api_proxy: &mut ApiProxyConfig, resolve_env: bool) {
let plans_path = {
let paths = 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, resolve_env) {
Ok(Some(plans_dto)) => {
let plans = plans_dto.plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect();
api_proxy.set_plans(plans);
}
Ok(None) => {
if let Some(legacy_plans) = read_legacy_api_proxy_plans(config, resolve_env) {
let migrated = PlansConfigDto { plans: legacy_plans.clone() };
let backup_dir = {
let cfg = config.config.load();
cfg.get_backup_dir().to_string()
};
match save_plans(&plans_path_str, &backup_dir, &migrated).await {
Ok(()) => {
info!("Migrated {} user plan(s) from api-proxy.yml to {plans_path_str}", migrated.plans.len());
}
Err(err) => error!("Failed to migrate plans to {plans_path_str}: {err}"),
}
let plans = legacy_plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect();
api_proxy.set_plans(plans);
}
}
Err(err) => error!("{err}"),
}
}
/// Read legacy plans still embedded in api-proxy.yml (pre plans.yml split).
fn read_legacy_api_proxy_plans(config: &AppConfig, resolve_env: bool) -> Option<Vec<UserPlanDto>> {
#[derive(serde::Deserialize)]
struct LegacyApiProxyPlans {
#[serde(default)]
plans: Vec<UserPlanDto>,
}
let api_proxy_file = {
let paths = config.paths.load();
paths.api_proxy_file_path.clone()
};
let file = open_file(&PathBuf::from(api_proxy_file.as_str())).ok()?;
let parsed: Result<LegacyApiProxyPlans, _> = serde_saphyr::from_reader(config_file_reader(file, resolve_env));
match parsed {
Ok(dto) if !dto.plans.is_empty() => Some(dto.plans),
_ => None,
}
}
pub async fn save_main_config(file_path: &str, backup_dir: &str, config: &ConfigDto) -> Result<(), TuliproxError> {
write_config_file(file_path, backup_dir, config, "config.yml").await
}
async fn save_sources_config<T>(file_path: &str, backup_dir: &str, config: &T) -> Result<(), TuliproxError>
where
T: ?Sized + Serialize,
{
write_config_file(file_path, backup_dir, config, "source.yml").await
}
pub async fn save_templates_config(
file_path: &str,
backup_dir: &str,
config: &TemplateDefinitionDto,
) -> Result<(), TuliproxError> {
write_config_file(file_path, backup_dir, config, TEMPLATE_FILE).await
}
async fn build_templates_to_persist(
app_config: &Arc<AppConfig>,
dto: &SourcesConfigDto,
) -> Result<Option<TemplateDefinitionDto>, TuliproxError> {
let mut new_dto = dto.clone();
let config = app_config.config.load();
let paths = app_config.paths.load();
let existing_source_inline_templates =
match parse_sources_file_from_path(Path::new(paths.sources_file_path.as_str()), true).await {
Ok(existing_sources) => existing_sources.templates,
Err(err) => {
warn!("Failed to read existing source.yml for template migration '{}': {err}", paths.sources_file_path);
None
}
};
let mapping_inline_templates = if let Some(mapping_file_path) = paths.mapping_file_path.as_ref() {
read_mappings_file_unprepared(mapping_file_path, true)?
.map(|(_, mapping)| mapping)
.and_then(|mapping| mapping.mappings.templates)
} else {
None
};
let source_inline_templates = if new_dto.templates.is_some() {
new_dto.templates.as_deref()
} else {
existing_source_inline_templates.as_deref()
};
let template_file_path = paths.template_file_path.as_deref().or(config.template_path.as_deref());
let template_bundle =
read_templates(template_file_path, true, source_inline_templates, mapping_inline_templates.as_deref())?;
let prepared_templates = template_bundle.prepared;
new_dto.prepare(true, config.get_hdhr_device_overview().as_ref(), prepared_templates.as_deref())?;
if let Some(templates) = new_dto.templates.clone() {
if templates.is_empty() {
Ok(None)
} else {
Ok(Some(TemplateDefinitionDto { templates }))
}
} else {
Ok(existing_source_inline_templates
.filter(|templates| !templates.is_empty())
.map(|templates| TemplateDefinitionDto { templates }))
}
}
pub async fn validate_source_config_for_persist(
app_config: &Arc<AppConfig>,
dto: &SourcesConfigDto,
) -> Result<Option<TemplateDefinitionDto>, TuliproxError> {
build_templates_to_persist(app_config, dto).await
}
pub async fn persist_templates_config(
app_config: &Arc<AppConfig>,
template_definition: &TemplateDefinitionDto,
) -> Result<(), TuliproxError> {
let config = app_config.config.load();
let paths = app_config.paths.load();
let template_file = utils::resolve_template_persist_file_path(
paths.template_file_path.as_deref().or(config.template_path.as_deref()),
&paths.config_path,
);
save_templates_config(&template_file, config.get_backup_dir().as_ref(), template_definition).await
}
/// Canonicalizes and rewrites the entire `source.yml` document.
///
/// This performs a whole-file YAML serialization: comments, blank lines, key
/// order and quoting are normalized away. It must only be used for an explicit
/// user edit (Source Editor save) or a controlled migration — never by
/// background tasks. Background mutations must go through the targeted
/// lossless patch pipeline (`api::source_yml_patch::execute_source_yml_patches`).
pub async fn replace_source_config_from_user_edit(
app_config: &Arc<AppConfig>,
source_file_path: Option<&Path>,
doc: SourcesConfigDto,
) -> Result<SourcesConfigDto, TuliproxError> {
let source_config = sanitize_sources_for_persist(doc.clone()).await;
persist_sanitized_source_config(app_config, source_file_path, doc, &source_config).await
}
async fn persist_sanitized_source_config(
app_config: &Arc<AppConfig>,
source_file_path: Option<&Path>,
doc: SourcesConfigDto,
source_config: &SourcesConfigDto,
) -> Result<SourcesConfigDto, TuliproxError> {
let source_file = {
source_file_path.and_then(|p| p.to_str()).map_or_else(
|| {
let paths = app_config.paths.load();
paths.sources_file_path.clone()
},
ToString::to_string,
)
};
let backup_dir = {
let config = app_config.config.load();
config.get_backup_dir().to_string()
};
save_sources_config(&source_file, &backup_dir, source_config).await?;
Ok(doc)
}
pub async fn sanitize_sources_for_persist(mut source_config: SourcesConfigDto) -> SourcesConfigDto {
source_config.templates = None;
for input in &mut source_config.inputs {
if input.panel_api.as_ref().is_some_and(|panel| panel.alias_pool.is_some()) {
if let Some(aliases) = input.aliases.as_mut() {
aliases.sort_by(|a, b| {
let a_ts = a.exp_date.unwrap_or(i64::MIN);
let b_ts = b.exp_date.unwrap_or(i64::MIN);
b_ts.cmp(&a_ts).then_with(|| a.name.cmp(&b.name))
});
}
}
if matches!(input.input_type, InputType::XtreamBatch | InputType::M3uBatch | InputType::StalkerBatch)
&& is_csv_file(input.url.as_str())
{
if let Some(aliases) = &input.aliases {
if let Err(err) = csv_write_inputs(input.url.as_str(), aliases).await {
error!("Could not persist aliases to csv {}: {}", input.url, err);
}
}
input.aliases = None;
}
input.id = 0;
if let Some(aliases) = input.aliases.as_mut() {
for alias in aliases {
alias.id = 0;
}
}
}
for source in &mut source_config.sources {
for target in &mut source.targets {
target.id = 0;
}
}
source_config
}
pub async fn persist_messaging_templates(
app_config: &Arc<AppConfig>,
cfg: &mut ConfigDto,
) -> Result<(), TuliproxError> {
let templates_dir = {
let paths = app_config.paths.load();
PathBuf::from(&paths.config_path).join("messaging_templates")
};
if let Some(messaging) = &mut cfg.messaging {
// Discord
if let Some(discord) = &mut messaging.discord {
for (kind, template) in &mut discord.templates {
*template = persist_single_template("discord", Some(kind.as_str()), template, &templates_dir).await?;
}
}
// Telegram
if let Some(telegram) = &mut messaging.telegram {
for (kind, template) in &mut telegram.templates {
*template = persist_single_template("telegram", Some(kind.as_str()), template, &templates_dir).await?;
}
}
// Rest
if let Some(rest) = &mut messaging.rest {
for (kind, template) in &mut rest.templates {
*template = persist_single_template("rest", Some(kind.as_str()), template, &templates_dir).await?;
}
}
// Pushover
if let Some(pushover) = &mut messaging.pushover {
for (kind, template) in &mut pushover.templates {
*template = persist_single_template("pushover", Some(kind.as_str()), template, &templates_dir).await?;
}
}
}
Ok(())
}
async fn persist_single_template(
prefix: &str,
event_key: Option<&str>,
template: &str,
templates_dir: &Path,
) -> Result<String, TuliproxError> {
if template.is_empty() || is_uri(template) {
return Ok(template.to_string());
}
// Treat existing file paths as file URLs
if tokio::fs::metadata(template).await.is_ok() {
if let Ok(canonical_path) = tokio::fs::canonicalize(template).await {
if let Ok(url) = Url::from_file_path(&canonical_path) {
return Ok(url.to_string());
}
}
return Ok(template.to_string());
}
// It's a raw string, persist it
if !file_exists_async(templates_dir).await {
tokio::fs::create_dir_all(templates_dir).await.map_err(|e| {
TuliproxError::Config(format!(
"Messaging templates dir: failed to create dir: {} {e}",
templates_dir.display()
))
})?;
}
// Persist under the canonical event id where the key resolves to one,
// so a config written with a legacy `MsgKind` name still lands on the
// filename the registry will look for.
let filename = event_key.map_or_else(
|| concat_string!(prefix, "_default.templ"),
|key| {
shared::model::notification::EventId::from_wire(key).map_or_else(
|| concat_string!(prefix, "_", &key.replace('.', "_"), ".templ"),
|id| id.template_filename(prefix),
)
},
);
let file_path = templates_dir.join(filename);
fs::write(&file_path, template)
.await
.map_err(|e| TuliproxError::Config(format!("Failed to write template file: {e}")))?;
Url::from_file_path(&file_path).map(|u| u.to_string()).map_err(|()| {
TuliproxError::Config(format!("Failed to convert persisted path to file URL: {}", file_path.display()))
})
}
// ---- API user migration (config file <-> user database) ----
//
// Was a method on `ApiProxyConfig`, then briefly a repository module. It writes
// the api-proxy config file, so it belongs with the rest of the configuration
// bootstrap; living in `repository` made that layer call back into this one.
fn serialize_api_proxy_config(config: &ApiProxyConfigDto) -> Result<String, String> {
let mut serialized = String::new();
let options = serde_saphyr::ser_options! {prefer_block_scalars: false};
serde_saphyr::to_fmt_writer_with_options(&mut serialized, config, options)
.map_err(|err| format!("Could not serialize api proxy config: {err}"))?;
Ok(serialized)
}
async fn api_proxy_file_would_change(api_proxy_file: &str, config: &ApiProxyConfigDto) -> Result<bool, String> {
let serialized = serialize_api_proxy_config(config)?;
match tokio::fs::read_to_string(api_proxy_file).await {
Ok(existing) => Ok(existing != serialized),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(true),
Err(err) => Err(format!("Could not read api proxy file {api_proxy_file}: {err}")),
}
}
async fn backfill_output_clusters_to_file(api_proxy: &ApiProxyConfig, cfg: &AppConfig, errors: &mut Vec<String>) {
if api_proxy.user.is_empty() {
return;
}
let paths = ArcSwap::load(&cfg.paths);
let api_proxy_file = paths.api_proxy_file_path.as_str();
let dto = ApiProxyConfigDto::from(api_proxy);
match api_proxy_file_would_change(api_proxy_file, &dto).await {
Ok(true) => {}
Ok(false) => return,
Err(err) => {
errors.push(err);
return;
}
}
let config = ArcSwap::load(&cfg.config);
let backup_dir = config.get_backup_dir();
if let Err(err) = save_api_proxy(api_proxy_file, backup_dir.as_ref(), &dto).await {
errors.push(format!("Error saving api proxy file: {err}"));
}
}
// we have the option to store user in the config file or in the user_db
// When we switch from one to other we need to migrate the existing data.
/// # Panics
pub async fn migrate_api_user(api_proxy: &mut ApiProxyConfig, cfg: &AppConfig, errors: &mut Vec<String>) {
let paths = ArcSwap::load(&cfg.paths);
let api_proxy_file = paths.api_proxy_file_path.as_str();
if api_proxy.use_user_db {
// we have user defined in config file.
// we migrate them to the db and delete them from the config file
if !&api_proxy.user.is_empty() {
if let Err(err) = merge_api_user(cfg, &api_proxy.user).await {
errors.push(err.to_string());
} else {
let config = ArcSwap::load(&cfg.config);
let backup_dir = config.get_backup_dir();
api_proxy.user = vec![];
if let Err(err) =
save_api_proxy(api_proxy_file, backup_dir.as_ref(), &ApiProxyConfigDto::from(&*api_proxy)).await
{
errors.push(format!("Error saving api proxy file: {err}"));
}
}
}
match load_api_user(cfg).await {
Ok(users) => {
let mut users = users;
api_proxy.resolve_target_users(&mut users);
api_proxy.user = users;
}
Err(err) => {
println!("{err}");
errors.push(err.to_string());
}
}
} else {
backfill_output_clusters_to_file(api_proxy, cfg, errors).await;
let user_db_path = get_api_user_db_path(cfg);
if file_exists_async(&user_db_path).await {
// we can't have user defined in db file.
// we need to load them and save them into the config file
if let Ok(stored_users) = load_api_user(cfg).await {
let mut stored_users = stored_users;
api_proxy.resolve_target_users(&mut stored_users);
for stored_user in stored_users {
if let Some(target_user) = api_proxy.user.iter_mut().find(|t| t.target == stored_user.target) {
for stored_credential in &stored_user.credentials {
if !target_user.credentials.iter().any(|c| c.username == stored_credential.username) {
target_user.credentials.push(stored_credential.clone());
}
}
} else {
api_proxy.user.push(stored_user);
}
}
}
let config = ArcSwap::load(&cfg.config);
let backup_dir = config.get_backup_dir();
let dto = ApiProxyConfigDto::from(&*api_proxy);
match api_proxy_file_would_change(api_proxy_file, &dto).await {
Ok(true) => {
if let Err(err) = save_api_proxy(api_proxy_file, backup_dir.as_ref(), &dto).await {
errors.push(format!("Error saving api proxy file: {err}"));
} else {
backup_api_user_db_file(cfg, &user_db_path).await;
let _ = tokio::fs::remove_file(&user_db_path).await;
}
}
Ok(false) => {
backup_api_user_db_file(cfg, &user_db_path).await;
let _ = tokio::fs::remove_file(&user_db_path).await;
}
Err(err) => errors.push(err),
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
get_batch_aliases, prepare_sources_batch, sanitize_sources_for_persist, write_config_file,
write_config_text_file,
};
use shared::{
model::{ConfigInputAliasDto, ConfigInputDto, InputType, SourcesConfigDto},
utils::Internable,
};
use tempfile::tempdir;
use tuliprox_core::utils::resolve_env_var;
#[tokio::test]
async fn api_proxy_template_failure_propagates_and_preserves_active_config(
) -> Result<(), Box<dyn std::error::Error>> {
use super::*;
let dir = tempdir()?;
let sources_path = dir.path().join("source.yml");
let template_path = dir.path().join("template.yml");
let proxy_path = dir.path().join("api-proxy.yml");
fs::write(&sources_path, "inputs: []\nsources: []\n").await?;
fs::write(&template_path, "templates: [").await?;
fs::write(&proxy_path, serde_saphyr::to_string(&ApiProxyConfigDto::default())?).await?;
let paths = ConfigPaths {
home_path: String::new(),
config_path: dir.path().to_string_lossy().into_owned(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: sources_path.to_string_lossy().into_owned(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: Some(template_path.to_string_lossy().into_owned()),
template_files_used: None,
api_proxy_file_path: proxy_path.to_string_lossy().into_owned(),
custom_stream_response_path: None,
};
assert!(read_api_proxy_file_with_templates(&paths.api_proxy_file_path, false, None)?.is_some());
let active = Arc::new(ApiProxyConfig::default());
let config = AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
hdhomerun: Arc::new(ArcSwapAny::default()),
api_proxy: Arc::new(ArcSwapAny::from(Some(Arc::clone(&active)))),
paths: Arc::new(ArcSwap::from_pointee(paths)),
file_locks: Arc::new(FileLockManager::default()),
custom_stream_response: Arc::new(ArcSwapAny::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
};
for resolve_env in [false, true] {
assert!(read_api_proxy(&config, resolve_env).await.is_err());
assert!(read_api_proxy_config(&config, resolve_env).await.is_err());
let Some(retained) = config.api_proxy.load_full() else {
return Err("active API-proxy configuration was cleared".into());
};
assert!(Arc::ptr_eq(&active, &retained));
}
Ok(())
}
#[tokio::test]
async fn user_filter_templates_resolve_env_in_all_sources() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let sources_path = dir.path().join("source.yml");
let mapping_path = dir.path().join("mapping.yml");
let template_path = dir.path().join("template.yml");
tokio::fs::write(
&sources_path,
r#"
inputs: []
sources: []
templates:
- name: source_group
value: '${env:PATH}'
"#,
)
.await?;
tokio::fs::write(
&mapping_path,
r#"
mappings:
templates:
- name: mapping_group
value: '${env:PATH}'
mapping: []
"#,
)
.await?;
tokio::fs::write(
&template_path,
r#"
templates:
- name: file_group
value: '${env:PATH}'
- name: combined
value: '!source_group!'
"#,
)
.await?;
let paths = shared::model::ConfigPaths {
home_path: String::new(),
config_path: dir.path().to_string_lossy().into_owned(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: sources_path.to_string_lossy().into_owned(),
mapping_file_path: Some(mapping_path.to_string_lossy().into_owned()),
mapping_files_used: None,
template_file_path: Some(template_path.to_string_lossy().into_owned()),
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
};
let Some(templates) =
super::load_prepared_global_templates(&paths, &tuliprox_core::model::Config::default()).await?
else {
return Err("missing templates".into());
};
let expected = std::env::var("PATH")?;
for name in ["source_group", "mapping_group", "file_group", "combined"] {
let Some(template) = templates.iter().find(|template| template.name == name) else {
return Err(format!("missing template {name}").into());
};
assert_eq!(template.value.to_string(), expected);
let raw = format!(r#"Group = "!{name}!""#);
let mut credential =
shared::model::ProxyUserCredentialsDto { filter: Some(raw.clone()), ..Default::default() };
credential.prepare_with_templates(Some(&templates))?;
assert_eq!(credential.filter.as_deref(), Some(raw.as_str()));
}
Ok(())
}
#[tokio::test]
async fn curation_item_limits_survive_source_load_sanitize_save_reload() {
let dir = tempdir().unwrap();
let path = dir.path().join("source.yml");
let document = r#"
inputs: []
sources:
- inputs: []
targets:
- name: discovery
enabled: false
output:
- type: xtream
curation:
enabled: false
tmdb:
enabled: false
api:
access_token: synthetic-test-token
trending:
- kind: movie
time_window: week
limit: 37
create_xtream_category: false
category_name: Saved label
- kind: tv
time_window: day
limit: 100
- kind: movie
time_window: day
"#;
tokio::fs::write(&path, document).await.unwrap();
let dto = super::parse_sources_file_from_path(&path, false).await.unwrap();
let expected = dto.sources[0].targets[0].curation.clone();
let sanitized = sanitize_sources_for_persist(dto).await;
super::save_sources_config(path.to_str().unwrap(), dir.path().join("backup").to_str().unwrap(), &sanitized)
.await
.unwrap();
let restored = super::parse_sources_file_from_path(&path, false).await.unwrap();
assert_eq!(restored.sources[0].targets[0].curation, expected);
let yaml = tokio::fs::read_to_string(&path).await.unwrap();
assert!(yaml.contains("limit: 37"));
assert!(!yaml.contains("limit: 100"));
tokio::fs::write(&path, document.replace("limit: 37", "scope: first_page")).await.unwrap();
assert!(super::parse_sources_file_from_path(&path, false).await.is_err());
}
#[test]
#[allow(clippy::manual_unwrap_or_default)]
fn test_resolve() {
// Use PATH which exists on both Windows and Unix
let resolved = resolve_env_var("${env:PATH}");
let expected = match std::env::var("PATH") {
Ok(value) => value,
Err(_) => String::new(),
};
assert_eq!(resolved, expected);
}
#[tokio::test]
async fn batch_provider_scheme_returns_clear_error() {
let result = get_batch_aliases(InputType::XtreamBatch, "provider://my_provider").await;
let err = result.expect_err("provider:// must not be accepted for batch inputs");
assert!(err.to_string().contains("does not support provider:// URLs"));
}
#[tokio::test]
async fn prepare_sources_batch_skips_csv_for_non_batch_url_even_with_batch_type() {
let mut sources = SourcesConfigDto {
inputs: vec![ConfigInputDto {
name: "input_http_aliases".intern(),
input_type: InputType::XtreamBatch,
url: "http://localhost:3001".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
..ConfigInputDto::default()
}],
..SourcesConfigDto::default()
};
prepare_sources_batch(&mut sources, false)
.await
.expect("non-batch URL must not be treated as CSV batch source");
assert_eq!(sources.inputs[0].input_type, InputType::Xtream);
}
#[tokio::test]
async fn stalker_batch_loads_and_persists_csv_aliases() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("stalker.csv");
std::fs::write(&path, "#name;username;password;url;max_connections\nalias;user;pass;http://portal.example;1\n")
.expect("write csv");
let (_, aliases) = get_batch_aliases(InputType::StalkerBatch, path.to_string_lossy().as_ref())
.await
.expect("read batch")
.expect("stalker batch aliases");
assert_eq!(aliases.len(), 1);
let sources = SourcesConfigDto {
inputs: vec![ConfigInputDto {
input_type: InputType::StalkerBatch,
url: path.to_string_lossy().into_owned(),
aliases: Some(vec![ConfigInputAliasDto { name: "updated".intern(), ..Default::default() }]),
..Default::default()
}],
..Default::default()
};
let sanitized = sanitize_sources_for_persist(sources).await;
assert!(sanitized.inputs[0].aliases.is_none());
assert!(std::fs::read_to_string(path).expect("read persisted csv").contains("updated"));
}
#[tokio::test]
async fn config_write_stops_when_backup_fails() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let config_path = dir.path().join("source.yml");
let invalid_backup_dir = dir.path().join("not-a-directory");
tokio::fs::write(&config_path, "old").await?;
tokio::fs::write(&invalid_backup_dir, "file").await?;
let result = write_config_file(
config_path.to_string_lossy().as_ref(),
invalid_backup_dir.to_string_lossy().as_ref(),
&"new",
"source.yml",
)
.await;
assert!(result.is_err());
assert_eq!(tokio::fs::read_to_string(config_path).await?, "old");
Ok(())
}
#[tokio::test]
async fn text_write_identical_content_is_noop() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
tokio::fs::write(&path, "same content").await?;
let written = write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"same content",
"source.yml",
None,
)
.await?;
assert!(!written);
assert_eq!(tokio::fs::read_to_string(&path).await?, "same content");
Ok(())
}
#[tokio::test]
async fn text_write_replaces_content_and_creates_backup() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
let backup_dir = dir.path().join("backup");
tokio::fs::write(&path, "old content").await?;
let written = write_config_text_file(
path.to_string_lossy().as_ref(),
backup_dir.to_string_lossy().as_ref(),
"new content",
"source.yml",
None,
)
.await?;
assert!(written);
assert_eq!(tokio::fs::read_to_string(&path).await?, "new content");
let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with("source.yml-"))
.map(|entry| entry.path())
.collect();
backups.sort();
assert_eq!(backups.len(), 1);
assert_eq!(tokio::fs::read_to_string(&backups[0]).await?, "old content");
Ok(())
}
#[tokio::test]
async fn text_write_preserves_crlf_and_unicode() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
let content = "key: \"héllo wörld\"\r\nother: value\r\n";
tokio::fs::write(&path, "placeholder").await?;
write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
content,
"source.yml",
None,
)
.await?;
assert_eq!(tokio::fs::read_to_string(&path).await?, content);
Ok(())
}
#[tokio::test]
async fn text_write_revision_conflict_leaves_file_untouched() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
tokio::fs::write(&path, "original").await?;
let stale_revision = blake3::hash(b"different content");
let result = write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"patched",
"source.yml",
Some(stale_revision),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("changed while an internal patch was being prepared"));
assert_eq!(tokio::fs::read_to_string(&path).await?, "original");
Ok(())
}
#[tokio::test]
async fn text_write_matching_revision_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
tokio::fs::write(&path, "original").await?;
let revision = blake3::hash(b"original");
let written = write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"patched",
"source.yml",
Some(revision),
)
.await?;
assert!(written);
assert_eq!(tokio::fs::read_to_string(&path).await?, "patched");
Ok(())
}
#[tokio::test]
async fn text_write_matching_revision_and_identical_content_is_noop() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
tokio::fs::write(&path, "original").await?;
let revision = blake3::hash(b"original");
let written = write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"original",
"source.yml",
Some(revision),
)
.await?;
assert!(!written);
assert!(!dir.path().join("backup").exists());
Ok(())
}
#[tokio::test]
async fn text_write_missing_destination_is_a_revision_conflict() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
let revision = blake3::hash(b"original");
let result = write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"patched",
"source.yml",
Some(revision),
)
.await;
assert!(result.is_err());
assert!(!path.exists());
Ok(())
}
#[tokio::test]
async fn text_write_preserves_missing_final_newline() -> Result<(), Box<dyn std::error::Error>> {
let dir = tempdir()?;
let path = dir.path().join("source.yml");
tokio::fs::write(&path, "old\n").await?;
write_config_text_file(
path.to_string_lossy().as_ref(),
dir.path().join("backup").to_string_lossy().as_ref(),
"key: value",
"source.yml",
None,
)
.await?;
assert_eq!(tokio::fs::read_to_string(&path).await?, "key: value");
Ok(())
}
}