From a3d133562e85f27e192c6fdb024362270127851c Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 07:00:36 -0700 Subject: [PATCH] feat(web): PUT /api/remotes + test endpoint, kind-aware, live-apply URL-keyed write-only password merge; whole-list replace rebuilds the live RwLock client list; POST /api/remotes/test checks reachability per kind. GET /api/remotes now reports kind + has_password (never plaintext). Integration test covers the PUT/GET round-trip, removal, and password preservation. Co-Authored-By: Claude Opus 4.8 --- src/web.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/api.rs | 48 +++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/web.rs b/src/web.rs index caaf4ad..fdb2a50 100644 --- a/src/web.rs +++ b/src/web.rs @@ -823,6 +823,25 @@ pub fn write_cookies(text: &str) -> Result { mod tests { use super::*; + #[test] + fn merge_keeps_blank_password_by_url() { + use crate::config::{RemoteKind, RemoteSection}; + let existing = vec![RemoteSection { + name: "old".into(), url: "http://p:8081".into(), + kind: RemoteKind::Catacomb, username: None, password: Some("SECRET".into()), + }]; + let inputs = vec![ + RemoteInput { name: "renamed".into(), url: "http://p:8081".into(), + kind: RemoteKind::Catacomb, username: None, password: None }, // blank → keep + RemoteInput { name: "new".into(), url: "http://q:8081".into(), + kind: RemoteKind::Catacomb, username: None, password: Some("TYPED".into()) }, + ]; + let out = merge_remote_passwords(&inputs, &existing); + assert_eq!(out[0].password.as_deref(), Some("SECRET")); // preserved by URL + assert_eq!(out[0].name, "renamed"); + assert_eq!(out[1].password.as_deref(), Some("TYPED")); + } + #[test] fn cookie_freshness_flags_expired_auth_cookie() { let now = 1_700_000_000; @@ -2246,6 +2265,99 @@ async fn get_maintenance_scan(State(state): State>) -> impl IntoRe Json(report) } +/// One peer as sent by the editor. Password is write-only: `None`/empty means +/// "keep the stored secret" (resolved by URL in `merge_remote_passwords`). +#[derive(serde::Deserialize)] +pub struct RemoteInput { + pub name: String, + pub url: String, + #[serde(default)] + pub kind: crate::config::RemoteKind, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub password: Option, +} + +/// Resolve write-only passwords: keep the typed password if non-empty, else +/// adopt the stored password of the existing remote with the same URL, else +/// None. Trims URLs so whitespace can't defeat the match. +pub fn merge_remote_passwords( + inputs: &[RemoteInput], + existing: &[crate::config::RemoteSection], +) -> Vec { + inputs + .iter() + .map(|i| { + let url = i.url.trim().to_string(); + let password = match i.password.as_deref().map(str::trim).filter(|p| !p.is_empty()) { + Some(p) => Some(p.to_string()), + None => existing + .iter() + .find(|e| e.url.trim() == url) + .and_then(|e| e.password.clone()), + }; + let username = i.username.as_deref().map(str::trim).filter(|u| !u.is_empty()).map(String::from); + crate::config::RemoteSection { + name: i.name.trim().to_string(), + url, + kind: i.kind.clone(), + username, + password, + } + }) + .collect() +} + +/// `PUT /api/remotes` — replace the whole peer list (live-apply). +async fn put_remotes( + State(state): State>, + Json(body): Json>, +) -> impl IntoResponse { + let merged = { + let mut cfg = state.config.lock_recover(); + let merged = merge_remote_passwords(&body, &cfg.remotes); + cfg.remotes = merged.clone(); + if let Err(e) = cfg.save(&state.config_path) { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); + } + merged + }; // config lock dropped here + let rebuilt: Vec> = merged + .iter() + .map(|r| std::sync::Arc::new(crate::remote::RemoteClientKind::from_section(r))) + .collect(); + *state.remotes.write().unwrap() = rebuilt; + Json(serde_json::json!({ "ok": true })).into_response() +} + +/// `POST /api/remotes/test` — reachability check for a (possibly unsaved) peer. +async fn test_remote( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + // Resolve a blank password from the stored remote with the same URL. + let section = { + let cfg = state.config.lock_recover(); + merge_remote_passwords(std::slice::from_ref(&body), &cfg.remotes) + .into_iter() + .next() + .unwrap() + }; + let result = tokio::task::spawn_blocking(move || { + match crate::remote::RemoteClientKind::from_section(§ion) { + crate::remote::RemoteClientKind::Catacomb(c) => c.library_json().map(|_| None::), + crate::remote::RemoteClientKind::Peertube(p) => p.list_channels().map(|ch| Some(ch.len())), + } + }) + .await; + match result { + Ok(Ok(channels)) => Json(serde_json::json!({ "ok": true, "channels": channels })).into_response(), + Ok(Err(e)) => Json(serde_json::json!({ "ok": false, "error": e })).into_response(), + Err(_) => Json(serde_json::json!({ "ok": false, "error": "test task failed" })).into_response(), + } +} + /// `GET /api/remotes` — list configured federation peers for the UI switcher. async fn get_remotes(State(state): State>) -> impl IntoResponse { let list: Vec<_> = state @@ -3386,7 +3498,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/maintenance/remove", post(post_maintenance_remove)) .route("/api/autotag/suggest", get(get_autotag_suggest)) .route("/api/autotag/apply", post(post_autotag_apply)) - .route("/api/remotes", get(get_remotes)) + .route("/api/remotes", get(get_remotes).put(put_remotes)) + .route("/api/remotes/test", post(test_remote)) .route("/api/remotes/:id/library", get(get_remote_library)) .route("/api/maintenance/dedup/scan", post(post_dedup_scan)) .route("/api/maintenance/dedup/status", get(get_dedup_status)) diff --git a/tests/api.rs b/tests/api.rs index e369d6c..5d12514 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -113,6 +113,17 @@ impl Server { fn delete(&self, path: &str) -> (u16, String) { curl(&self.req_args(path, "DELETE"), None).expect("curl DELETE") } + + fn put(&self, path: &str, json: &str) -> (u16, String) { + let mut a = self.req_args(path, "PUT"); + let url = a.pop().unwrap(); + a.extend([ + "-H".into(), "Content-Type: application/json".into(), + "--data-binary".into(), "@-".into(), + ]); + a.push(url); + curl(&a, Some(json)).expect("curl PUT") + } } impl Drop for Server { @@ -513,3 +524,40 @@ fn pwa_assets_served_and_ungated() { assert_eq!(s.get("/sw.js").0, 200, "service worker stays public"); assert_eq!(s.get("/icons/icon-512.png").0, 200, "icons stay public"); } + +#[test] +fn remotes_editor_put_get_roundtrip() { + if !have_curl() { eprintln!("skip: no curl"); return; } + let s = Server::start(); + + // Replace the (empty) peer list with a catacomb + a peertube remote. + let body = r#"[ + {"name":"peerA","url":"http://a:8081","kind":"catacomb","password":"secret"}, + {"name":"frama","url":"https://framatube.org","kind":"peertube"} + ]"#; + let (code, _) = s.put("/api/remotes", body); + assert_eq!(code, 200, "PUT /api/remotes should succeed"); + + let (code, list) = s.get("/api/remotes"); + assert_eq!(code, 200); + assert!(list.contains("\"kind\":\"catacomb\""), "catacomb kind present: {list}"); + assert!(list.contains("\"kind\":\"peertube\""), "peertube kind present: {list}"); + assert!(list.contains("\"name\":\"peerA\""), "peerA present: {list}"); + assert!(list.contains("\"name\":\"frama\""), "frama present: {list}"); + assert!(list.contains("\"has_password\":true"), "peerA has_password true: {list}"); + // Passwords are write-only: the plaintext must never be echoed back. + assert!(!list.contains("secret"), "GET must not leak the password: {list}"); + + // It persisted to config.toml too. + let cfg = std::fs::read_to_string(s.dir.join("config.toml")).unwrap(); + assert!(cfg.contains("framatube.org"), "peertube remote saved to config: {cfg}"); + assert!(cfg.contains("peertube"), "kind saved to config: {cfg}"); + + // Removing one entry via PUT drops it from GET. + let (code, _) = s.put("/api/remotes", r#"[{"name":"peerA","url":"http://a:8081","kind":"catacomb"}]"#); + assert_eq!(code, 200); + let (_, list) = s.get("/api/remotes"); + assert!(!list.contains("frama"), "removed peer gone from GET: {list}"); + // The blank-password edit of peerA kept its stored secret. + assert!(list.contains("\"has_password\":true"), "peerA password preserved on blank edit: {list}"); +}