diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 93243e8..74ba734 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2349,15 +2349,46 @@ pub async fn test_sync_target( let hints = provider_setup_hints(config.provider_hint.as_deref()); let (target, temp_ref): (Box, Option) = if let Some(id) = config.id.clone() { - // Existing target (any kind) — dispatch to its concrete impl. let row = state .store .get_sync_target(&id) .await .map_err(|e| WaError::new("sync", e.to_string()))?; - let target = crate::sync::build_sync_target(&row) - .map_err(|e| WaError::new("sync", e.to_string()))?; - (target, None) + if row.kind == "webdav" { + // Editing an existing webdav target: test the values on screen but + // reuse the STORED password (via its credential_ref) unless the user + // typed a new one — so "Test" works after a URL fix without having to + // re-enter the password. + let (credential_ref, temp_ref) = + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); + crate::sync::credentials::set(&temp_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (temp_ref.clone(), Some(temp_ref)) + } else { + (row.credential_ref.clone(), None) + }; + let target = crate::sync::WebDavTarget { + base_url: config.base_url.clone().or(row.base_url.clone()).unwrap_or_default(), + provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()), + remote_base_path: config + .remote_base_path + .clone() + .unwrap_or(row.remote_base_path.clone()), + username: config.username.clone().or(row.username.clone()).unwrap_or_default(), + credential_ref, + third_party: false, + allow_plaintext_lan: config + .allow_plaintext_lan + .unwrap_or(row.allow_plaintext_lan), + }; + (Box::new(target), temp_ref) + } else { + // Non-webdav (OAuth) target — dispatch to its concrete impl as-is. + let target = crate::sync::build_sync_target(&row) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (target, None) + } } else { // Unsaved WebDAV target: stash the secret under a temp credential_ref so // the same resolve-at-use path works, then clean it up after the test. @@ -2372,6 +2403,7 @@ pub async fn test_sync_target( } let target = crate::sync::WebDavTarget { base_url, + provider_hint: config.provider_hint.clone(), remote_base_path: config .remote_base_path .clone() @@ -3028,6 +3060,13 @@ mod tests { enabled, third_party, host: Some(host.to_string()), + upload_transcript: true, + upload_notes: true, + upload_summary: true, + upload_recording: false, + trigger_on_finalize: true, + allow_plaintext_lan: false, + encrypt_before_upload: false, } } diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 4b5cb3f..78db143 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -75,7 +75,11 @@ pub trait SyncManager: Send + Sync { /// WebDAV provider — primary targets + Synology (feature `sync`). #[cfg(feature = "sync")] pub struct WebDavTarget { - pub base_url: String, // https://host/remote.php/dav/files// , /seafdav , /dav , … + /// For Nextcloud/ownCloud this is just the server URL (e.g. `https://host`); + /// the canonical `/remote.php/dav/files//` path is derived. For other + /// providers it's the full DAV URL entered by the user (`…/seafdav`, `…/dav`). + pub base_url: String, + pub provider_hint: Option, pub remote_base_path: String, pub username: String, pub credential_ref: String, // key into the OS credential store — resolved at use, not stored here @@ -90,6 +94,7 @@ impl WebDavTarget { pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self { Self { base_url: row.base_url.clone().unwrap_or_default(), + provider_hint: row.provider_hint.clone(), remote_base_path: row.remote_base_path.clone(), username: row.username.clone().unwrap_or_default(), credential_ref: row.credential_ref.clone(), @@ -98,11 +103,38 @@ impl WebDavTarget { } } + /// The effective WebDAV root the paths hang off. For Nextcloud/ownCloud the + /// user supplies only their server URL and we build the canonical + /// `…/remote.php/dav/files//` path (deriving the origin, so a pasted + /// full DAV URL is normalized too). Other providers use the URL verbatim. + fn dav_root(&self) -> String { + match self.provider_hint.as_deref() { + Some("nextcloud") | Some("owncloud") => { + let origin = reqwest::Url::parse(&self.base_url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .unwrap_or_else(|| self.base_url.trim_end_matches('/').to_string()); + // ponytail: raw username in the path — fine for typical Nextcloud + // usernames; add percent-encoding if one ever contains `/` or spaces. + format!( + "{origin}/remote.php/dav/files/{}", + self.username.trim_matches('/') + ) + } + _ => self.base_url.trim_end_matches('/').to_string(), + } + } + /// Full URL for a path under this target, resolving `//` at the join. fn url_for(&self, remote_path: &str) -> String { - let base = self.base_url.trim_end_matches('/'); + let base = self.dav_root(); + let base = base.trim_end_matches('/'); let path = remote_path.trim_start_matches('/'); - format!("{base}/{path}") + if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + } } /// Resolves the secret and returns an authenticated request builder, after @@ -380,6 +412,7 @@ mod tests { fn url_join_normalizes_slashes() { let t = WebDavTarget { base_url: "https://host/dav/".to_string(), + provider_hint: None, remote_base_path: "/WhispAssist".to_string(), username: "u".to_string(), credential_ref: "r".to_string(), @@ -392,6 +425,30 @@ mod tests { ); } + #[test] + fn nextcloud_builds_dav_path_from_server_url() { + let mk = |base: &str| WebDavTarget { + base_url: base.to_string(), + provider_hint: Some("nextcloud".to_string()), + remote_base_path: "/WhispAssist".to_string(), + username: "dwdoubet".to_string(), + credential_ref: "r".to_string(), + third_party: false, + allow_plaintext_lan: false, + }; + // Bare server URL → canonical DAV path built from username. + assert_eq!( + mk("https://box.dou.bet").url_for("/WhispAssist"), + "https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist" + ); + // A pasted full DAV URL normalizes to the same thing (origin only). + assert_eq!( + mk("https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist") + .url_for("/WhispAssist"), + "https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist" + ); + } + /// Full WebDAV round-trip against a real server, opt-in (needs one running): /// WA_WEBDAV_URL, WA_WEBDAV_USER, WA_WEBDAV_PASS /// e.g. `wsgidav --host=127.0.0.1 --port=8899 --root= --auth=anonymous` @@ -414,6 +471,7 @@ mod tests { let target = WebDavTarget { base_url, + provider_hint: None, remote_base_path: "/WhispAssist-test".to_string(), username: user, credential_ref: cred_ref.to_string(),