diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 80f30ed9f2..076ce39ecc 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -52,11 +52,14 @@ import { aiTestConnection, checkMcpServerStatus, installMcpServer, + forgetWebdavSyncSecretsPassphrase, forgetWebdavSavedPassword, listSystemFonts, + saveWebdavSyncSecretsPreference, saveWebdavSavedPassword, webdavPasswordStatus, webdavSyncDownload, + webdavSyncSecretsStatus, webdavSyncTest, webdavSyncUpload, type AiModelInfo, @@ -1278,13 +1281,14 @@ const webdavHasSavedPassword = ref(false); const webdavRemotePath = ref(localStorage.getItem("dbx-webdav-remote-path") || DEFAULT_WEB_DAV_REMOTE_PATH); const webdavSyncSecrets = ref(false); const webdavSecretsPassphrase = ref(""); +const webdavHasSavedSecretsPassphrase = ref(false); const webdavAutoUploadEnabled = ref(localStorage.getItem("dbx-webdav-auto-upload-enabled") === "true"); const webdavAutoUploadIntervalMinutes = ref(Number(localStorage.getItem("dbx-webdav-auto-upload-interval-minutes") || String(DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES))); const webdavBusy = ref<"" | "test" | "upload" | "download">(""); const webdavMessage = ref(""); const webdavError = ref(false); -const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim())); +const webdavReady = computed(() => !!webdavEndpoint.value.trim() && !webdavBusy.value && (!webdavSyncSecrets.value || !!webdavSecretsPassphrase.value.trim() || webdavHasSavedSecretsPassphrase.value)); function currentWebDavConfig(): WebDavConfig { return { @@ -1320,6 +1324,7 @@ async function runWebDavAction(kind: "test" | "upload" | "download", action: () try { rememberWebDavFields(); await applyWebDavPasswordPreference(); + await applyWebDavSyncSecretsPreference(); setWebDavResult(await action()); } catch (e: any) { setWebDavResult(e?.message || String(e), true); @@ -1356,6 +1361,40 @@ async function applyWebDavPasswordPreference() { } } +async function refreshWebDavSyncSecretsStatus() { + try { + const status = await webdavSyncSecretsStatus(); + webdavSyncSecrets.value = status.enabled; + webdavHasSavedSecretsPassphrase.value = status.hasSavedPassphrase; + } catch { + webdavSyncSecrets.value = false; + webdavHasSavedSecretsPassphrase.value = false; + } +} + +async function applyWebDavSyncSecretsPreference() { + const passphrase = webdavSecretsPassphrase.value.trim(); + if (!webdavSyncSecrets.value) { + await saveWebdavSyncSecretsPreference(false); + return; + } + await saveWebdavSyncSecretsPreference(true, passphrase || undefined); + if (passphrase) { + webdavHasSavedSecretsPassphrase.value = true; + webdavSecretsPassphrase.value = ""; + } +} + +async function clearWebDavSyncSecretsPassphrase() { + try { + await forgetWebdavSyncSecretsPassphrase(); + webdavHasSavedSecretsPassphrase.value = false; + webdavSecretsPassphrase.value = ""; + } catch (e: any) { + setWebDavResult(e?.message || String(e), true); + } +} + async function testWebDav() { await runWebDavAction("test", async () => { await webdavSyncTest(currentWebDavConfig()); @@ -1425,7 +1464,9 @@ watch( editDebugLoggingEnabled.value = settingsStore.desktopSettings.debug_logging_enabled; editSidebarTablePageSize.value = settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE; webdavPassword.value = ""; + webdavSecretsPassphrase.value = ""; await refreshWebDavPasswordStatus(); + await refreshWebDavSyncSecretsStatus(); syncAiEditState(); if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus(); if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus(); @@ -3173,7 +3214,21 @@ onUnmounted(cleanupPreviewEditor);
- +
+ + +

{{ t("settings.syncSecretsPassphraseDescription") }}

diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 877b99615d..697165a295 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -223,6 +223,9 @@ export const webdavSyncTest = forward("webdavSyncTest"); export const webdavPasswordStatus = forward("webdavPasswordStatus"); export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword"); export const forgetWebdavSavedPassword = forward("forgetWebdavSavedPassword"); +export const webdavSyncSecretsStatus = forward("webdavSyncSecretsStatus"); +export const saveWebdavSyncSecretsPreference = forward("saveWebdavSyncSecretsPreference"); +export const forgetWebdavSyncSecretsPassphrase = forward("forgetWebdavSyncSecretsPassphrase"); export const webdavSyncUpload = forward("webdavSyncUpload"); export const webdavSyncDownload = forward("webdavSyncDownload"); export const saveAiConversation = forward("saveAiConversation"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 693799e656..f689c1992b 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -1095,6 +1095,11 @@ export interface WebDavPasswordStatus { hasSavedPassword: boolean; } +export interface WebDavSyncSecretsStatus { + enabled: boolean; + hasSavedPassphrase: boolean; +} + export async function webdavSyncTest(config: WebDavConfig): Promise { return post("/api/cloud-sync/webdav/test", { config }); } @@ -1111,6 +1116,18 @@ export async function forgetWebdavSavedPassword(config: WebDavConfig): Promise { + return post("/api/cloud-sync/webdav/sync-secrets-status", {}); +} + +export async function saveWebdavSyncSecretsPreference(enabled: boolean, passphrase?: string): Promise { + return post("/api/cloud-sync/webdav/save-sync-secrets-preference", { enabled, passphrase }); +} + +export async function forgetWebdavSyncSecretsPassphrase(): Promise { + return post("/api/cloud-sync/webdav/forget-sync-secrets-passphrase", {}); +} + export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { return post("/api/cloud-sync/webdav/upload", { config, editorSettings, secretsPassphrase }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 41f81af92a..e3aad86622 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -190,6 +190,11 @@ export interface WebDavPasswordStatus { hasSavedPassword: boolean; } +export interface WebDavSyncSecretsStatus { + enabled: boolean; + hasSavedPassphrase: boolean; +} + export interface QueryPagination { limit: number; offset: number; @@ -428,6 +433,18 @@ export async function forgetWebdavSavedPassword(config: WebDavConfig): Promise { + return invoke("webdav_sync_secrets_status"); +} + +export async function saveWebdavSyncSecretsPreference(enabled: boolean, passphrase?: string): Promise { + return invoke("save_webdav_sync_secrets_preference", { enabled, passphrase }); +} + +export async function forgetWebdavSyncSecretsPassphrase(): Promise { + return invoke("forget_webdav_sync_secrets_passphrase"); +} + export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise { return invoke("webdav_sync_upload", { config, editorSettings, secretsPassphrase }); } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index cbc72e8b76..8b9f989a27 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -11,9 +11,10 @@ use sha2::{Digest, Sha256}; use crate::ai::AiConfig; use crate::connection_secrets::{ - MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, + MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, + MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, }; -use crate::models::connection::{ConnectionConfig, TransportLayerConfig}; +use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig}; use crate::saved_sql::SavedSqlLibrary; use crate::storage::{DesktopSettings, Storage}; @@ -31,6 +32,7 @@ const SECRET_KEYS: &[&str] = &[ MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_TOKEN_SIGNING_KEY, + NACOS_AUTH_PASSWORD_KEY, ]; const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers."; @@ -50,6 +52,13 @@ pub struct WebDavPasswordStatus { pub has_saved_password: bool, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncSecretsStatus { + pub enabled: bool, + pub has_saved_passphrase: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SyncSnapshot { @@ -143,6 +152,21 @@ pub async fn build_sync_snapshot( }) } +pub async fn build_sync_snapshot_with_saved_secrets( + storage: &Storage, + app_version: impl Into, + editor_settings: Option, + secrets_passphrase: Option<&str>, +) -> Result { + match normalized_passphrase(secrets_passphrase) { + Some(passphrase) => build_sync_snapshot(storage, app_version, editor_settings, Some(passphrase)).await, + None => { + let saved_passphrase = resolve_webdav_sync_secrets_passphrase(storage).await?; + build_sync_snapshot(storage, app_version, editor_settings, saved_passphrase.as_deref()).await + } + } +} + pub async fn apply_sync_snapshot( storage: &Storage, snapshot: &SyncSnapshot, @@ -214,6 +238,46 @@ pub async fn resolve_webdav_password(storage: &Storage, config: &mut WebDavConfi Ok(()) } +pub async fn webdav_sync_secrets_status(storage: &Storage) -> Result { + Ok(WebDavSyncSecretsStatus { + enabled: storage.load_webdav_sync_secrets_enabled().await?, + has_saved_passphrase: storage.load_webdav_sync_secrets_passphrase_blob().await?.is_some(), + }) +} + +pub async fn save_webdav_sync_secrets_preference( + storage: &Storage, + enabled: bool, + passphrase: Option<&str>, +) -> Result<(), String> { + let normalized = normalized_passphrase(passphrase); + let blob = match normalized { + Some(passphrase) => { + let secret = storage.load_or_create_local_device_secret().await?; + let blob = encrypt_text_with_secret(passphrase, &secret)?; + Some(serde_json::to_value(blob).map_err(|e| e.to_string())?) + } + None => None, + }; + storage.save_webdav_sync_secrets_preference(enabled, blob.as_ref()).await +} + +pub async fn forget_webdav_sync_secrets_passphrase(storage: &Storage) -> Result<(), String> { + storage.delete_webdav_sync_secrets_passphrase_blob().await +} + +pub async fn resolve_webdav_sync_secrets_passphrase(storage: &Storage) -> Result, String> { + if !storage.load_webdav_sync_secrets_enabled().await? { + return Ok(None); + } + let Some(value) = storage.load_webdav_sync_secrets_passphrase_blob().await? else { + return Ok(None); + }; + let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?; + let secret = storage.load_or_create_local_device_secret().await?; + decrypt_text_with_secret(&blob, &secret).map(Some) +} + impl WebDavClient { pub fn new(config: WebDavConfig) -> Self { Self { http: Client::new(), config } @@ -326,6 +390,8 @@ fn scrub_connection_secrets(config: &mut ConnectionConfig) { } config.redis_sentinel_password.clear(); config.connection_string = None; + scrub_mq_external_config_secrets(config); + scrub_nacos_auth_secrets(config); } fn webdav_password_account(config: &WebDavConfig) -> String { @@ -382,6 +448,7 @@ async fn build_sensitive_payload( push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string); } push_mq_external_config_secrets(&mut connection_secrets, config); + push_nacos_external_config_secrets(&mut connection_secrets, config); } Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? }) @@ -407,6 +474,44 @@ fn push_mq_external_config_secrets(secrets: &mut Vec, } } +fn scrub_mq_external_config_secrets(config: &mut ConnectionConfig) { + if config.db_type != DatabaseType::MessageQueue { + return; + } + let Some(external_config) = config.external_config.as_mut() else { + return; + }; + if let Some(auth) = external_config.get_mut("auth").and_then(serde_json::Value::as_object_mut) { + match auth.get("kind").and_then(serde_json::Value::as_str) { + Some("token") => scrub_json_secret(auth, "token"), + Some("basic") => scrub_json_secret(auth, "password"), + Some("apiKey") | Some("api_key") | Some("apikey") => scrub_json_secret(auth, "value"), + Some("oauth2") => scrub_json_secret(auth, "clientSecret"), + _ => {} + } + } + if let Some(signing) = external_config.get_mut("tokenSigning").and_then(serde_json::Value::as_object_mut) { + scrub_json_secret(signing, "key"); + } +} + +fn push_nacos_external_config_secrets(secrets: &mut Vec, config: &ConnectionConfig) { + if config.db_type != DatabaseType::Nacos { + return; + } + let Some(auth) = config + .external_config + .as_ref() + .and_then(|external_config| external_config.get("auth")) + .and_then(serde_json::Value::as_object) + else { + return; + }; + if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { + push_json_secret(secrets, &config.id, NACOS_AUTH_PASSWORD_KEY, auth, "password"); + } +} + fn push_json_secret( secrets: &mut Vec, connection_id: &str, @@ -430,6 +535,30 @@ fn push_secret(secrets: &mut Vec, connection_id: &str, }); } +fn scrub_nacos_auth_secrets(config: &mut ConnectionConfig) { + if config.db_type != DatabaseType::Nacos { + return; + } + let Some(auth) = config + .external_config + .as_mut() + .and_then(|external_config| external_config.get_mut("auth")) + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") && auth.contains_key("password") + { + scrub_json_secret(auth, "password"); + } +} + +fn scrub_json_secret(object: &mut serde_json::Map, field: &str) { + if object.contains_key(field) { + object.insert(field.to_string(), serde_json::Value::String(String::new())); + } +} + async fn apply_sensitive_payload(storage: &Storage, payload: &SensitiveSyncPayload) -> Result<(), String> { for secret in &payload.connection_secrets { if !SECRET_KEYS.contains(&secret.key.as_str()) @@ -586,12 +715,130 @@ fn parent_collection_paths(remote_path: &str) -> Vec { #[cfg(test)] mod tests { use super::{ - decrypt_sensitive_payload, encrypt_sensitive_payload, normalized_remote_path, parent_collection_paths, - scrub_connection_secrets, ConnectionSecretSnapshot, SensitiveSyncPayload, + build_sync_snapshot_with_saved_secrets, decrypt_sensitive_payload, encrypt_sensitive_payload, + forget_webdav_sync_secrets_passphrase, normalized_remote_path, parent_collection_paths, + resolve_webdav_sync_secrets_passphrase, save_webdav_sync_secrets_preference, scrub_connection_secrets, + webdav_sync_secrets_status, ConnectionSecretSnapshot, SensitiveSyncPayload, }; + use crate::connection_secrets::NACOS_AUTH_PASSWORD_KEY; use crate::models::connection::{ default_redis_key_separator, ConnectionConfig, DatabaseType, TransportLayerConfig, }; + use crate::storage::Storage; + + fn temp_db_path(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("dbx-cloud-sync-{name}-{}.db", uuid::Uuid::new_v4())) + } + + fn postgres_connection(id: &str, password: &str) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: "Postgres".to_string(), + db_type: DatabaseType::Postgres, + driver_profile: None, + driver_label: None, + url_params: None, + host: "127.0.0.1".to_string(), + port: 5432, + username: "app".to_string(), + password: password.to_string(), + database: Some("app_db".to_string()), + visible_databases: None, + visible_schemas: None, + attached_databases: Vec::new(), + color: None, + transport_layers: Vec::new(), + connect_timeout_secs: 5, + query_timeout_secs: 30, + idle_timeout_secs: 60, + keepalive_interval_secs: 0, + ssl: false, + ca_cert_path: String::new(), + client_cert_path: String::new(), + client_key_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: None, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), + redis_scan_page_size: None, + etcd_endpoints: String::new(), + gbase_server: String::new(), + informix_server: String::new(), + external_config: None, + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), + one_time: false, + read_only: false, + } + } + + fn nacos_connection(id: &str, password: &str) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: "Nacos".to_string(), + db_type: DatabaseType::Nacos, + driver_profile: None, + driver_label: None, + url_params: None, + host: "127.0.0.1".to_string(), + port: 8848, + username: "nacos".to_string(), + password: String::new(), + database: None, + visible_databases: None, + visible_schemas: None, + attached_databases: Vec::new(), + color: None, + transport_layers: Vec::new(), + connect_timeout_secs: 5, + query_timeout_secs: 30, + idle_timeout_secs: 60, + keepalive_interval_secs: 0, + ssl: false, + ca_cert_path: String::new(), + client_cert_path: String::new(), + client_key_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: None, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + redis_key_separator: default_redis_key_separator(), + redis_scan_page_size: None, + etcd_endpoints: String::new(), + gbase_server: String::new(), + informix_server: String::new(), + external_config: Some(serde_json::json!({ + "namespace": "public", + "group": "DEFAULT_GROUP", + "auth": { + "kind": "usernamePassword", + "username": "nacos", + "password": password + } + })), + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), + one_time: false, + read_only: false, + } + } + + fn nacos_auth_password(config: &ConnectionConfig) -> Option<&str> { + config.external_config.as_ref()?.get("auth")?.get("password")?.as_str() + } #[test] fn normalizes_empty_remote_path_to_default() { @@ -731,4 +978,68 @@ mod tests { let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap(); assert!(decrypt_sensitive_payload(&encrypted, "wrong-pass").is_err()); } + + #[tokio::test] + async fn webdav_sync_secrets_preference_round_trips_and_clears_passphrase() { + let storage = Storage::open(&temp_db_path("sync-secrets-preference")).await.unwrap(); + + let status = webdav_sync_secrets_status(&storage).await.unwrap(); + assert!(!status.enabled); + assert!(!status.has_saved_passphrase); + assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap(), None); + + save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); + + let status = webdav_sync_secrets_status(&storage).await.unwrap(); + assert!(status.enabled); + assert!(status.has_saved_passphrase); + assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap().as_deref(), Some("sync-pass")); + + forget_webdav_sync_secrets_passphrase(&storage).await.unwrap(); + let status = webdav_sync_secrets_status(&storage).await.unwrap(); + assert!(status.enabled); + assert!(!status.has_saved_passphrase); + assert_eq!(resolve_webdav_sync_secrets_passphrase(&storage).await.unwrap(), None); + } + + #[tokio::test] + async fn saved_sync_passphrase_encrypts_snapshot_secrets_without_exposing_connection_passwords() { + let storage = Storage::open(&temp_db_path("saved-sync-snapshot")).await.unwrap(); + storage.save_connections(&[postgres_connection("pg", "db-secret")]).await.unwrap(); + + let plain_snapshot = + build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); + assert!(plain_snapshot.encrypted_secrets.is_none()); + assert_eq!(plain_snapshot.connections[0].password, ""); + + save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); + let encrypted_snapshot = + build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); + + assert_eq!(encrypted_snapshot.connections[0].password, ""); + let encrypted = encrypted_snapshot.encrypted_secrets.as_ref().expect("encrypted secrets"); + let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap(); + assert!(decrypted.connection_secrets.iter().any(|secret| { + secret.connection_id == "pg" && secret.key == "password" && secret.secret == "db-secret" + })); + } + + #[tokio::test] + async fn saved_sync_passphrase_encrypts_nacos_auth_password_without_exposing_it() { + let storage = Storage::open(&temp_db_path("saved-sync-nacos-snapshot")).await.unwrap(); + storage.save_connections(&[nacos_connection("nacos", "nacos-secret")]).await.unwrap(); + + save_webdav_sync_secrets_preference(&storage, true, Some("sync-pass")).await.unwrap(); + let encrypted_snapshot = + build_sync_snapshot_with_saved_secrets(&storage, "test-version", None, None).await.unwrap(); + + assert_eq!(nacos_auth_password(&encrypted_snapshot.connections[0]), Some("")); + let public_json = serde_json::to_string(&encrypted_snapshot.connections).unwrap(); + assert!(!public_json.contains("nacos-secret")); + let encrypted = encrypted_snapshot.encrypted_secrets.as_ref().expect("encrypted secrets"); + let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap(); + assert!(decrypted.connection_secrets.iter().any(|secret| { + secret.connection_id == "nacos" && secret.key == NACOS_AUTH_PASSWORD_KEY && secret.secret == "nacos-secret" + })); + } } diff --git a/crates/dbx-core/src/connection_secrets.rs b/crates/dbx-core/src/connection_secrets.rs index 1b06addeb4..2748485fe4 100644 --- a/crates/dbx-core/src/connection_secrets.rs +++ b/crates/dbx-core/src/connection_secrets.rs @@ -18,6 +18,8 @@ pub const MQ_AUTH_API_KEY_VALUE_KEY: &str = "mq.auth.api_key_value"; pub const MQ_AUTH_CLIENT_SECRET_KEY: &str = "mq.auth.client_secret"; pub const MQ_TOKEN_SIGNING_SECRET_PREFIX: &str = "mq.token_signing."; pub const MQ_TOKEN_SIGNING_KEY: &str = "mq.token_signing.key"; +pub const NACOS_AUTH_SECRET_PREFIX: &str = "nacos.auth."; +pub const NACOS_AUTH_PASSWORD_KEY: &str = "nacos.auth.password"; pub trait ConnectionSecretStore { fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 5d774783de..cc33ef7369 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -9,7 +9,8 @@ use uuid::Uuid; use crate::ai::{AiChatMessage, AiConfig, AiConversation, AiProvider}; use crate::connection_secrets::{ MQ_AUTH_API_KEY_VALUE_KEY, MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, - MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, + MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY, + NACOS_AUTH_SECRET_PREFIX, }; use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle}; use crate::history::{HistoryEntry, MAX_HISTORY}; @@ -505,6 +506,18 @@ fn scrub_mq_token_signing_secret(config: &mut ConnectionConfig) { scrub_json_secret(signing, "key"); } +fn scrub_nacos_auth_secrets(config: &mut ConnectionConfig) { + if config.db_type != DatabaseType::Nacos { + return; + } + let Some(auth) = nacos_auth_object_mut(config.external_config.as_mut()) else { + return; + }; + if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { + scrub_json_secret(auth, "password"); + } +} + fn delete_secret_prefix_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, @@ -935,6 +948,35 @@ impl Storage { settings.insert("webdav_passwords".to_string(), serde_json::Value::Object(credentials)); self.save_app_settings_json(&settings).await } + + pub async fn save_webdav_sync_secrets_preference( + &self, + enabled: bool, + blob: Option<&serde_json::Value>, + ) -> Result<(), String> { + let mut settings = self.load_app_settings_json().await?; + settings.insert("webdav_sync_secrets_enabled".to_string(), serde_json::Value::Bool(enabled)); + if let Some(blob) = blob { + settings.insert("webdav_sync_secrets_passphrase".to_string(), blob.clone()); + } + self.save_app_settings_json(&settings).await + } + + pub async fn load_webdav_sync_secrets_enabled(&self) -> Result { + let settings = self.load_app_settings_json().await?; + Ok(settings.get("webdav_sync_secrets_enabled").and_then(serde_json::Value::as_bool).unwrap_or(false)) + } + + pub async fn load_webdav_sync_secrets_passphrase_blob(&self) -> Result, String> { + let settings = self.load_app_settings_json().await?; + Ok(settings.get("webdav_sync_secrets_passphrase").cloned()) + } + + pub async fn delete_webdav_sync_secrets_passphrase_blob(&self) -> Result<(), String> { + let mut settings = self.load_app_settings_json().await?; + settings.remove("webdav_sync_secrets_passphrase"); + self.save_app_settings_json(&settings).await + } } // AI Conversations @@ -1031,6 +1073,7 @@ impl Storage { sanitized.connection_string = None; scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); + scrub_nacos_auth_secrets(&mut sanitized); let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json]) @@ -1067,6 +1110,7 @@ impl Storage { sanitized.connection_string = None; scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); + scrub_nacos_auth_secrets(&mut sanitized); let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![config_id, json]) @@ -1124,6 +1168,7 @@ impl Storage { } persist_mq_auth_secrets_in_tx(&tx, &config)?; persist_mq_token_signing_secret_in_tx(&tx, &config)?; + persist_nacos_auth_secrets_in_tx(&tx, &config)?; } if configs.is_empty() { @@ -1210,11 +1255,14 @@ impl Storage { config.connection_string = self.get_secret(&id, "connection_string").await?; let needs_mq_auth_rewrite = self.hydrate_mq_auth_secrets(&id, &mut config).await?; let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?; - let needs_mq_secret_rewrite = needs_mq_auth_rewrite || needs_mq_token_signing_rewrite; - if needs_mq_secret_rewrite { + let needs_nacos_auth_rewrite = self.hydrate_nacos_auth_secret(&id, &mut config).await?; + let needs_external_secret_rewrite = + needs_mq_auth_rewrite || needs_mq_token_signing_rewrite || needs_nacos_auth_rewrite; + if needs_external_secret_rewrite { let mut sanitized = config.clone().canonicalized(); scrub_mq_auth_secrets(&mut sanitized); scrub_mq_token_signing_secret(&mut sanitized); + scrub_nacos_auth_secrets(&mut sanitized); let sanitized_json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; let update_id = id.clone(); self.with_conn(move |conn| { @@ -1275,6 +1323,24 @@ impl Storage { hydrate_mq_json_secret(self, connection_id, MQ_TOKEN_SIGNING_KEY, signing, "key").await } + + async fn hydrate_nacos_auth_secret( + &self, + connection_id: &str, + config: &mut ConnectionConfig, + ) -> Result { + if config.db_type != DatabaseType::Nacos { + return Ok(false); + } + let Some(auth) = nacos_auth_object_mut(config.external_config.as_mut()) else { + return Ok(false); + }; + if auth.get("kind").and_then(serde_json::Value::as_str) != Some("usernamePassword") { + return Ok(false); + } + + hydrate_mq_json_secret(self, connection_id, NACOS_AUTH_PASSWORD_KEY, auth, "password").await + } } // Saved SQL @@ -2092,6 +2158,45 @@ fn persist_mq_token_signing_secret_in_tx( persist_json_secret_if_present_in_tx(tx, &config.id, MQ_TOKEN_SIGNING_KEY, signing, "key") } +fn persist_nacos_auth_secrets_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionConfig) -> Result<(), String> { + if config.db_type != DatabaseType::Nacos { + delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; + return Ok(()); + } + + let Some(auth) = nacos_auth_object(config.external_config.as_ref()) else { + delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; + return Ok(()); + }; + + if auth.get("kind").and_then(serde_json::Value::as_str) == Some("usernamePassword") { + replace_nacos_auth_secret_in_tx(tx, &config.id, NACOS_AUTH_PASSWORD_KEY, auth, "password")?; + } else { + delete_secret_prefix_in_tx(tx, &config.id, NACOS_AUTH_SECRET_PREFIX)?; + } + + Ok(()) +} + +fn replace_nacos_auth_secret_in_tx( + tx: &rusqlite::Transaction<'_>, + connection_id: &str, + key: &str, + auth: &serde_json::Map, + field: &str, +) -> Result<(), String> { + let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty()); + let existing = if current.is_none() { get_secret_in_tx(tx, connection_id, key)? } else { None }; + delete_secret_prefix_in_tx(tx, connection_id, NACOS_AUTH_SECRET_PREFIX)?; + match current { + Some(secret) => persist_secret_in_tx(tx, connection_id, key, secret), + None => match existing { + Some(secret) => persist_secret_in_tx(tx, connection_id, key, &secret), + None => Ok(()), + }, + } +} + fn persist_json_secret_if_present_in_tx( tx: &rusqlite::Transaction<'_>, connection_id: &str, @@ -2153,6 +2258,16 @@ fn mq_token_signing_object_mut( value?.get_mut("tokenSigning")?.as_object_mut() } +fn nacos_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map> { + value?.get("auth")?.as_object() +} + +fn nacos_auth_object_mut( + value: Option<&mut serde_json::Value>, +) -> Option<&mut serde_json::Map> { + value?.get_mut("auth")?.as_object_mut() +} + fn is_api_key_auth_kind(kind: &str) -> bool { matches!(kind, "apiKey" | "api_key" | "apikey") } @@ -2183,7 +2298,9 @@ fn map_from_sql_err(err: serde_json::Error) -> rusqlite::Error { #[cfg(test)] mod tests { use super::{maybe_import_user_data_db, DataDbImportResult, DesktopIconTheme, DesktopSettings, Storage}; - use crate::connection_secrets::{MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY}; + use crate::connection_secrets::{ + MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY, + }; use crate::models::connection::{ConnectionConfig, DatabaseType}; use crate::saved_sql::SavedSqlFile; use std::time::{SystemTime, UNIX_EPOCH}; @@ -2254,6 +2371,63 @@ mod tests { } } + fn nacos_connection(id: &str, password: &str) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: "Nacos".to_string(), + db_type: DatabaseType::Nacos, + driver_profile: None, + driver_label: None, + url_params: None, + host: "127.0.0.1".to_string(), + port: 8848, + username: "nacos".to_string(), + password: String::new(), + database: None, + visible_databases: None, + visible_schemas: None, + attached_databases: Vec::new(), + color: None, + transport_layers: Vec::new(), + connect_timeout_secs: 30, + query_timeout_secs: 300, + idle_timeout_secs: 600, + keepalive_interval_secs: crate::models::connection::default_keepalive_interval_secs(), + ssl: false, + ca_cert_path: String::new(), + client_cert_path: String::new(), + client_key_path: String::new(), + sysdba: false, + oracle_connection_type: None, + connection_string: None, + redis_connection_mode: None, + redis_sentinel_master: String::new(), + redis_sentinel_nodes: String::new(), + redis_sentinel_username: String::new(), + redis_sentinel_password: String::new(), + redis_sentinel_tls: false, + redis_cluster_nodes: String::new(), + redis_key_separator: ":".to_string(), + redis_scan_page_size: None, + etcd_endpoints: String::new(), + gbase_server: String::new(), + informix_server: String::new(), + external_config: Some(serde_json::json!({ + "namespace": "public", + "group": "DEFAULT_GROUP", + "auth": { + "kind": "usernamePassword", + "username": "nacos", + "password": password + } + })), + jdbc_driver_class: None, + jdbc_driver_paths: Vec::new(), + one_time: false, + read_only: false, + } + } + async fn raw_connection_json(storage: &Storage, id: &str) -> String { let id = id.to_string(); storage @@ -2286,6 +2460,10 @@ mod tests { config.external_config.as_ref()?.get("tokenSigning")?.get("key")?.as_str() } + fn nacos_auth_password(config: &ConnectionConfig) -> Option<&str> { + config.external_config.as_ref()?.get("auth")?.get("password")?.as_str() + } + async fn create_data_dir_with_connection(name: &str, connection_id: &str, token: &str) -> std::path::PathBuf { let data_dir = temp_data_dir(name); let storage = Storage::open(&data_dir.join("dbx.db")).await.unwrap(); @@ -2485,6 +2663,46 @@ mod tests { assert_eq!(mq_token_signing_key(&loaded[0]), Some("broker-signing-secret")); } + #[tokio::test] + async fn save_connections_moves_nacos_auth_password_to_secret_table_and_restores_it() { + let path = temp_db_path("nacos-auth-secret"); + let storage = Storage::open(&path).await.unwrap(); + + storage.save_connections(&[nacos_connection("nacos", "nacos-secret")]).await.unwrap(); + + let raw_json = raw_connection_json(&storage, "nacos").await; + assert!(!raw_json.contains("nacos-secret")); + let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); + assert_eq!(nacos_auth_password(&persisted), Some("")); + assert_eq!( + storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), + Some("nacos-secret") + ); + + let loaded = storage.load_connections().await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(nacos_auth_password(&loaded[0]), Some("nacos-secret")); + } + + #[tokio::test] + async fn load_connections_migrates_legacy_nacos_auth_password_out_of_config_json() { + let path = temp_db_path("nacos-auth-legacy-migration"); + let storage = Storage::open(&path).await.unwrap(); + insert_raw_connection(&storage, &nacos_connection("nacos", "legacy-nacos-secret")).await; + + let loaded = storage.load_connections().await.unwrap(); + + assert_eq!(nacos_auth_password(&loaded[0]), Some("legacy-nacos-secret")); + assert_eq!( + storage.get_secret("nacos", NACOS_AUTH_PASSWORD_KEY).await.unwrap().as_deref(), + Some("legacy-nacos-secret") + ); + let raw_json = raw_connection_json(&storage, "nacos").await; + assert!(!raw_json.contains("legacy-nacos-secret")); + let persisted: ConnectionConfig = serde_json::from_str(&raw_json).unwrap(); + assert_eq!(nacos_auth_password(&persisted), Some("")); + } + #[tokio::test] async fn desktop_settings_default_to_background_enabled() { let path = temp_db_path("desktop-settings-default"); diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 9f660f2461..d65a327adf 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -551,6 +551,15 @@ async fn main() { .route("/cloud-sync/webdav/password-status", post(routes::cloud_sync::webdav_password_status)) .route("/cloud-sync/webdav/save-password", post(routes::cloud_sync::save_webdav_saved_password)) .route("/cloud-sync/webdav/forget-password", post(routes::cloud_sync::forget_webdav_saved_password)) + .route("/cloud-sync/webdav/sync-secrets-status", post(routes::cloud_sync::webdav_sync_secrets_status)) + .route( + "/cloud-sync/webdav/save-sync-secrets-preference", + post(routes::cloud_sync::save_webdav_sync_secrets_preference), + ) + .route( + "/cloud-sync/webdav/forget-sync-secrets-passphrase", + post(routes::cloud_sync::forget_webdav_sync_secrets_passphrase), + ) .route("/cloud-sync/webdav/upload", post(routes::cloud_sync::webdav_sync_upload)) .route("/cloud-sync/webdav/download", post(routes::cloud_sync::webdav_sync_download)); diff --git a/crates/dbx-web/src/routes/cloud_sync.rs b/crates/dbx-web/src/routes/cloud_sync.rs index e9cd6b1332..4417b9076e 100644 --- a/crates/dbx-web/src/routes/cloud_sync.rs +++ b/crates/dbx-web/src/routes/cloud_sync.rs @@ -3,9 +3,12 @@ use std::sync::Arc; use axum::extract::State; use axum::Json; use dbx_core::cloud_sync::{ - apply_sync_snapshot, build_sync_snapshot, forget_webdav_password, resolve_webdav_password, save_webdav_password, - webdav_saved_password_status, ApplySnapshotOptions, ApplySnapshotSummary, WebDavClient, WebDavConfig, - WebDavPasswordStatus, WebDavSyncSummary, + apply_sync_snapshot, build_sync_snapshot_with_saved_secrets, forget_webdav_password, + forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_webdav_password, + resolve_webdav_sync_secrets_passphrase, save_webdav_password, + save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, webdav_saved_password_status, + webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary, + WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary, }; use dbx_core::storage::DesktopSettings; use serde::{Deserialize, Serialize}; @@ -50,6 +53,13 @@ pub struct WebDavDownloadRequest { pub secrets_passphrase: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebDavSyncSecretsPreferenceRequest { + pub enabled: bool, + pub passphrase: Option, +} + pub async fn webdav_sync_test( State(state): State>, Json(mut req): Json, @@ -82,12 +92,33 @@ pub async fn forget_webdav_saved_password( Ok(Json(())) } +pub async fn webdav_sync_secrets_status( + State(state): State>, +) -> Result, AppError> { + core_webdav_sync_secrets_status(&state.app.storage).await.map(Json).map_err(AppError) +} + +pub async fn save_webdav_sync_secrets_preference( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + core_save_webdav_sync_secrets_preference(&state.app.storage, req.enabled, req.passphrase.as_deref()) + .await + .map_err(AppError)?; + Ok(Json(())) +} + +pub async fn forget_webdav_sync_secrets_passphrase(State(state): State>) -> Result, AppError> { + core_forget_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError)?; + Ok(Json(())) +} + pub async fn webdav_sync_upload( State(state): State>, Json(mut req): Json, ) -> Result, AppError> { resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError)?; - let snapshot = build_sync_snapshot( + let snapshot = build_sync_snapshot_with_saved_secrets( &state.app.storage, env!("CARGO_PKG_VERSION"), req.editor_settings, @@ -104,10 +135,16 @@ pub async fn webdav_sync_download( ) -> Result, AppError> { resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError)?; let (snapshot, summary) = WebDavClient::new(req.config).get_snapshot().await.map_err(AppError)?; + let explicit_passphrase = req.secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let saved_passphrase = if explicit_passphrase.is_some() { + None + } else { + resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError)? + }; let apply_summary = apply_sync_snapshot( &state.app.storage, &snapshot, - ApplySnapshotOptions { secrets_passphrase: req.secrets_passphrase.as_deref() }, + ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) }, ) .await .map_err(AppError)?; diff --git a/src-tauri/src/commands/cloud_sync.rs b/src-tauri/src/commands/cloud_sync.rs index 2eeca1428b..c52cd37536 100644 --- a/src-tauri/src/commands/cloud_sync.rs +++ b/src-tauri/src/commands/cloud_sync.rs @@ -1,9 +1,12 @@ use std::sync::Arc; use dbx_core::cloud_sync::{ - apply_sync_snapshot, build_sync_snapshot, forget_webdav_password, resolve_webdav_password, save_webdav_password, - webdav_saved_password_status, ApplySnapshotOptions, ApplySnapshotSummary, WebDavClient, WebDavConfig, - WebDavPasswordStatus, WebDavSyncSummary, + apply_sync_snapshot, build_sync_snapshot_with_saved_secrets, forget_webdav_password, + forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_webdav_password, + resolve_webdav_sync_secrets_passphrase, save_webdav_password, + save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, webdav_saved_password_status, + webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary, + WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary, }; use dbx_core::storage::DesktopSettings; use serde::{Deserialize, Serialize}; @@ -48,6 +51,25 @@ pub async fn forget_webdav_saved_password(state: State<'_, Arc>, confi forget_webdav_password(&state.storage, &config).await } +#[tauri::command] +pub async fn webdav_sync_secrets_status(state: State<'_, Arc>) -> Result { + core_webdav_sync_secrets_status(&state.storage).await +} + +#[tauri::command] +pub async fn save_webdav_sync_secrets_preference( + state: State<'_, Arc>, + enabled: bool, + passphrase: Option, +) -> Result<(), String> { + core_save_webdav_sync_secrets_preference(&state.storage, enabled, passphrase.as_deref()).await +} + +#[tauri::command] +pub async fn forget_webdav_sync_secrets_passphrase(state: State<'_, Arc>) -> Result<(), String> { + core_forget_webdav_sync_secrets_passphrase(&state.storage).await +} + #[tauri::command] pub async fn webdav_sync_upload( state: State<'_, Arc>, @@ -56,9 +78,13 @@ pub async fn webdav_sync_upload( secrets_passphrase: Option, ) -> Result { resolve_webdav_password(&state.storage, &mut config).await?; - let snapshot = - build_sync_snapshot(&state.storage, env!("CARGO_PKG_VERSION"), editor_settings, secrets_passphrase.as_deref()) - .await?; + let snapshot = build_sync_snapshot_with_saved_secrets( + &state.storage, + env!("CARGO_PKG_VERSION"), + editor_settings, + secrets_passphrase.as_deref(), + ) + .await?; WebDavClient::new(config).put_snapshot(&snapshot).await } @@ -70,10 +96,16 @@ pub async fn webdav_sync_download( ) -> Result { resolve_webdav_password(&state.storage, &mut config).await?; let (snapshot, summary) = WebDavClient::new(config).get_snapshot().await?; + let explicit_passphrase = secrets_passphrase.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let saved_passphrase = if explicit_passphrase.is_some() { + None + } else { + resolve_webdav_sync_secrets_passphrase(&state.storage).await? + }; let apply_summary = apply_sync_snapshot( &state.storage, &snapshot, - ApplySnapshotOptions { secrets_passphrase: secrets_passphrase.as_deref() }, + ApplySnapshotOptions { secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()) }, ) .await?; Ok(WebDavDownloadResult { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 595f8b6b2a..9443bf24e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -739,6 +739,9 @@ pub fn run() { commands::cloud_sync::webdav_password_status, commands::cloud_sync::save_webdav_saved_password, commands::cloud_sync::forget_webdav_saved_password, + commands::cloud_sync::webdav_sync_secrets_status, + commands::cloud_sync::save_webdav_sync_secrets_preference, + commands::cloud_sync::forget_webdav_sync_secrets_passphrase, commands::cloud_sync::webdav_sync_upload, commands::cloud_sync::webdav_sync_download, commands::connection::test_connection,