diff --git a/k-tv-backend/api/src/extractors.rs b/k-tv-backend/api/src/extractors.rs index adef53b..ddc2548 100644 --- a/k-tv-backend/api/src/extractors.rs +++ b/k-tv-backend/api/src/extractors.rs @@ -78,6 +78,21 @@ impl FromRequestParts for OptionalCurrentUser { } } +/// Extracted admin user — returns 403 if user is not an admin. +pub struct AdminUser(pub User); + +impl FromRequestParts for AdminUser { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let CurrentUser(user) = CurrentUser::from_request_parts(parts, state).await?; + if !user.is_admin { + return Err(ApiError::Forbidden("Admin access required".to_string())); + } + Ok(AdminUser(user)) + } +} + /// Authenticate using JWT Bearer token from the `Authorization` header. #[cfg(feature = "auth-jwt")] async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { diff --git a/k-tv-backend/api/src/routes/admin_providers.rs b/k-tv-backend/api/src/routes/admin_providers.rs new file mode 100644 index 0000000..a2341ac --- /dev/null +++ b/k-tv-backend/api/src/routes/admin_providers.rs @@ -0,0 +1,363 @@ +//! Admin provider management routes. +//! +//! All routes require an admin user. Allows listing, updating, deleting, and +//! testing media provider configs stored in the DB. Only available when +//! CONFIG_SOURCE=db. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{get, post, put, delete}; +use axum::Json; +use domain::errors::DomainResult; +use domain::ProviderConfigRow; +use serde::{Deserialize, Serialize}; + +use crate::config::ConfigSource; +use crate::error::ApiError; +use crate::extractors::AdminUser; +use crate::state::AppState; + +// --------------------------------------------------------------------------- +// DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct ProviderConfigPayload { + pub config_json: HashMap, + pub enabled: bool, +} + +#[derive(Debug, Serialize)] +pub struct ProviderConfigResponse { + pub provider_type: String, + pub config_json: HashMap, + pub enabled: bool, +} + +#[derive(Debug, Serialize)] +pub struct TestResult { + pub ok: bool, + pub message: String, +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +pub fn router() -> Router { + Router::new() + .route("/", get(list_providers)) + .route("/{type}", put(update_provider).delete(delete_provider)) + .route("/{type}/test", post(test_provider)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn mask_config(raw: &str) -> HashMap { + let parsed: HashMap = + serde_json::from_str(raw).unwrap_or_default(); + parsed + .into_iter() + .map(|(k, v)| { + let secret_key = ["key", "password", "secret", "token"] + .iter() + .any(|kw| k.to_lowercase().contains(kw)); + let masked = if secret_key { + match &v { + serde_json::Value::String(s) if !s.is_empty() => { + serde_json::Value::String("***".to_string()) + } + _ => v, + } + } else { + v + }; + (k, masked) + }) + .collect() +} + +fn conflict_response() -> impl IntoResponse { + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "UI config disabled — set CONFIG_SOURCE=db on the server" + })), + ) +} + +async fn rebuild_registry(state: &AppState) -> DomainResult<()> { + let rows = state.provider_config_repo.get_all().await?; + let mut new_registry = infra::ProviderRegistry::new(); + + for row in &rows { + if !row.enabled { + continue; + } + match row.provider_type.as_str() { + #[cfg(feature = "jellyfin")] + "jellyfin" => { + if let Ok(cfg) = + serde_json::from_str::(&row.config_json) + { + new_registry.register( + "jellyfin", + Arc::new(infra::JellyfinMediaProvider::new(cfg)), + ); + } + } + _ => {} + } + } + + if new_registry.is_empty() { + new_registry.register("noop", Arc::new(NoopMediaProvider)); + } + + *state.provider_registry.write().await = Arc::new(new_registry); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub async fn list_providers( + State(state): State, + AdminUser(_user): AdminUser, +) -> Result { + let rows = state + .provider_config_repo + .get_all() + .await + .map_err(ApiError::from)?; + + let response: Vec = rows + .iter() + .map(|row| ProviderConfigResponse { + provider_type: row.provider_type.clone(), + config_json: mask_config(&row.config_json), + enabled: row.enabled, + }) + .collect(); + + Ok(Json(response)) +} + +pub async fn update_provider( + State(state): State, + AdminUser(_user): AdminUser, + Path(provider_type): Path, + Json(payload): Json, +) -> Result { + if state.config.config_source != ConfigSource::Db { + return Ok(conflict_response().into_response()); + } + + let known = matches!(provider_type.as_str(), "jellyfin" | "local_files"); + if !known { + return Err(ApiError::Validation(format!( + "Unknown provider type: {}", + provider_type + ))); + } + + let config_json = serde_json::to_string(&payload.config_json) + .map_err(|e| ApiError::Internal(format!("Failed to serialize config: {}", e)))?; + + let row = ProviderConfigRow { + provider_type: provider_type.clone(), + config_json: config_json.clone(), + enabled: payload.enabled, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + + state + .provider_config_repo + .upsert(&row) + .await + .map_err(ApiError::from)?; + + rebuild_registry(&state) + .await + .map_err(ApiError::from)?; + + let response = ProviderConfigResponse { + provider_type, + config_json: mask_config(&config_json), + enabled: payload.enabled, + }; + + Ok(Json(response).into_response()) +} + +pub async fn delete_provider( + State(state): State, + AdminUser(_user): AdminUser, + Path(provider_type): Path, +) -> Result { + if state.config.config_source != ConfigSource::Db { + return Ok(conflict_response().into_response()); + } + + state + .provider_config_repo + .delete(&provider_type) + .await + .map_err(ApiError::from)?; + + rebuild_registry(&state) + .await + .map_err(ApiError::from)?; + + Ok(StatusCode::NO_CONTENT.into_response()) +} + +pub async fn test_provider( + State(_state): State, + AdminUser(_user): AdminUser, + Path(provider_type): Path, + Json(payload): Json, +) -> Result { + let result = match provider_type.as_str() { + "jellyfin" => test_jellyfin(&payload.config_json).await, + "local_files" => test_local_files(&payload.config_json), + _ => TestResult { + ok: false, + message: "Unknown provider type".to_string(), + }, + }; + + Ok(Json(result)) +} + +async fn test_jellyfin(config: &HashMap) -> TestResult { + let base_url = match config.get("base_url") { + Some(u) => u.trim_end_matches('/').to_string(), + None => { + return TestResult { + ok: false, + message: "Missing field: base_url".to_string(), + } + } + }; + let api_key = match config.get("api_key") { + Some(k) => k.clone(), + None => { + return TestResult { + ok: false, + message: "Missing field: api_key".to_string(), + } + } + }; + + let url = format!("{}/System/Info", base_url); + let client = reqwest::Client::new(); + match client + .get(&url) + .header("X-Emby-Token", &api_key) + .send() + .await + { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + TestResult { + ok: true, + message: format!("Connected successfully (HTTP {})", status.as_u16()), + } + } else { + TestResult { + ok: false, + message: format!("Jellyfin returned HTTP {}", status.as_u16()), + } + } + } + Err(e) => TestResult { + ok: false, + message: format!("Connection failed: {}", e), + }, + } +} + +fn test_local_files(config: &HashMap) -> TestResult { + let path = match config.get("files_dir") { + Some(p) => p.clone(), + None => { + return TestResult { + ok: false, + message: "Missing field: files_dir".to_string(), + } + } + }; + let p = std::path::Path::new(&path); + if p.exists() && p.is_dir() { + TestResult { + ok: true, + message: format!("Directory exists: {}", path), + } + } else { + TestResult { + ok: false, + message: format!("Path does not exist or is not a directory: {}", path), + } + } +} + +// --------------------------------------------------------------------------- +// NoopMediaProvider (local copy — avoids pub-ing it from main.rs) +// --------------------------------------------------------------------------- + +struct NoopMediaProvider; + +#[async_trait::async_trait] +impl domain::IMediaProvider for NoopMediaProvider { + fn capabilities(&self) -> domain::ProviderCapabilities { + domain::ProviderCapabilities { + collections: false, + series: false, + genres: false, + tags: false, + decade: false, + search: false, + streaming_protocol: domain::StreamingProtocol::DirectFile, + rescan: false, + transcode: false, + } + } + + async fn fetch_items( + &self, + _: &domain::MediaFilter, + ) -> domain::DomainResult> { + Err(domain::DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } + + async fn fetch_by_id( + &self, + _: &domain::MediaItemId, + ) -> domain::DomainResult> { + Err(domain::DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } + + async fn get_stream_url( + &self, + _: &domain::MediaItemId, + _: &domain::StreamQuality, + ) -> domain::DomainResult { + Err(domain::DomainError::InfrastructureError( + "No media provider configured.".into(), + )) + } +} diff --git a/k-tv-backend/api/src/routes/mod.rs b/k-tv-backend/api/src/routes/mod.rs index a766434..fa48157 100644 --- a/k-tv-backend/api/src/routes/mod.rs +++ b/k-tv-backend/api/src/routes/mod.rs @@ -6,6 +6,7 @@ use crate::state::AppState; use axum::Router; pub mod admin; +pub mod admin_providers; pub mod auth; pub mod channels; pub mod config; @@ -17,6 +18,7 @@ pub mod library; pub fn api_v1_router() -> Router { Router::new() .nest("/admin", admin::router()) + .nest("/admin/providers", admin_providers::router()) .nest("/auth", auth::router()) .nest("/channels", channels::router()) .nest("/config", config::router())