Files
tuliprox/backend/app/src/api/endpoints/v1_api_user.rs
T
euzuandGitHub 89ec7c0e97 Fix/filter input and flussonic fix (#888)
* filter input for user form

* added opt-in bounded Flussonic HLS catch-up

- Add flussonic_hls_catchup input option with native default
- Generate bounded archive sources during M3U import
- Preserve live URLs and query tokens
- Expose native and bounded_archive in the WebUI select

* added opt-in bounded Flussonic HLS catch-up

- Add flussonic_hls_catchup input option with native default
- Generate bounded archive sources during M3U import
- Preserve live URLs and query tokens
- Expose native and bounded_archive in the WebUI select
2026-09-30 17:28:58 +02:00

433 lines
18 KiB
Rust

use crate::{
api::{
auth_middleware::permission_layer,
model::AppState,
panel_api::{sync_panel_api_alias_pool_for_target, target_has_alias_pool_min},
},
model::{ApiProxyConfig, ProxyUserCredentials, TargetUser},
repository::store_api_user,
};
use axum::{response::IntoResponse, Router};
use serde_json::json;
use shared::{
model::{
permission::Permission, ApiProxyConfigDto, EventMessage, ProxyUserCredentialsDto, UserLifecycleEvent,
UserLifecycleState,
},
utils::mask_credentials,
};
use std::{path::Path, sync::Arc};
async fn validate_user_filter(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Json(filter): axum::extract::Json<Option<String>>,
) -> axum::response::Response {
let api_proxy = app_state.app_config.api_proxy.load();
let templates = api_proxy.as_ref().and_then(|config| config.templates.as_deref());
let valid = filter
.as_deref()
.map(str::trim)
.filter(|raw| !raw.is_empty())
.is_none_or(|raw| shared::foundation::get_filter(raw, templates).is_ok());
if valid {
axum::http::StatusCode::NO_CONTENT.into_response()
} else {
// Parser errors can contain expanded template values that the caller cannot read.
(axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Invalid user filter"}))).into_response()
}
}
#[allow(clippy::too_many_lines)]
async fn save_config_api_proxy_user(
method: axum::http::Method,
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path(target_name): axum::extract::Path<String>,
axum::extract::Json(mut credential): axum::extract::Json<ProxyUserCredentialsDto>,
) -> impl axum::response::IntoResponse + Send {
let api_proxy_file_path = {
let paths = app_state.app_config.paths.load();
paths.api_proxy_file_path.clone()
};
let _lock = app_state.app_config.file_locks.write_lock(Path::new(&api_proxy_file_path)).await;
let runtime_api_proxy = app_state.app_config.api_proxy.load_full();
let templates = runtime_api_proxy.as_ref().and_then(|config| config.templates.as_deref());
if let Err(err) = credential.prepare_with_templates(templates) {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
if let Err(err) = credential.validate() {
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
}
let is_update = method == axum::http::Method::PUT;
let mut api_proxy = if let Some(old) = app_state.app_config.api_proxy.load().clone() {
(*old).clone()
} else {
ApiProxyConfig::default()
};
if let Some(plan) = credential.plan.as_ref() {
if !api_proxy.plans.iter().any(|p| p.name == *plan) {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Unknown user plan {plan}")})),
)
.into_response();
}
}
// Trial plans: new users without an explicit expiry get the trial window.
if !is_update {
if let Some(plan) = credential.plan.as_ref().and_then(|name| api_proxy.plans.iter().find(|p| p.name == *name)) {
if let Some(trial_secs) = plan.t_trial_duration_secs {
if credential.exp_date.is_none() {
let expires =
chrono::Utc::now().timestamp().saturating_add(i64::try_from(trial_secs).unwrap_or(i64::MAX));
credential.exp_date = Some(expires);
}
if credential.status.is_none() {
credential.status = Some(shared::model::ProxyUserStatus::Trial);
}
}
}
}
let new_user = {
let mut user = ProxyUserCredentials::from(&credential);
user.resolve_plan_with_templates(&api_proxy.plan_map(), api_proxy.templates.as_deref());
Arc::new(user)
};
// ---------- Search for existing Target and existing User ----------
let mut existing_target_index: Option<usize> = None; // index of target (target_name), if present
let mut existing_user_target_index: Option<usize> = None; // index of existing users target
let mut existing_user_index: Option<usize> = None; // index of the user in the targets credentials list
for (t_idx, target_user) in api_proxy.user.iter().enumerate() {
if target_user.target == target_name {
existing_target_index = Some(t_idx);
}
for (u_idx, user) in target_user.credentials.iter().enumerate() {
if let (Some(u), Some(c)) = (&user.token, &credential.token) {
if u == c && user.username != credential.username {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate token {}", mask_credentials(c))})),
)
.into_response();
}
}
if user.username == credential.username {
// if not an update und username exists -> Error (duplicate username)
if !is_update {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("Duplicate username {}", &credential.username)})),
)
.into_response();
}
// mark position of user (for update / move)
existing_user_target_index = Some(t_idx);
existing_user_index = Some(u_idx);
}
}
}
let existing_user_position = if is_update {
let (Some(user_idx), Some(user_target_idx)) = (existing_user_index, existing_user_target_index) else {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("User {} not found", credential.username)})),
)
.into_response();
};
Some((user_idx, user_target_idx))
} else {
None
};
// ---------- create target if new target does not exist ----------
let target_idx = if let Some(idx) = existing_target_index {
idx
} else {
api_proxy.user.push(TargetUser { target: target_name.clone(), credentials: vec![] });
api_proxy.user.len() - 1
};
if let Some((user_idx, user_target_idx)) = existing_user_position {
let mut remove_empty_target = false;
if user_target_idx == target_idx {
// Update
api_proxy.user[user_target_idx].credentials[user_idx] = Arc::clone(&new_user);
} else {
// Move: remove from old target and insert into new target
api_proxy.user[user_target_idx].credentials.remove(user_idx);
api_proxy.user[target_idx].credentials.push(Arc::clone(&new_user));
remove_empty_target = api_proxy.user[user_target_idx].credentials.is_empty();
}
if remove_empty_target {
api_proxy.user.retain(|t| !t.credentials.is_empty());
}
} else {
// new user
api_proxy.user[target_idx].credentials.push(Arc::clone(&new_user));
}
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user).await {
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
.into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(
backup_dir.as_ref(),
&ApiProxyConfigDto::from(&*new_api_proxy),
paths.api_proxy_file_path.as_str(),
)
.await
{
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()})))
.into_response();
}
}
// Update state after successful save
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
// Only after the write succeeded and the new config is live: an event
// published before the store could describe a state no reader can see.
app_state.event_manager.send_event(EventMessage::UserLifecycle(UserLifecycleEvent::new(
new_user.username.as_str().into(),
target_name.as_str().into(),
if is_update { UserLifecycleState::Updated } else { UserLifecycleState::Created },
)));
if target_has_alias_pool_min(&app_state, &target_name) {
let app_state_clone = Arc::clone(&app_state);
let target_name_clone = target_name.clone();
tokio::spawn(async move {
sync_panel_api_alias_pool_for_target(&app_state_clone, &target_name_clone).await;
});
}
axum::http::StatusCode::OK.into_response()
}
async fn delete_config_api_proxy_user(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path((target_name, username)): axum::extract::Path<(String, String)>,
) -> impl axum::response::IntoResponse + Send {
if let Some(old_api_proxy) = app_state.app_config.api_proxy.load().clone() {
let mut api_proxy = (*old_api_proxy).clone();
let mut modified = false;
for target_user in &mut api_proxy.user {
if target_user.target == target_name {
let count = target_user.credentials.len();
target_user.credentials.retain(|user| user.username != username);
modified = count != target_user.credentials.len();
break;
}
}
if modified {
let new_api_proxy = Arc::new(api_proxy);
if new_api_proxy.use_user_db {
if let Err(err) = store_api_user(&app_state.app_config, &new_api_proxy.user).await {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": err.to_string()})),
)
.into_response();
}
} else {
let config = app_state.app_config.config.load();
let backup_dir = config.get_backup_dir();
let paths = app_state.app_config.paths.load();
if let Some(err) = crate::api::endpoints::v1_api_config::intern_save_config_api_proxy(
backup_dir.as_ref(),
&ApiProxyConfigDto::from(&*new_api_proxy),
paths.api_proxy_file_path.as_str(),
)
.await
{
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": err.to_string()})),
)
.into_response();
}
}
app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy)));
app_state.event_manager.send_event(EventMessage::UserLifecycle(UserLifecycleEvent::new(
username.as_str().into(),
target_name.as_str().into(),
UserLifecycleState::Deleted,
)));
} else {
return (
axum::http::StatusCode::BAD_REQUEST,
axum::Json(json!({"error": format!("User not found {username} in target {target_name}")})),
)
.into_response();
}
}
axum::http::StatusCode::OK.into_response()
}
/// Terminates a specific playback session for a user.
///
/// This is the explicit `Terminate` path from the playback state machine:
/// - Removes the session and all associated streams
/// - Releases the counted lease if held
/// - Sets lifecycle to `Expired`
async fn terminate_user_session(
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
axum::extract::Path((username, session_token)): axum::extract::Path<(String, String)>,
) -> impl axum::response::IntoResponse {
app_state.active_users.terminate_session(&username, &session_token).await;
app_state.active_provider.clear_provider_reservation(&session_token);
(axum::http::StatusCode::NO_CONTENT).into_response()
}
pub fn v1_api_user_register(router: Router<Arc<AppState>>) -> axum::Router<Arc<AppState>> {
router
.route("/user/filter/validate", axum::routing::post(validate_user_filter))
.route("/user/{target}", axum::routing::post(save_config_api_proxy_user))
.route("/user/{target}", axum::routing::put(save_config_api_proxy_user))
.route("/user/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
.route("/user/{username}/session/{session_token}", axum::routing::delete(terminate_user_session))
}
pub fn v1_api_user_register_with_permissions(
router: Router<Arc<AppState>>,
app_state: &Arc<AppState>,
) -> axum::Router<Arc<AppState>> {
let user_write_routes = Router::new()
.route("/filter/validate", axum::routing::post(validate_user_filter))
.route("/{target}", axum::routing::post(save_config_api_proxy_user).put(save_config_api_proxy_user))
.route("/{target}/{username}", axum::routing::delete(delete_config_api_proxy_user))
.route("/{username}/session/{session_token}", axum::routing::delete(terminate_user_session))
.layer(permission_layer!(app_state, Permission::UserWrite));
router.nest("/user", user_write_routes)
}
#[cfg(test)]
mod tests {
use super::v1_api_user_register_with_permissions;
use crate::{
api::model::create_test_app_state,
auth::create_jwt_web_user,
model::{ApiProxyConfig, Config},
};
use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
Router,
};
use shared::model::{PatternTemplate, Permission, TemplateValue, UserId, WebAuthConfigDto, WebUiConfigDto};
use std::sync::Arc;
use tower::ServiceExt;
#[tokio::test]
async fn user_filter_validation_requires_user_write_and_keeps_templates_private(
) -> Result<(), Box<dyn std::error::Error>> {
let web_ui = WebUiConfigDto {
auth: Some(WebAuthConfigDto {
enabled: true,
issuer: "filter-test".to_owned(),
secret: "filter-test-secret".to_owned(),
..Default::default()
}),
..Default::default()
};
let config = Config { web_ui: Some((&web_ui).into()), ..Default::default() };
let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else {
return Err("missing web auth".into());
};
let writer =
create_jwt_web_user(web_auth, "writer", Permission::UserWrite.into(), 0, UserId::from("web:writer"))?;
let reader =
create_jwt_web_user(web_auth, "reader", Permission::UserRead.into(), 0, UserId::from("web:reader"))?;
let app_state = create_test_app_state(config);
let mut groups = PatternTemplate {
name: "groups".to_owned(),
value: TemplateValue::Single("Sports".to_owned()),
placeholder: String::new(),
};
groups.prepare();
let mut secret = PatternTemplate {
name: "invalid_regex".to_owned(),
value: TemplateValue::Single("private-template-value[".to_owned()),
placeholder: String::new(),
};
secret.prepare();
app_state.app_config.api_proxy.store(Some(Arc::new(ApiProxyConfig {
templates: Some(Arc::from([groups, secret])),
..Default::default()
})));
let router = v1_api_user_register_with_permissions(Router::new(), &app_state).with_state(app_state);
for (filter, status) in [
(Some(r#"Group = "!groups!""#), StatusCode::NO_CONTENT),
(None, StatusCode::NO_CONTENT),
(Some(" "), StatusCode::NO_CONTENT),
(Some("("), StatusCode::BAD_REQUEST),
(Some(r#"Group = "!missing!""#), StatusCode::BAD_REQUEST),
(Some(r#"Group ~ "!invalid_regex!""#), StatusCode::BAD_REQUEST),
] {
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/user/filter/validate")
.header("Authorization", format!("Bearer {writer}"))
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&filter)?))?,
)
.await?;
assert_eq!(response.status(), status);
let body = to_bytes(response.into_body(), 1024).await?;
if status == StatusCode::NO_CONTENT {
assert!(body.is_empty());
} else {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body)?,
serde_json::json!({"error": "Invalid user filter"})
);
}
}
let response = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/user/filter/validate")
.header("Authorization", format!("Bearer {reader}"))
.header("Content-Type", "application/json")
.body(Body::from("null"))?,
)
.await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/user/filter/validate")
.header("Content-Type", "application/json")
.body(Body::from("null"))?,
)
.await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
Ok(())
}
}