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 <noreply@anthropic.com>
This commit is contained in:
parent
db0c74ffca
commit
a3d133562e
2 changed files with 162 additions and 1 deletions
115
src/web.rs
115
src/web.rs
|
|
@ -823,6 +823,25 @@ pub fn write_cookies(text: &str) -> Result<usize, String> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn cookie_freshness_flags_expired_auth_cookie() {
|
fn cookie_freshness_flags_expired_auth_cookie() {
|
||||||
let now = 1_700_000_000;
|
let now = 1_700_000_000;
|
||||||
|
|
@ -2246,6 +2265,99 @@ async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoRe
|
||||||
Json(report)
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<crate::config::RemoteSection> {
|
||||||
|
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<Arc<WebState>>,
|
||||||
|
Json(body): Json<Vec<RemoteInput>>,
|
||||||
|
) -> 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<std::sync::Arc<crate::remote::RemoteClientKind>> = 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<Arc<WebState>>,
|
||||||
|
Json(body): Json<RemoteInput>,
|
||||||
|
) -> 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::<usize>),
|
||||||
|
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.
|
/// `GET /api/remotes` — list configured federation peers for the UI switcher.
|
||||||
async fn get_remotes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
async fn get_remotes(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
let list: Vec<_> = state
|
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/maintenance/remove", post(post_maintenance_remove))
|
||||||
.route("/api/autotag/suggest", get(get_autotag_suggest))
|
.route("/api/autotag/suggest", get(get_autotag_suggest))
|
||||||
.route("/api/autotag/apply", post(post_autotag_apply))
|
.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/remotes/:id/library", get(get_remote_library))
|
||||||
.route("/api/maintenance/dedup/scan", post(post_dedup_scan))
|
.route("/api/maintenance/dedup/scan", post(post_dedup_scan))
|
||||||
.route("/api/maintenance/dedup/status", get(get_dedup_status))
|
.route("/api/maintenance/dedup/status", get(get_dedup_status))
|
||||||
|
|
|
||||||
48
tests/api.rs
48
tests/api.rs
|
|
@ -113,6 +113,17 @@ impl Server {
|
||||||
fn delete(&self, path: &str) -> (u16, String) {
|
fn delete(&self, path: &str) -> (u16, String) {
|
||||||
curl(&self.req_args(path, "DELETE"), None).expect("curl DELETE")
|
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 {
|
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("/sw.js").0, 200, "service worker stays public");
|
||||||
assert_eq!(s.get("/icons/icon-512.png").0, 200, "icons stay 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}");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue