Files
tuliprox/backend/app/src/api/endpoints/v1_api_config.rs
T
euzuandGitHub b6bab647df feat(watch): group added/removed contexts per watch entry (#912)
* feat(watch): group added/removed contexts per watch entry

- watch entries: regex string or {pattern, on:
[channels|group_added|group_removed]}
- one playlist.watch.changed per refresh with WatchChange list;
playlist.groups.changed aliased
- Telegram/ntfy message length limits
- web UI: Mapping and Watch sub pages, new Mapping icon
- ClusterFlags on create_bitset!, bitflags removed

BREAKING CHANGE: playlist.watch.changed templates must use the new
watch.* fields

* feat(watch): group added/removed contexts per watch entry

- watch entries: regex string or {pattern, on:
[channels|group_added|group_removed]}
- one playlist.watch.changed per refresh with WatchChange list;
playlist.groups.changed aliased
- Telegram/ntfy message length limits
- web UI: Mapping and Watch sub pages, new Mapping icon
- ClusterFlags on create_bitset!, bitflags removed

BREAKING CHANGE: playlist.watch.changed templates must use the new
watch.* fields
2026-10-12 21:05:55 +02:00

1486 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>,
}
/// 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<AppState>, 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<String> = 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<AppState>,
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<Option<Vec<u8>>, 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<AppState>,
should_persist: bool,
) -> Result<Option<(String, Option<Vec<u8>>)>, 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<AppState>,
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<String, std::io::Error> {
match tokio::fs::read(path).await {
Ok(bytes) => Ok(file_revision_from_bytes(&bytes)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok("missing".to_string()),
Err(err) => Err(err),
}
}
fn response_with_revision_header(
mut response: axum::response::Response,
revision_header: &'static str,
revision: &str,
) -> axum::response::Response {
let Ok(header_name) = HeaderName::try_from(revision_header) else {
return response;
};
let Ok(header_value) = HeaderValue::from_str(revision) else {
return response;
};
response.headers_mut().insert(header_name, header_value);
response
}
fn require_matching_revision(
headers: &HeaderMap,
current_revision: &str,
revision_header: &'static str,
file_label: &str,
) -> Option<axum::response::Response> {
let if_match = headers.get(IF_MATCH).and_then(|value| value.to_str().ok()).map(str::trim);
let Some(if_match) = if_match.filter(|value| !value.is_empty()) else {
let response = (
StatusCode::PRECONDITION_REQUIRED,
axum::Json(json!({
"error": format!("Missing required '{}' header for {file_label}", HEADER_IF_MATCH)
})),
)
.into_response();
return Some(response_with_revision_header(response, revision_header, current_revision));
};
if if_match != current_revision {
let response = (
StatusCode::CONFLICT,
axum::Json(json!({
"error": format!("{file_label} changed on server. Reload configuration and retry save."),
})),
)
.into_response();
return Some(response_with_revision_header(response, revision_header, current_revision));
}
None
}
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<PermissionSet> {
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<PermissionSet>) {
// 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<TuliproxError> {
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<TuliproxError> {
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<AppState>, 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<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Json(mut cfg): axum::extract::Json<ConfigDto>,
) -> 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, &current_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<String> = 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<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Json(sources): axum::extract::Json<SourcesConfigDto>,
) -> 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, &current_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<Arc<AppState>>,
) -> 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<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Json(mut req_api_proxy): axum::extract::Json<ApiProxyConfigDto>,
) -> 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, &current_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<AppState>, permissions: Option<PermissionSet>) -> 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<Arc<AppState>>,
) -> impl IntoResponse + Send {
get_config_common(&app_state, None).await
}
async fn config(
AuthBearer(token): AuthBearer,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> 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<Arc<AppState>>,
) -> 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<u16>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> 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<Arc<AppState>>,
axum::extract::Json(request): axum::extract::Json<XtreamLoginRequest>,
) -> impl IntoResponse + Send {
if request.url.trim().is_empty() {
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "URL is required"}))).into_response();
}
if request.username.trim().is_empty() {
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Username is required"}))).into_response();
}
if request.password.trim().is_empty() {
return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Password is required"}))).into_response();
}
let 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<crate::model::ConfigProvider>],
) -> Result<InputSource, TuliproxError> {
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<AppState>) -> Result<PlansConfigDto, TuliproxError> {
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<Arc<AppState>>,
) -> 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<Arc<AppState>>,
) -> 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<Arc<AppState>>,
axum::extract::Json(mut req_plans): axum::extract::Json<PlansConfigDto>,
) -> 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<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
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<AppState>) -> Router<Arc<AppState>> {
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<String>,
/// Restrict to one channel by its stable id (`telegram`, `discord`, ...).
/// Absent means every configured channel.
#[serde(default)]
pub channel: Option<String>,
/// 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<String>,
/// 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<MessagingTestChannelResult>,
}
/// 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<Arc<AppState>>,
axum::Json(request): axum::Json<MessagingTestRequest>,
) -> 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));
}
}