use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde_json::json;
use super::super::config_io;
use super::super::server::WebUiState;
use super::config_api::{reject_if_playing, reload_hardware_after_mutation};
use super::helpers::{
require_configured_dir, resolve_resource_path, spawn_blocking_io, validate_resource_name,
};
use crate::config::Profile;
use config::Config;
#[allow(clippy::result_large_err)]
fn validate_profile_filename(name: &str) -> Result<(), axum::response::Response> {
validate_resource_name(name, "profile", None)
}
pub(super) async fn get_profiles(State(state): State<WebUiState>) -> impl IntoResponse {
let profiles_dir = require_configured_dir(
&state.profiles_dir,
"profiles",
StatusCode::SERVICE_UNAVAILABLE,
)?;
let result = spawn_blocking_io("read profiles dir", move || {
let entries = std::fs::read_dir(&profiles_dir)?;
let mut items: Vec<(String, serde_json::Value)> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "yaml" && ext != "yml" {
continue;
}
let filename = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let profile = match Config::builder()
.add_source(config::File::from(path.as_path()))
.build()
.and_then(|c| c.try_deserialize::<Profile>())
{
Ok(p) => p,
Err(_) => continue,
};
items.push((
filename.clone(),
json!({
"filename": filename,
"hostname": profile.hostname(),
"has_audio": profile.audio_config().is_some(),
"has_midi": profile.midi().is_some(),
"has_dmx": profile.dmx().is_some(),
"has_trigger": profile.trigger().is_some(),
"has_controllers": !profile.controllers().is_empty(),
}),
));
}
items.sort_by(|a, b| a.0.cmp(&b.0));
Ok::<_, std::io::Error>(items.into_iter().map(|(_, v)| v).collect::<Vec<_>>())
})
.await?;
Ok::<_, axum::response::Response>((StatusCode::OK, Json(json!(result))).into_response())
}
pub(super) async fn get_profile(
State(state): State<WebUiState>,
Path(filename): Path<String>,
) -> impl IntoResponse {
validate_profile_filename(&filename)?;
let profiles_dir = require_configured_dir(
&state.profiles_dir,
"profiles",
StatusCode::SERVICE_UNAVAILABLE,
)?;
let file_path = {
let yaml_path = resolve_resource_path(&profiles_dir, &filename, "yaml")?;
if yaml_path.is_file() {
yaml_path
} else {
let yml_path = resolve_resource_path(&profiles_dir, &filename, "yml")?;
if yml_path.is_file() {
yml_path
} else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Profile '{}' not found", filename)})),
)
.into_response());
}
}
};
let fp = file_path.clone();
let (raw, profile) = spawn_blocking_io("read profile", move || {
let raw =
std::fs::read_to_string(&fp).map_err(|e| format!("Failed to read profile: {}", e))?;
let profile: Profile = Config::builder()
.add_source(config::File::from(fp.as_path()))
.build()
.and_then(|c| c.try_deserialize())
.map_err(|e| format!("Failed to parse profile: {}", e))?;
Ok::<_, String>((raw, profile))
})
.await?;
let profile_json = serde_json::to_value(&profile).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to serialize profile: {}", e)})),
)
.into_response()
})?;
Ok::<_, axum::response::Response>(
(
StatusCode::OK,
Json(json!({"profile": profile_json, "yaml": raw})),
)
.into_response(),
)
}
pub(super) async fn put_profile(
State(state): State<WebUiState>,
Path(filename): Path<String>,
Json(body): Json<serde_json::Value>,
) -> impl IntoResponse {
validate_profile_filename(&filename)?;
if let Some(resp) = reject_if_playing(&state).await {
return Err(resp);
}
let profiles_dir = require_configured_dir(
&state.profiles_dir,
"profiles",
StatusCode::SERVICE_UNAVAILABLE,
)?;
let profile: Profile = serde_json::from_value(body).map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(json!({"error": format!("Invalid profile: {}", e)})),
)
.into_response()
})?;
let yaml = crate::util::to_yaml_string(&profile).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("Failed to serialize profile: {}", e)})),
)
.into_response()
})?;
let file_path = resolve_resource_path(&profiles_dir, &filename, "yaml")?;
let dir = profiles_dir;
let fp = file_path;
let yaml_owned = yaml;
spawn_blocking_io("write profile", move || {
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
config_io::atomic_write(&fp, &yaml_owned)
})
.await?;
reload_hardware_after_mutation(&state).await;
Ok::<_, axum::response::Response>(
(
StatusCode::OK,
Json(json!({"status": "saved", "filename": filename})),
)
.into_response(),
)
}
pub(super) async fn delete_profile_file(
State(state): State<WebUiState>,
Path(filename): Path<String>,
) -> impl IntoResponse {
validate_profile_filename(&filename)?;
if let Some(resp) = reject_if_playing(&state).await {
return Err(resp);
}
let profiles_dir = require_configured_dir(
&state.profiles_dir,
"profiles",
StatusCode::SERVICE_UNAVAILABLE,
)?;
let file_path = resolve_resource_path(&profiles_dir, &filename, "yaml")?;
let yml_path = resolve_resource_path(&profiles_dir, &filename, "yml")?;
let target = if file_path.is_file() {
file_path
} else if yml_path.is_file() {
yml_path
} else {
return Err((
StatusCode::NOT_FOUND,
Json(json!({"error": format!("Profile '{}' not found", filename)})),
)
.into_response());
};
spawn_blocking_io("delete profile", move || std::fs::remove_file(&target)).await?;
reload_hardware_after_mutation(&state).await;
Ok::<_, axum::response::Response>(
(
StatusCode::OK,
Json(json!({"status": "deleted", "filename": filename})),
)
.into_response(),
)
}
#[cfg(test)]
mod test {
use super::super::router;
use super::super::test_helpers::*;
use axum::body::Body;
use axum::http::StatusCode;
use tower::ServiceExt;
fn write_profile_file(dir: &std::path::Path, filename: &str, content: &str) {
std::fs::write(dir.join(filename), content).unwrap();
}
#[tokio::test]
async fn get_profiles_lists_files() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
write_profile_file(
&profiles_dir,
"01-host-a.yaml",
"hostname: host-a\naudio:\n device: dev-a\n track_mappings:\n drums: [1]\n",
);
write_profile_file(
&profiles_dir,
"02-host-b.yml",
"hostname: host-b\nmidi:\n device: midi-b\n",
);
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/profiles")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["filename"], "01-host-a");
assert_eq!(arr[0]["hostname"], "host-a");
assert_eq!(arr[0]["has_audio"], true);
assert_eq!(arr[1]["filename"], "02-host-b");
assert_eq!(arr[1]["hostname"], "host-b");
assert_eq!(arr[1]["has_midi"], true);
}
#[tokio::test]
async fn get_profiles_empty_dir() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/profiles")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn get_profiles_no_dir_configured() {
let (state, _dir) = test_state();
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/profiles")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn get_profile_by_filename() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
write_profile_file(
&profiles_dir,
"host-a.yaml",
"hostname: host-a\naudio:\n device: dev-a\n track_mappings:\n drums: [1]\n",
);
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/profiles/host-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_body(response).await;
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(parsed["profile"]["hostname"].as_str().unwrap() == "host-a");
assert!(parsed["yaml"].as_str().unwrap().contains("host-a"));
}
#[tokio::test]
async fn get_profile_not_found() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.uri("/profiles/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_profile_creates_file() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir.clone());
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/profiles/new-host")
.header("content-type", "application/json")
.body(Body::from(
r#"{"hostname": "new-host", "audio": {"device": "dev-x", "track_mappings": {"drums": [1]}}}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(profiles_dir.join("new-host.yaml").exists());
}
#[tokio::test]
async fn put_profile_validates() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/profiles/bad")
.header("content-type", "application/json")
.body(Body::from(r#"{"controllers": "not-an-array"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_profile_removes_file() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
write_profile_file(
&profiles_dir,
"host-a.yaml",
"hostname: host-a\naudio:\n device: dev-a\n track_mappings:\n drums: [1]\n",
);
state.profiles_dir = Some(profiles_dir.clone());
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("DELETE")
.uri("/profiles/host-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(!profiles_dir.join("host-a.yaml").exists());
}
#[tokio::test]
async fn delete_profile_not_found() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("DELETE")
.uri("/profiles/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_profile_path_traversal_rejected() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("PUT")
.uri("/profiles/..%2Fevil")
.header("content-type", "application/json")
.body(Body::from(r#"{"hostname": "evil"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_profile_path_traversal_rejected() {
let (mut state, dir) = test_state();
let profiles_dir = dir.path().join("profiles");
std::fs::create_dir(&profiles_dir).unwrap();
state.profiles_dir = Some(profiles_dir);
let app = router().with_state(state);
let response = app
.oneshot(
http::Request::builder()
.method("DELETE")
.uri("/profiles/..%2Fevil")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
}