feat: admin provider routes (list/update/delete/test) with admin middleware
This commit is contained in:
@@ -78,6 +78,21 @@ impl FromRequestParts<AppState> for OptionalCurrentUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracted admin user — returns 403 if user is not an admin.
|
||||||
|
pub struct AdminUser(pub User);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for AdminUser {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
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.
|
/// Authenticate using JWT Bearer token from the `Authorization` header.
|
||||||
#[cfg(feature = "auth-jwt")]
|
#[cfg(feature = "auth-jwt")]
|
||||||
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
|
||||||
|
|||||||
363
k-tv-backend/api/src/routes/admin_providers.rs
Normal file
363
k-tv-backend/api/src/routes/admin_providers.rs
Normal file
@@ -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<String, String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProviderConfigResponse {
|
||||||
|
pub provider_type: String,
|
||||||
|
pub config_json: HashMap<String, serde_json::Value>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct TestResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
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<String, serde_json::Value> {
|
||||||
|
let parsed: HashMap<String, serde_json::Value> =
|
||||||
|
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::<infra::JellyfinConfig>(&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<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let rows = state
|
||||||
|
.provider_config_repo
|
||||||
|
.get_all()
|
||||||
|
.await
|
||||||
|
.map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
let response: Vec<ProviderConfigResponse> = 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<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
Path(provider_type): Path<String>,
|
||||||
|
Json(payload): Json<ProviderConfigPayload>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
Path(provider_type): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<AppState>,
|
||||||
|
AdminUser(_user): AdminUser,
|
||||||
|
Path(provider_type): Path<String>,
|
||||||
|
Json(payload): Json<ProviderConfigPayload>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<String, String>) -> 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<String, String>) -> 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<Vec<domain::MediaItem>> {
|
||||||
|
Err(domain::DomainError::InfrastructureError(
|
||||||
|
"No media provider configured.".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_by_id(
|
||||||
|
&self,
|
||||||
|
_: &domain::MediaItemId,
|
||||||
|
) -> domain::DomainResult<Option<domain::MediaItem>> {
|
||||||
|
Err(domain::DomainError::InfrastructureError(
|
||||||
|
"No media provider configured.".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_stream_url(
|
||||||
|
&self,
|
||||||
|
_: &domain::MediaItemId,
|
||||||
|
_: &domain::StreamQuality,
|
||||||
|
) -> domain::DomainResult<String> {
|
||||||
|
Err(domain::DomainError::InfrastructureError(
|
||||||
|
"No media provider configured.".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use crate::state::AppState;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod admin_providers;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -17,6 +18,7 @@ pub mod library;
|
|||||||
pub fn api_v1_router() -> Router<AppState> {
|
pub fn api_v1_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.nest("/admin", admin::router())
|
.nest("/admin", admin::router())
|
||||||
|
.nest("/admin/providers", admin_providers::router())
|
||||||
.nest("/auth", auth::router())
|
.nest("/auth", auth::router())
|
||||||
.nest("/channels", channels::router())
|
.nest("/channels", channels::router())
|
||||||
.nest("/config", config::router())
|
.nest("/config", config::router())
|
||||||
|
|||||||
Reference in New Issue
Block a user