mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
* 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
1486 lines
62 KiB
Rust
1486 lines
62 KiB
Rust
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, ¤t_revision, HEADER_CONFIG_MAIN_REVISION, "config.yml")
|
||
{
|
||
return response;
|
||
}
|
||
|
||
// A client that echoes a redacted secret back means "keep what is
|
||
// stored". Without this the round-trip would write the mask over the
|
||
// real token and silently break the channel.
|
||
if let Some(incoming) = cfg.messaging.as_mut() {
|
||
let stored = app_state.app_config.config.load();
|
||
if let Some(current) = stored.messaging.as_ref() {
|
||
incoming.restore_redacted_secrets(&shared::model::MessagingConfigDto::from(current));
|
||
}
|
||
}
|
||
|
||
if let Err(err) = cfg.prepare(false) {
|
||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||
}
|
||
if !cfg.is_valid() {
|
||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"}))).into_response();
|
||
}
|
||
let reload = recording_reload_for(&app_state, &cfg).await;
|
||
if let RecordingReloadOutcome::Refused(refusal) = &reload {
|
||
return (axum::http::StatusCode::CONFLICT, axum::Json(json!({"error": refusal.to_string()}))).into_response();
|
||
}
|
||
if let Err(err) = validate_library_paths_from_dto(&cfg) {
|
||
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response()
|
||
} else {
|
||
if let Err(err) = persist_messaging_templates(&app_state.app_config, &mut cfg).await {
|
||
error!("Failed to persist messaging templates: {err}");
|
||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
|
||
if let Some(err) = intern_save_config_main(&file_path, &backup_dir, &cfg).await {
|
||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
let updated_revision = match read_file_revision(&file_path).await {
|
||
Ok(revision) => revision,
|
||
Err(err) => {
|
||
error!("Failed to read updated revision for config.yml '{file_path}': {err}");
|
||
return internal_server_error!();
|
||
}
|
||
};
|
||
// A setting that will not take effect until a restart has to say so,
|
||
// or the operator reads the 200 as "applied" and waits forever.
|
||
let pending: Vec<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, ¤t_revision, HEADER_CONFIG_SOURCES_REVISION, "source.yml")
|
||
{
|
||
return response;
|
||
}
|
||
let original_sources_file = match read_optional_file(Path::new(&sources_file_path)).await {
|
||
Ok(content) => content,
|
||
Err(err) => {
|
||
error!("Failed to snapshot source.yml '{sources_file_path}' before save: {err}");
|
||
return internal_server_error!();
|
||
}
|
||
};
|
||
|
||
let templates_to_persist =
|
||
match crate::config_loader::validate_source_config_for_persist(&app_state.app_config, &sources).await {
|
||
Ok(value) => value,
|
||
Err(err) => {
|
||
error!("Failed to validate source.yml {err}");
|
||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
};
|
||
|
||
let template_snapshot = match snapshot_template_file(&app_state, templates_to_persist.is_some()).await {
|
||
Ok(snapshot) => snapshot,
|
||
Err(err) => {
|
||
error!("Failed to snapshot template config before save: {err}");
|
||
return internal_server_error!();
|
||
}
|
||
};
|
||
|
||
if let Some(template_definition) = templates_to_persist.as_ref() {
|
||
if let Err(err) =
|
||
crate::config_loader::persist_templates_config(&app_state.app_config, template_definition).await
|
||
{
|
||
error!("Failed to save template config {err}");
|
||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
}
|
||
|
||
// Derive lifecycle changes from the currently loaded configuration before it is replaced.
|
||
let bouquet_mutations = build_bouquet_mutation_batch(&app_state, &sources);
|
||
|
||
match crate::config_loader::replace_source_config_from_user_edit(&app_state.app_config, None, sources).await {
|
||
Ok(_) => {}
|
||
Err(err) => {
|
||
error!("Failed to persist source.yml {err}");
|
||
if let Some((template_file_path, original_template_file)) = template_snapshot.as_ref() {
|
||
if let Err(rollback_err) =
|
||
restore_sources_file(Path::new(template_file_path), original_template_file.as_deref()).await
|
||
{
|
||
error!("Failed to roll back template config after source.yml save failure: {rollback_err}");
|
||
}
|
||
}
|
||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
}
|
||
|
||
// Reload before deleting bouquets so a failed reload can restore the persisted
|
||
// configuration without having touched target-local state.
|
||
if let Err(reload_err) = ConfigFile::load_sources(&app_state).await {
|
||
return roll_back_sources_after_update_failure(
|
||
&app_state,
|
||
Path::new(&sources_file_path),
|
||
original_sources_file.as_deref(),
|
||
template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())),
|
||
&reload_err,
|
||
)
|
||
.await;
|
||
}
|
||
|
||
// The mutation lock prevents concurrent bouquet writes from observing a partial lifecycle update.
|
||
if let Err(mutation_err) = apply_bouquet_mutation_batch(&app_state, &bouquet_mutations).await {
|
||
return roll_back_sources_after_update_failure(
|
||
&app_state,
|
||
Path::new(&sources_file_path),
|
||
original_sources_file.as_deref(),
|
||
template_snapshot.as_ref().map(|(path, content)| (Path::new(path), content.as_deref())),
|
||
&mutation_err,
|
||
)
|
||
.await;
|
||
}
|
||
|
||
app_state.active_provider.update_config(&app_state.app_config);
|
||
let updated_revision = match read_file_revision(&sources_file_path).await {
|
||
Ok(revision) => revision,
|
||
Err(err) => {
|
||
error!("Failed to read updated revision for source.yml '{sources_file_path}': {err}");
|
||
return internal_server_error!();
|
||
}
|
||
};
|
||
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_SOURCES_REVISION, &updated_revision)
|
||
}
|
||
|
||
async fn get_config_api_proxy_config_public(
|
||
axum::extract::State(app_state): axum::extract::State<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, ¤t_revision, HEADER_CONFIG_API_PROXY_REVISION, "api-proxy.yml")
|
||
{
|
||
return response;
|
||
}
|
||
|
||
for server_info in &mut req_api_proxy.server {
|
||
if !server_info.validate() {
|
||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid content"})))
|
||
.into_response();
|
||
}
|
||
}
|
||
|
||
// TODO if hot reload is on, it is loaded twice, avoid this
|
||
// Build the updated config without mutating global state yet
|
||
let base = app_state.app_config.api_proxy.load().as_deref().cloned().unwrap_or_default();
|
||
let updated_api_proxy = ApiProxyConfig {
|
||
use_user_db: req_api_proxy.use_user_db,
|
||
server: req_api_proxy.server.iter().map(Into::into).collect(),
|
||
auth_error_status: req_api_proxy.auth_error_status,
|
||
..base
|
||
};
|
||
|
||
// Full-config validation: catches duplicate server names, duplicate usernames/tokens
|
||
// and users referencing missing servers, which per-row validate() cannot see
|
||
let stored_plans = updated_api_proxy.plans.clone();
|
||
let mut updated_api_proxy_dto = ApiProxyConfigDto::from(&updated_api_proxy);
|
||
if let Err(err) = updated_api_proxy_dto.prepare_with_templates(updated_api_proxy.templates.as_deref()) {
|
||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||
}
|
||
|
||
if let Some(err) = intern_save_config_api_proxy(&backup_dir, &updated_api_proxy_dto, &api_proxy_file_path).await {
|
||
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
|
||
.into_response();
|
||
}
|
||
// Persist succeeded — now update in‑memory state with the prepared config.
|
||
// Plans live in plans.yml, so re-inject them (the DTO round-trip drops them).
|
||
let mut stored_api_proxy =
|
||
ApiProxyConfig::from_dto_with_templates(&updated_api_proxy_dto, updated_api_proxy.templates.clone());
|
||
stored_api_proxy.set_plans(stored_plans);
|
||
app_state.app_config.api_proxy.store(Some(Arc::new(stored_api_proxy)));
|
||
|
||
let updated_revision = match read_file_revision(&api_proxy_file_path).await {
|
||
Ok(revision) => revision,
|
||
Err(err) => {
|
||
error!("Failed to read updated revision for api-proxy.yml '{api_proxy_file_path}': {err}");
|
||
return internal_server_error!();
|
||
}
|
||
};
|
||
response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_API_PROXY_REVISION, &updated_revision)
|
||
}
|
||
|
||
async fn get_config_common(app_state: &Arc<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));
|
||
}
|
||
}
|