Files
tuliprox/backend/app/src/api/endpoints/v1_api_config.rs
T

1486 lines
62 KiB
Rust
Raw Normal View History

2026-08-27 18:36:23 +05:00
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::{
2026-09-30 17:28:58 +02:00
persist_messaging_templates, plans_file_path, prepare_sources_batch, prepare_users,
read_api_proxy_file_with_templates, read_plans_file, save_plans,
2026-08-27 18:36:23 +05:00
},
iptv::xtream::{get_xtream_stream_url_base, xtream_login},
2026-10-04 16:02:55 +02:00
model::{
recording_reload_outcome, validate_library_paths_from_dto, ApiProxyConfig, InputSource, RecordingReloadOutcome,
UserPlan,
},
2026-08-27 18:36:23 +05:00
utils::request::download_text_content,
};
use axum::{
http::{header::IF_MATCH, HeaderMap, HeaderName, HeaderValue, StatusCode},
response::IntoResponse,
Router,
};
use log::error;
2025-09-26 14:13:30 +02:00
use serde_json::json;
use shared::{
error::TuliproxError,
2026-08-27 18:36:23 +05:00
model::{
permission::{Permission, PermissionSet},
2026-09-19 10:17:23 +02:00
ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigDto, ConfigTargetDto, InputFetchMethod, PlansConfigDto,
SourcesConfigDto, XtreamLoginRequest,
2026-08-27 18:36:23 +05:00
},
utils::{
2026-04-09 22:06:01 +02:00
parse_provider_scheme_url_parts, HEADER_CONFIG_API_PROXY_REVISION, HEADER_CONFIG_MAIN_REVISION,
HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH, PROVIDER_SCHEME_PREFIX,
},
};
2026-08-27 18:36:23 +05:00
use std::{collections::HashMap, path::Path, sync::Arc};
2025-09-26 14:13:30 +02:00
fn file_revision_from_bytes(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() }
2026-09-03 17:59:54 +02:00
/// 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
}
2026-03-20 15:31:19 +01:00
fn has_any_permission(permissions: PermissionSet, required: &[Permission]) -> bool {
required.iter().any(|permission| permissions.contains(*permission))
}
2026-08-29 15:32:08 +02:00
/// 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.
2026-03-20 15:31:19 +01:00
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()?;
2026-08-29 15:32:08 +02:00
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),
)
2026-03-20 15:31:19 +01:00
}
2026-09-19 10:17:23 +02:00
/// 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() }
}
2026-03-20 15:31:19 +01:00
fn filter_api_proxy_by_permissions(api_proxy: &mut ApiProxyConfigDto, permissions: PermissionSet) {
if !permissions.contains(Permission::ConfigRead) {
2026-09-19 10:17:23 +02:00
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;
2026-03-20 15:31:19 +01:00
}
if !permissions.contains(Permission::UserRead) {
api_proxy.user.clear();
}
}
2026-09-19 10:17:23 +02:00
/// 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() }
}
2026-06-08 19:29:45 +02:00
fn filter_app_config_by_permissions(app_config: &mut shared::model::AppConfigDto, permissions: Option<PermissionSet>) {
2026-08-29 15:32:08 +02:00
// 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();
}
2026-06-08 19:29:45 +02:00
if let Some(permissions) = permissions {
if !permissions.contains(Permission::ConfigRead) {
app_config.config = ConfigDto::default();
2026-03-20 15:31:19 +01:00
}
2026-06-08 19:29:45 +02:00
if !permissions.contains(Permission::SourceRead) {
app_config.mappings = None;
app_config.templates = None;
2026-09-19 10:17:23 +02:00
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();
}
2026-06-08 19:29:45 +02:00
}
2026-03-20 15:31:19 +01:00
2026-06-08 19:29:45 +02:00
if let Some(api_proxy) = app_config.api_proxy.as_mut() {
2026-09-19 10:17:23 +02:00
filter_api_proxy_by_permissions(api_proxy, permissions);
2026-03-20 15:31:19 +01:00
}
}
}
pub(in crate::api::endpoints) async fn intern_save_config_api_proxy(
backup_dir: &str,
api_proxy: &ApiProxyConfigDto,
file_path: &str,
) -> Option<TuliproxError> {
2026-08-27 18:36:23 +05:00
match crate::config_loader::save_api_proxy(file_path, backup_dir, api_proxy).await {
2025-09-26 14:13:30 +02:00
Ok(()) => {}
Err(err) => {
error!("Failed to save api-proxy.yml {err}");
2025-09-26 14:13:30 +02:00
return Some(err);
}
}
None
}
2025-11-19 15:13:33 -06:00
async fn intern_save_config_main(file_path: &str, backup_dir: &str, cfg: &ConfigDto) -> Option<TuliproxError> {
2026-08-27 18:36:23 +05:00
match crate::config_loader::save_main_config(file_path, backup_dir, cfg).await {
2025-09-26 14:13:30 +02:00
Ok(()) => {}
Err(err) => {
error!("Failed to save config.yml {err}");
return Some(err);
}
}
None
}
2026-10-04 16:02:55 +02:00
/// 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)
}
2025-09-26 14:13:30 +02:00
async fn save_config_main(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
headers: HeaderMap,
2026-01-25 12:41:18 +01:00
axum::extract::Json(mut cfg): axum::extract::Json<ConfigDto>,
2025-09-26 14:13:30 +02:00
) -> impl axum::response::IntoResponse + Send {
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;
}
2026-08-29 15:32:08 +02:00
// 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));
}
}
2026-02-23 11:46:21 +01:00
if let Err(err) = cfg.prepare(false) {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
if !cfg.is_valid() {
2026-10-04 16:02:55 +02:00
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) {
2026-02-23 11:46:21 +01:00
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
} else {
2026-08-27 18:36:23 +05:00
if let Err(err) = persist_messaging_templates(&app_state.app_config, &mut cfg).await {
2026-01-25 12:41:18 +01:00
error!("Failed to persist messaging templates: {err}");
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
.into_response();
2026-01-25 12:41:18 +01:00
}
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();
2025-09-26 14:13:30 +02:00
}
let updated_revision = match read_file_revision(&file_path).await {
Ok(revision) => revision,
Err(err) => {
error!("Failed to read updated revision for config.yml '{file_path}': {err}");
return internal_server_error!();
}
};
2026-10-04 16:02:55 +02:00
// 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)
2025-09-26 14:13:30 +02:00
}
}
2025-12-17 19:31:07 +01:00
async fn save_config_sources(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
headers: HeaderMap,
axum::extract::Json(sources): axum::extract::Json<SourcesConfigDto>,
2025-12-17 19:31:07 +01:00
) -> impl axum::response::IntoResponse + Send {
2026-09-03 17:59:54 +02:00
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;
}
2026-09-03 17:59:54 +02:00
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!();
}
};
2026-08-27 18:36:23 +05:00
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();
}
};
2025-12-17 19:31:07 +01:00
2026-09-03 17:59:54 +02:00
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!();
}
};
2026-02-23 17:00:11 +01:00
if let Some(template_definition) = templates_to_persist.as_ref() {
2026-08-27 18:36:23 +05:00
if let Err(err) =
crate::config_loader::persist_templates_config(&app_state.app_config, template_definition).await
{
2026-02-23 17:00:11 +01:00
error!("Failed to save template config {err}");
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
2026-02-23 17:00:11 +01:00
.into_response();
2025-12-17 19:31:07 +01:00
}
}
2026-02-23 17:00:11 +01:00
2026-09-03 17:59:54 +02:00
// Derive lifecycle changes from the currently loaded configuration before it is replaced.
let bouquet_mutations = build_bouquet_mutation_batch(&app_state, &sources);
2026-08-30 21:08:30 +02:00
match crate::config_loader::replace_source_config_from_user_edit(&app_state.app_config, None, sources).await {
2026-02-23 17:00:11 +01:00
Ok(_) => {}
Err(err) => {
error!("Failed to persist source.yml {err}");
2026-09-03 17:59:54 +02:00
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()})))
2026-02-23 17:00:11 +01:00
.into_response();
}
}
2026-09-03 17:59:54 +02:00
// 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;
2026-02-23 17:00:11 +01:00
}
2026-09-19 10:17:23 +02:00
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)
2025-12-17 19:31:07 +01:00
}
2026-03-20 15:31:19 +01:00
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!();
}
};
2026-09-30 17:28:58 +02:00
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!()
}
2025-09-26 14:13:30 +02:00
async fn save_config_api_proxy_config(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
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!();
}
};
2026-08-27 18:36:23 +05:00
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 {
2025-09-26 14:13:30 +02:00
if !server_info.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"})))
.into_response();
2025-09-26 14:13:30 +02:00
}
}
2025-09-29 15:39:27 +02:00
// TODO if hot reload is on, it is loaded twice, avoid this
2025-09-30 10:54:30 +02:00
// Build the updated config without mutating global state yet
let base = app_state.app_config.api_proxy.load().as_deref().cloned().unwrap_or_default();
2025-09-30 10:54:30 +02:00
let updated_api_proxy = ApiProxyConfig {
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,
2025-09-30 10:54:30 +02:00
..base
2025-09-29 15:39:27 +02:00
};
2025-09-30 10:54:30 +02:00
2026-08-21 17:50:12 +05:00
// 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);
2026-09-30 17:28:58 +02:00
if let Err(err) = updated_api_proxy_dto.prepare_with_templates(updated_api_proxy.templates.as_deref()) {
2026-08-27 18:36:23 +05:00
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
2026-08-21 17:50:12 +05:00
}
2026-08-27 18:36:23 +05:00
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();
2025-09-26 14:13:30 +02:00
}
2026-08-21 17:50:12 +05:00
// 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).
2026-09-30 17:28:58 +02:00
let mut stored_api_proxy =
ApiProxyConfig::from_dto_with_templates(&updated_api_proxy_dto, updated_api_proxy.templates.clone());
2026-08-21 17:50:12 +05:00
stored_api_proxy.set_plans(stored_plans);
app_state.app_config.api_proxy.store(Some(Arc::new(stored_api_proxy)));
2025-09-30 10:54:30 +02:00
let updated_revision = match read_file_revision(&api_proxy_file_path).await {
Ok(revision) => revision,
Err(err) => {
error!("Failed to read updated revision for api-proxy.yml '{api_proxy_file_path}': {err}");
return internal_server_error!();
}
};
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_API_PROXY_REVISION, &updated_revision)
2025-09-26 14:13:30 +02:00
}
2026-06-08 19:29:45 +02:00
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();
2026-08-27 18:36:23 +05:00
(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();
2026-08-27 18:36:23 +05:00
crate::config_loader::read_app_config_dto(&paths, true, false).await
};
match read_result {
2025-09-26 14:13:30 +02:00
Ok(mut app_config) => {
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await {
2025-09-26 14:13:30 +02:00
error!("Failed to prepare sources batch: {err}");
2026-01-20 10:09:58 +01:00
internal_server_error!()
2025-11-14 09:49:48 +01:00
} else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await {
2025-09-26 14:13:30 +02:00
error!("Failed to prepare users: {err}");
2026-01-20 10:09:58 +01:00
internal_server_error!()
2025-09-26 14:13:30 +02:00
} else {
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);
2026-08-27 18:36:23 +05:00
let response =
response_with_revision_header(response, HEADER_CONFIG_SOURCES_REVISION, &sources_revision);
response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &api_proxy_revision)
2025-09-26 14:13:30 +02:00
}
}
Err(err) => {
error!("Failed to read config files: {err}");
2026-01-20 10:09:58 +01:00
internal_server_error!()
2025-09-26 14:13:30 +02:00
}
}
}
2026-08-27 18:36:23 +05:00
async fn config_unprotected(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl IntoResponse + Send {
2026-06-08 19:29:45 +02:00
get_config_common(&app_state, None).await
}
2026-03-20 15:31:19 +01:00
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();
};
2026-09-19 10:17:23 +02:00
if !has_any_permission(
permissions,
&[
Permission::ConfigRead,
Permission::SourceRead,
Permission::UserRead,
Permission::UserWrite,
Permission::PlaylistRead,
Permission::PlaylistWrite,
],
) {
2026-03-20 15:31:19 +01:00
return axum::http::StatusCode::FORBIDDEN.into_response();
}
2026-06-08 19:29:45 +02:00
get_config_common(&app_state, Some(permissions)).await
2026-03-20 15:31:19 +01:00
}
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();
};
2026-09-19 10:17:23 +02:00
if !has_any_permission(permissions, &[Permission::ConfigRead, Permission::UserRead, Permission::UserWrite]) {
2026-03-20 15:31:19 +01:00
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!();
}
};
2026-09-30 17:28:58 +02:00
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()),
) {
2026-03-20 15:31:19 +01:00
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!()
}
}
}
2025-09-26 14:13:30 +02:00
async fn config_batch_content(
axum::extract::Path(input_id): axum::extract::Path<u16>,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
) -> impl IntoResponse + Send {
2025-09-26 14:13:30 +02:00
if let Some(config_input) = app_state.app_config.get_input_by_id(input_id) {
// The url is changed at this point, we need the raw url for the batch file
if let Some(batch_url) = config_input.t_batch_url.as_ref() {
let input_source = InputSource::from(&*config_input).with_url(batch_url.to_owned());
2026-01-20 18:51:46 +01:00
return match download_text_content(
&app_state.app_config,
&app_state.http_clients.default.load(),
2026-01-20 18:51:46 +01:00
&input_source,
None,
None,
false,
)
2026-08-27 18:36:23 +05:00
.await
2026-01-20 18:51:46 +01:00
{
2025-09-26 14:13:30 +02:00
Ok((content, _path)) => {
// Return CSV with explicit content-type
try_unwrap_body!(axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8")
.body(content))
}
Err(err) => {
error!("Failed to read batch file: {err}");
2026-01-20 10:09:58 +01:00
internal_server_error!()
2025-09-26 14:13:30 +02:00
}
};
}
}
(axum::http::StatusCode::NOT_FOUND, axum::Json(json!({"error": "Input not found or batch URL missing"})))
.into_response()
2025-09-26 14:13:30 +02:00
}
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();
}
2026-04-09 22:06:01 +02:00
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();
2026-08-29 15:32:08 +02:00
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}");
2026-08-27 18:36:23 +05:00
(StatusCode::BAD_GATEWAY, axum::Json(json!({"error": "Failed to get Xtream login info"}))).into_response()
}
}
}
2026-04-09 22:06:01 +02:00
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)?;
2026-08-27 18:36:23 +05:00
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)
});
2026-04-09 22:06:01 +02:00
Some(
request_provider
.into_iter()
.chain(providers.iter().cloned())
2026-04-09 22:06:01 +02:00
.find(|provider| provider.name.as_ref() == provider_name)
2026-08-27 18:36:23 +05:00
.ok_or_else(|| {
TuliproxError::ConfigInput(format!("Provider config for '{provider_name}' not found"))
})?,
2026-04-09 22:06:01 +02:00
)
} 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(),
})
}
2026-09-19 10:17:23 +02:00
fn get_config_plans_dto(app_state: &Arc<AppState>) -> Result<PlansConfigDto, TuliproxError> {
2026-08-21 17:50:12 +05:00
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) {
2026-09-19 10:17:23 +02:00
Ok(Some(dto)) => Ok(dto),
Ok(None) => Ok(PlansConfigDto::default()),
2026-08-21 17:50:12 +05:00
Err(err) => {
error!("Failed to read plans config: {err}");
2026-09-19 10:17:23 +02:00
Err(err)
2026-08-21 17:50:12 +05:00
}
}
}
2026-09-19 10:17:23 +02:00
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!(),
}
}
2026-08-21 17:50:12 +05:00
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()
}
2025-09-26 14:13:30 +02:00
pub fn v1_api_config_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
router
2026-06-08 19:29:45 +02:00
.route("/config", axum::routing::get(config_unprotected))
2025-09-26 14:13:30 +02:00
.route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content))
.route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info))
2025-09-26 14:13:30 +02:00
.route("/config/main", axum::routing::post(save_config_main))
2025-12-17 19:31:07 +01:00
.route("/config/sources", axum::routing::post(save_config_sources))
2026-08-27 18:36:23 +05:00
.route(
"/config/apiproxy",
axum::routing::get(get_config_api_proxy_config_public).put(save_config_api_proxy_config),
)
2026-09-19 10:17:23 +02:00
.route("/config/plans", axum::routing::get(get_config_plans_unprotected).put(save_config_plans))
2025-09-26 14:13:30 +02:00
}
2026-03-20 15:31:19 +01:00
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))
2026-09-19 10:17:23 +02:00
.route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config))
.route("/config/plans", axum::routing::get(get_config_plans));
2026-08-21 17:50:12 +05:00
2026-03-20 15:31:19 +01:00
// 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()
2026-08-29 15:32:08 +02:00
.route("/config/messaging/test", axum::routing::post(test_messaging))
2026-03-20 15:31:19 +01:00
.route("/config/main", axum::routing::post(save_config_main))
.route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config))
2026-08-21 17:50:12 +05:00
.route("/config/plans", axum::routing::put(save_config_plans))
2026-03-20 15:31:19 +01:00
.layer(permission_layer!(app_state, Permission::ConfigWrite));
2026-09-19 10:17:23 +02:00
Router::new().merge(base_read).merge(source_read).merge(source_write).merge(config_write)
2026-03-20 15:31:19 +01:00
}
2026-08-29 15:32:08 +02:00
/// 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();
2026-08-29 15:32:08 +02:00
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 {
2026-04-09 22:06:01 +02:00
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,
};
2026-03-20 15:31:19 +01:00
use shared::{
model::{
2026-04-09 22:06:01 +02:00
ApiProxyConfigDto, ApiProxyServerInfoDto, AppConfigDto, ConfigDto, ConfigProviderDto, Permission,
2026-08-27 18:36:23 +05:00
PermissionSet, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, XtreamLoginRequest,
2026-03-20 15:31:19 +01:00
},
utils::{HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH},
};
2026-04-09 22:06:01 +02:00
use std::sync::Arc;
#[test]
fn require_matching_revision_rejects_missing_if_match_header() {
let headers = HeaderMap::new();
2026-08-27 18:36:23 +05:00
let response = require_matching_revision(&headers, "rev-a", HEADER_CONFIG_SOURCES_REVISION, "source.yml")
2026-06-08 19:29:45 +02:00
.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();
2026-08-27 18:36:23 +05:00
headers.insert(HEADER_IF_MATCH, HeaderValue::from_str("rev-a").expect("header value should be valid"));
let result = require_matching_revision(&headers, "rev-a", HEADER_CONFIG_SOURCES_REVISION, "source.yml");
assert!(result.is_none(), "exact revision match should be accepted");
}
2026-03-20 15:31:19 +01:00
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,
}],
2026-08-27 18:36:23 +05:00
user: vec![TargetUserDto { target: String::from("target-a"), credentials: vec![] }],
use_user_db: true,
auth_error_status: 401,
}
}
2026-03-20 15:31:19 +01:00
#[test]
fn filter_app_config_clears_unauthorized_sections() {
let permissions: PermissionSet = Permission::ConfigRead.into();
let mut app_config = AppConfigDto {
2026-08-27 18:36:23 +05:00
config: ConfigDto { storage_dir: Some(String::from("storage")), ..ConfigDto::default() },
2026-03-20 15:31:19 +01:00
sources: SourcesConfigDto {
inputs: vec![],
sources: vec![],
provider: Some(vec![]),
templates: Some(vec![]),
},
mappings: None,
2026-04-17 11:57:43 +02:00
templates: Some(TemplateDefinitionDto::default()),
api_proxy: Some(make_test_api_proxy()),
2026-03-20 15:31:19 +01:00
};
2026-06-08 19:29:45 +02:00
filter_app_config_by_permissions(&mut app_config, Some(permissions));
2026-03-20 15:31:19 +01:00
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);
2026-10-04 16:02:55 +02:00
assert_eq!(api_proxy.user, [] as [shared::model::TargetUserDto; 0]);
2026-03-20 15:31:19 +01:00
assert!(api_proxy.use_user_db);
}
#[test]
2026-09-19 10:17:23 +02:00
fn filter_api_proxy_keeps_user_and_sanitized_server_section_with_user_read() {
2026-03-20 15:31:19 +01:00
let permissions: PermissionSet = Permission::UserRead.into();
let mut api_proxy = make_test_api_proxy();
2026-03-20 15:31:19 +01:00
filter_api_proxy_by_permissions(&mut api_proxy, permissions);
2026-09-19 10:17:23 +02:00
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());
2026-03-20 15:31:19 +01:00
assert_eq!(api_proxy.user.len(), 1);
assert!(!api_proxy.use_user_db);
assert_eq!(api_proxy.auth_error_status, 403);
}
2026-09-19 10:17:23 +02:00
#[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(),
),
2026-09-19 10:17:23 +02:00
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")]),
2026-09-19 10:17:23 +02:00
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");
2026-09-19 10:17:23 +02:00
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);
}
2026-04-09 22:06:01 +02:00
#[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(),
2026-04-09 22:06:01 +02:00
dns: None,
}));
let request = XtreamLoginRequest {
url: "provider://b1g".to_string(),
username: "demo".to_string(),
password: "secret".to_string(),
providers: None,
2026-04-09 22:06:01 +02:00
};
2026-04-17 11:57:43 +02:00
let input_source = build_xtream_login_input_source(&request, std::slice::from_ref(&provider))
2026-04-09 22:06:01 +02:00
.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!(
2026-04-17 11:57:43 +02:00
input_source.provider.as_ref().and_then(|provider| provider.urls.first()).map(std::convert::AsRef::as_ref),
Some("http://request.example")
);
}
2026-09-03 17:59:54 +02:00
#[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();
2026-09-03 17:59:54 +02:00
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()),
2026-09-03 17:59:54 +02:00
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()),
2026-09-03 17:59:54 +02:00
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));
2026-09-03 17:59:54 +02:00
}
}