diff --git a/src/web/api.rs b/src/web/api.rs index afa6ab3..93e7f23 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -9,6 +9,7 @@ use axum::response::Json; use axum::routing::{get, post}; use axum::Router; use serde::Serialize; +use tokio::time::{timeout, Duration}; use crate::config::Config; use crate::daemon::SupervisorCmd; @@ -24,6 +25,8 @@ pub fn routes() -> Router { .route("/api/logs", get(get_logs)) .route("/api/reconnect/{share}", post(reconnect_share)) .route("/api/preset/{profile}", post(post_preset)) + .route("/api/test-connection", post(post_test_connection)) + .route("/api/browse", post(post_browse)) } /// GET /api/status — overall daemon status. @@ -421,6 +424,117 @@ async fn get_logs( }) } +/// POST /api/test-connection — verify SFTP credentials can connect. +#[derive(serde::Deserialize)] +struct TestConnRequest { + nas_host: String, + nas_user: String, + #[serde(default)] + nas_pass: Option, + #[serde(default)] + nas_key_file: Option, + #[serde(default = "default_sftp_port")] + sftp_port: u16, +} + +fn default_sftp_port() -> u16 { + 22 +} + +#[derive(Serialize)] +struct TestConnResponse { + ok: bool, + message: String, +} + +const TEST_CONNECTION_TIMEOUT: Duration = Duration::from_secs(12); + +async fn post_test_connection( + Json(body): Json, +) -> Json { + let params = crate::rclone::probe::ConnParams { + nas_host: body.nas_host, + nas_user: body.nas_user, + nas_pass: body.nas_pass, + nas_key_file: body.nas_key_file, + sftp_port: body.sftp_port, + }; + + match timeout( + TEST_CONNECTION_TIMEOUT, + tokio::task::spawn_blocking(move || crate::rclone::probe::test_connection(¶ms)), + ) + .await + { + Ok(Ok(Ok(()))) => Json(TestConnResponse { + ok: true, + message: "Connected".to_string(), + }), + Ok(Ok(Err(e))) => Json(TestConnResponse { + ok: false, + message: e.to_string(), + }), + Ok(Err(e)) => Json(TestConnResponse { + ok: false, + message: format!("Internal error: {e}"), + }), + Err(_) => Json(TestConnResponse { + ok: false, + message: format!( + "Connection test timed out after {}s", + TEST_CONNECTION_TIMEOUT.as_secs() + ), + }), + } +} + +/// POST /api/browse — list subdirectories at a remote path. +#[derive(serde::Deserialize)] +struct BrowseRequest { + nas_host: String, + nas_user: String, + #[serde(default)] + nas_pass: Option, + #[serde(default)] + nas_key_file: Option, + #[serde(default = "default_sftp_port")] + sftp_port: u16, + #[serde(default = "default_browse_path")] + path: String, +} + +fn default_browse_path() -> String { + "/".to_string() +} + +#[derive(Serialize)] +struct BrowseResponse { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + dirs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +async fn post_browse( + Json(body): Json, +) -> Json { + let params = crate::rclone::probe::ConnParams { + nas_host: body.nas_host, + nas_user: body.nas_user, + nas_pass: body.nas_pass, + nas_key_file: body.nas_key_file, + sftp_port: body.sftp_port, + }; + let path = body.path; + + match tokio::task::spawn_blocking(move || crate::rclone::probe::browse_dirs(¶ms, &path)).await { + Ok(Ok(dirs)) => Json(BrowseResponse { ok: true, dirs: Some(dirs), error: None }), + Ok(Err(e)) => Json(BrowseResponse { ok: false, dirs: None, error: Some(e.to_string()) }), + Err(e) => Json(BrowseResponse { ok: false, dirs: None, error: Some(format!("Internal error: {e}")) }), + } +} + /// POST /api/reconnect/{share} — trigger reconnect for a single share. async fn reconnect_share( State(state): State, diff --git a/templates/web/tabs/config.html b/templates/web/tabs/config.html index 18d262c..82843c0 100644 --- a/templates/web/tabs/config.html +++ b/templates/web/tabs/config.html @@ -1,12 +1,37 @@ -
+
@@ -195,7 +275,15 @@ if (window.Alpine) {
- +
+ + + + +
@@ -261,7 +349,28 @@ if (window.Alpine) {
- +
+ + +
+
+ +
+