feat(api): 添加WebDAV同步秘密状态和偏好设置功能 (#2560)

- 新增获取WebDAV同步秘密状态的接口
- 新增保存WebDAV同步秘密偏好的接口
- 新增忘记WebDAV同步秘密密码的接口
- 更新相关的存储和加密逻辑以支持新的功能

Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
二丫讲梵
2026-07-04 22:58:09 +08:00
committed by GitHub
co-authored by t8y2
parent 223ecbec20
commit 842dcc6cab
11 changed files with 726 additions and 22 deletions
@@ -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);
</div>
<div v-if="webdavSyncSecrets" class="space-y-2">
<Label for="webdav-secrets-passphrase">{{ t("settings.syncSecretsPassphrase") }}</Label>
<PasswordInput id="webdav-secrets-passphrase" v-model="webdavSecretsPassphrase" autocomplete="new-password" />
<div class="flex items-center gap-2">
<PasswordInput id="webdav-secrets-passphrase" v-model="webdavSecretsPassphrase" class="min-w-0 flex-1" :placeholder="webdavHasSavedSecretsPassphrase ? '••••••••' : ''" :show-toggle="!webdavHasSavedSecretsPassphrase || !!webdavSecretsPassphrase" autocomplete="new-password" />
<Button
v-if="webdavHasSavedSecretsPassphrase"
type="button"
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
:title="t('settings.syncClearSavedPassword')"
:aria-label="t('settings.syncClearSavedPassword')"
@click="clearWebDavSyncSecretsPassphrase"
>
<X class="size-3.5" />
</Button>
</div>
<p class="text-xs text-muted-foreground">{{ t("settings.syncSecretsPassphraseDescription") }}</p>
</div>
</div>
+3
View File
@@ -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");
+17
View File
@@ -1095,6 +1095,11 @@ export interface WebDavPasswordStatus {
hasSavedPassword: boolean;
}
export interface WebDavSyncSecretsStatus {
enabled: boolean;
hasSavedPassphrase: boolean;
}
export async function webdavSyncTest(config: WebDavConfig): Promise<void> {
return post("/api/cloud-sync/webdav/test", { config });
}
@@ -1111,6 +1116,18 @@ export async function forgetWebdavSavedPassword(config: WebDavConfig): Promise<v
return post("/api/cloud-sync/webdav/forget-password", { config });
}
export async function webdavSyncSecretsStatus(): Promise<WebDavSyncSecretsStatus> {
return post("/api/cloud-sync/webdav/sync-secrets-status", {});
}
export async function saveWebdavSyncSecretsPreference(enabled: boolean, passphrase?: string): Promise<void> {
return post("/api/cloud-sync/webdav/save-sync-secrets-preference", { enabled, passphrase });
}
export async function forgetWebdavSyncSecretsPassphrase(): Promise<void> {
return post("/api/cloud-sync/webdav/forget-sync-secrets-passphrase", {});
}
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<WebDavSyncSummary> {
return post("/api/cloud-sync/webdav/upload", { config, editorSettings, secretsPassphrase });
}
+17
View File
@@ -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<v
return invoke("forget_webdav_saved_password", { config });
}
export async function webdavSyncSecretsStatus(): Promise<WebDavSyncSecretsStatus> {
return invoke("webdav_sync_secrets_status");
}
export async function saveWebdavSyncSecretsPreference(enabled: boolean, passphrase?: string): Promise<void> {
return invoke("save_webdav_sync_secrets_preference", { enabled, passphrase });
}
export async function forgetWebdavSyncSecretsPassphrase(): Promise<void> {
return invoke("forget_webdav_sync_secrets_passphrase");
}
export async function webdavSyncUpload(config: WebDavConfig, editorSettings?: unknown, secretsPassphrase?: string): Promise<WebDavSyncSummary> {
return invoke("webdav_sync_upload", { config, editorSettings, secretsPassphrase });
}
+315 -4
View File
@@ -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<String>,
editor_settings: Option<serde_json::Value>,
secrets_passphrase: Option<&str>,
) -> Result<SyncSnapshot, String> {
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<WebDavSyncSecretsStatus, String> {
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<Option<String>, 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<ConnectionSecretSnapshot>,
}
}
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<ConnectionSecretSnapshot>, 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<ConnectionSecretSnapshot>,
connection_id: &str,
@@ -430,6 +535,30 @@ fn push_secret(secrets: &mut Vec<ConnectionSecretSnapshot>, 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<String, serde_json::Value>, 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<String> {
#[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"
}));
}
}
@@ -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>;
+222 -4
View File
@@ -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<bool, String> {
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<Option<serde_json::Value>, 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<bool, String> {
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<String, serde_json::Value>,
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<String, serde_json::Value>> {
value?.get("auth")?.as_object()
}
fn nacos_auth_object_mut(
value: Option<&mut serde_json::Value>,
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
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");
+9
View File
@@ -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));
+42 -5
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebDavSyncSecretsPreferenceRequest {
pub enabled: bool,
pub passphrase: Option<String>,
}
pub async fn webdav_sync_test(
State(state): State<Arc<WebState>>,
Json(mut req): Json<WebDavConfigRequest>,
@@ -82,12 +92,33 @@ pub async fn forget_webdav_saved_password(
Ok(Json(()))
}
pub async fn webdav_sync_secrets_status(
State(state): State<Arc<WebState>>,
) -> Result<Json<WebDavSyncSecretsStatus>, 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<Arc<WebState>>,
Json(req): Json<WebDavSyncSecretsPreferenceRequest>,
) -> Result<Json<()>, 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<Arc<WebState>>) -> Result<Json<()>, 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<Arc<WebState>>,
Json(mut req): Json<WebDavUploadRequest>,
) -> Result<Json<WebDavSyncSummary>, 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<Json<WebDavDownloadResult>, 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)?;
+39 -7
View File
@@ -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<AppState>>, confi
forget_webdav_password(&state.storage, &config).await
}
#[tauri::command]
pub async fn webdav_sync_secrets_status(state: State<'_, Arc<AppState>>) -> Result<WebDavSyncSecretsStatus, String> {
core_webdav_sync_secrets_status(&state.storage).await
}
#[tauri::command]
pub async fn save_webdav_sync_secrets_preference(
state: State<'_, Arc<AppState>>,
enabled: bool,
passphrase: Option<String>,
) -> 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<AppState>>) -> Result<(), String> {
core_forget_webdav_sync_secrets_passphrase(&state.storage).await
}
#[tauri::command]
pub async fn webdav_sync_upload(
state: State<'_, Arc<AppState>>,
@@ -56,9 +78,13 @@ pub async fn webdav_sync_upload(
secrets_passphrase: Option<String>,
) -> Result<WebDavSyncSummary, String> {
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<WebDavDownloadResult, String> {
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 {
+3
View File
@@ -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,