feat: add WebDAV cloud sync with encrypted secrets support
Add WebDAV-based settings sync for desktop app, including connection configuration, editor settings, and optional encrypted secrets sync. Also persist WebDAV remember-password preference across sessions.
This commit is contained in:
parent
dacb162248
commit
0c9ee34a82
|
|
@ -1545,6 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -1833,7 +1834,9 @@ dependencies = [
|
|||
name = "dbx-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
|
|
@ -1860,6 +1863,7 @@ dependencies = [
|
|||
"rustls 0.23.40",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sqlparser",
|
||||
"tiberius",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, shallowRef, computed } from "vue";
|
||||
import { ref, watch, shallowRef, computed, onMounted } from "vue";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { CircleHelp, ExternalLink, Loader2, Pencil, RefreshCw, Settings, Trash2 } from "lucide-vue-next";
|
||||
import {
|
||||
CircleHelp,
|
||||
Cloud,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-vue-next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -13,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
useSettingsStore,
|
||||
AI_PROVIDER_PRESETS,
|
||||
|
|
@ -27,7 +40,19 @@ import {
|
|||
import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { aiListModels, aiTestConnection, listSystemFonts, type AiModelInfo } from "@/lib/api";
|
||||
import {
|
||||
aiListModels,
|
||||
aiTestConnection,
|
||||
forgetWebdavSavedPassword,
|
||||
listSystemFonts,
|
||||
saveWebdavSavedPassword,
|
||||
webdavPasswordStatus,
|
||||
webdavSyncDownload,
|
||||
webdavSyncTest,
|
||||
webdavSyncUpload,
|
||||
type AiModelInfo,
|
||||
type WebDavConfig,
|
||||
} from "@/lib/api";
|
||||
import { eventToShortcut } from "@/lib/keyboardShortcuts";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
|
|
@ -42,9 +67,11 @@ import { uuid } from "@/lib/utils";
|
|||
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import type { AppThemeAppearance } from "@/lib/appTheme";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -142,10 +169,6 @@ function confirmDeleteSnippet(snippet: SqlSnippet) {
|
|||
}
|
||||
}
|
||||
|
||||
function restoreDefaultSnippets() {
|
||||
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
|
||||
}
|
||||
|
||||
const presetFontLabels = new Map(FONT_FAMILIES.map((font) => [font.value, font.label]));
|
||||
|
||||
function cssFontFamilyForName(name: string): string {
|
||||
|
|
@ -212,6 +235,7 @@ watch(
|
|||
void loadSystemFontOptions();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
|
|
@ -356,6 +380,7 @@ type SettingsCategory =
|
|||
| "redis"
|
||||
| "shortcuts"
|
||||
| "snippets"
|
||||
| "sync"
|
||||
| "ai"
|
||||
| "security"
|
||||
| "about";
|
||||
|
|
@ -366,6 +391,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
|
|||
{ value: "redis", label: t("settings.redisTab") },
|
||||
{ value: "shortcuts", label: t("settings.shortcutsTab") },
|
||||
{ value: "snippets", label: t("settings.snippetsTab") },
|
||||
...(isWeb ? [] : [{ value: "sync" as const, label: t("settings.syncTab") }]),
|
||||
{ value: "ai", label: t("settings.aiTab") },
|
||||
...(isWeb ? [{ value: "security" as const, label: t("settings.securityTab") }] : []),
|
||||
{ value: "about", label: t("settings.aboutTab") },
|
||||
|
|
@ -400,6 +426,138 @@ function openExternalUrl(url: string) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- WebDAV Sync ----------
|
||||
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
|
||||
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
|
||||
const webdavPassword = ref("");
|
||||
const webdavRememberPassword = ref(localStorage.getItem("dbx-webdav-remember-password") === "true");
|
||||
const webdavHasSavedPassword = ref(false);
|
||||
const webdavRemotePath = ref(localStorage.getItem("dbx-webdav-remote-path") || "DBX/sync/snapshot.json");
|
||||
const webdavSyncSecrets = ref(false);
|
||||
const webdavSecretsPassphrase = ref("");
|
||||
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()),
|
||||
);
|
||||
|
||||
function currentWebDavConfig(): WebDavConfig {
|
||||
return {
|
||||
endpoint: webdavEndpoint.value.trim(),
|
||||
username: webdavUsername.value.trim() || undefined,
|
||||
password: webdavPassword.value || undefined,
|
||||
remotePath: webdavRemotePath.value.trim() || "DBX/sync/snapshot.json",
|
||||
};
|
||||
}
|
||||
|
||||
function currentWebDavAccountConfig(): WebDavConfig {
|
||||
const config = currentWebDavConfig();
|
||||
return { ...config, password: undefined };
|
||||
}
|
||||
|
||||
function rememberWebDavFields() {
|
||||
localStorage.setItem("dbx-webdav-endpoint", webdavEndpoint.value.trim());
|
||||
localStorage.setItem("dbx-webdav-username", webdavUsername.value.trim());
|
||||
localStorage.setItem("dbx-webdav-remote-path", webdavRemotePath.value.trim() || "DBX/sync/snapshot.json");
|
||||
}
|
||||
|
||||
function setWebDavResult(message: string, error = false) {
|
||||
webdavMessage.value = message;
|
||||
webdavError.value = error;
|
||||
}
|
||||
|
||||
async function runWebDavAction(kind: "test" | "upload" | "download", action: () => Promise<string>) {
|
||||
webdavBusy.value = kind;
|
||||
webdavMessage.value = "";
|
||||
webdavError.value = false;
|
||||
try {
|
||||
rememberWebDavFields();
|
||||
await applyWebDavPasswordPreference();
|
||||
setWebDavResult(await action());
|
||||
} catch (e: any) {
|
||||
setWebDavResult(e?.message || String(e), true);
|
||||
} finally {
|
||||
webdavBusy.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWebDavPasswordStatus() {
|
||||
if (!webdavEndpoint.value.trim()) {
|
||||
webdavHasSavedPassword.value = false;
|
||||
webdavRememberPassword.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await webdavPasswordStatus(currentWebDavAccountConfig());
|
||||
webdavHasSavedPassword.value = status.hasSavedPassword;
|
||||
if (status.hasSavedPassword) webdavRememberPassword.value = true;
|
||||
} catch {
|
||||
webdavHasSavedPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyWebDavPasswordPreference() {
|
||||
const password = webdavPassword.value;
|
||||
if (webdavRememberPassword.value && password) {
|
||||
await saveWebdavSavedPassword(currentWebDavAccountConfig(), password);
|
||||
webdavHasSavedPassword.value = true;
|
||||
return;
|
||||
}
|
||||
if (!webdavRememberPassword.value && webdavHasSavedPassword.value) {
|
||||
await forgetWebdavSavedPassword(currentWebDavAccountConfig());
|
||||
webdavHasSavedPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testWebDav() {
|
||||
await runWebDavAction("test", async () => {
|
||||
await webdavSyncTest(currentWebDavConfig());
|
||||
return t("settings.syncTestSuccess");
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadWebDavSnapshot() {
|
||||
await runWebDavAction("upload", async () => {
|
||||
const summary = await webdavSyncUpload(
|
||||
currentWebDavConfig(),
|
||||
settingsStore.editorSettings,
|
||||
webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined,
|
||||
);
|
||||
return t("settings.syncUploadSuccess", { bytes: summary.bytes, path: summary.remotePath });
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadWebDavSnapshot() {
|
||||
if (!window.confirm(t("settings.syncDownloadConfirm"))) return;
|
||||
await runWebDavAction("download", async () => {
|
||||
const result = await webdavSyncDownload(
|
||||
currentWebDavConfig(),
|
||||
webdavSyncSecrets.value ? webdavSecretsPassphrase.value : undefined,
|
||||
);
|
||||
if (result.editorSettings && typeof result.editorSettings === "object") {
|
||||
settingsStore.updateEditorSettings(result.editorSettings as any);
|
||||
}
|
||||
await settingsStore.updateDesktopSettings(result.desktopSettings);
|
||||
await connectionStore.initFromDisk();
|
||||
const message = t("settings.syncDownloadSuccess", {
|
||||
bytes: result.summary.bytes,
|
||||
path: result.summary.remotePath,
|
||||
});
|
||||
if (result.applySummary.encryptedSecretsPresent && !result.applySummary.secretsApplied) {
|
||||
return `${message} ${t("settings.syncSecretsSkipped")}`;
|
||||
}
|
||||
if (result.applySummary.secretsApplied) {
|
||||
return `${message} ${t("settings.syncSecretsApplied")}`;
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
|
|
@ -412,10 +570,25 @@ watch(
|
|||
await settingsStore.initAiConfig();
|
||||
await settingsStore.initDesktopSettings();
|
||||
editShowTrayIcon.value = settingsStore.desktopSettings.show_tray_icon;
|
||||
webdavPassword.value = "";
|
||||
await refreshWebDavPasswordStatus();
|
||||
syncAiEditState();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch([webdavEndpoint, webdavUsername], () => {
|
||||
void refreshWebDavPasswordStatus();
|
||||
});
|
||||
watch(webdavRememberPassword, (val) => {
|
||||
localStorage.setItem("dbx-webdav-remember-password", String(val));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refreshWebDavPasswordStatus();
|
||||
});
|
||||
|
||||
const oldPassword = ref("");
|
||||
const newPassword = ref("");
|
||||
const confirmNewPassword = ref("");
|
||||
|
|
@ -1040,11 +1213,16 @@ watch(
|
|||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="auto-select-active-sidebar-node">{{ t("settings.autoSelectActiveSidebarNode") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t("settings.autoSelectActiveSidebarNodeDescription") }}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="start">
|
||||
{{ t("settings.autoSelectActiveSidebarNodeDescription") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch id="auto-select-active-sidebar-node" v-model="editAutoSelectActiveSidebarNode" />
|
||||
</div>
|
||||
|
|
@ -1168,11 +1346,103 @@ watch(
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button variant="outline" size="sm" @click="restoreDefaultSnippets">
|
||||
{{ t("settings.snippetsRestoreDefaults") }}
|
||||
</Button>
|
||||
<section v-else-if="activeSettingsTab === 'sync'" class="flex flex-col gap-5 py-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium">
|
||||
<Cloud class="h-4 w-4 text-muted-foreground" />
|
||||
{{ t("settings.syncWebDavTitle") }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.syncWebDavDescription") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="webdav-endpoint">{{ t("settings.syncEndpoint") }}</Label>
|
||||
<Input
|
||||
id="webdav-endpoint"
|
||||
v-model="webdavEndpoint"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/remote.php/dav/files/user/"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="webdav-username">{{ t("settings.syncUsername") }}</Label>
|
||||
<Input id="webdav-username" v-model="webdavUsername" autocomplete="username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="webdav-password">{{ t("settings.syncPassword") }}</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="webdav-password"
|
||||
v-model="webdavPassword"
|
||||
type="password"
|
||||
:placeholder="webdavHasSavedPassword ? '••••••••' : '输入密码'"
|
||||
:disabled="webdavHasSavedPassword"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<Button
|
||||
v-if="webdavHasSavedPassword"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
title="清除已保存的密码"
|
||||
@click="
|
||||
webdavRememberPassword = false;
|
||||
forgetWebdavSavedPassword(currentWebDavAccountConfig());
|
||||
webdavHasSavedPassword = false;
|
||||
webdavPassword = '';
|
||||
"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="webdavRememberPassword" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
|
||||
<span>
|
||||
{{ t("settings.syncRememberWebDavPassword") }}
|
||||
<span v-if="webdavHasSavedPassword">{{ t("settings.syncSavedPassword") }}</span>
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="start">
|
||||
{{ t("settings.syncRememberWebDavPasswordDescription") }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<Label for="webdav-remote-path">{{ t("settings.syncRemotePath") }}</Label>
|
||||
<Input id="webdav-remote-path" v-model="webdavRemotePath" autocomplete="off" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.syncRemotePathDescription") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{{ t("settings.syncSecretNotice") }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-md border bg-muted/20 px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="webdav-sync-secrets">{{ t("settings.syncSecrets") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.syncSecretsDescription") }}</p>
|
||||
</div>
|
||||
<Switch id="webdav-sync-secrets" v-model="webdavSyncSecrets" />
|
||||
</div>
|
||||
<div v-if="webdavSyncSecrets" class="space-y-2">
|
||||
<Label for="webdav-secrets-passphrase">{{ t("settings.syncSecretsPassphrase") }}</Label>
|
||||
<Input
|
||||
id="webdav-secrets-passphrase"
|
||||
v-model="webdavSecretsPassphrase"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.syncSecretsPassphraseDescription") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -1532,6 +1802,37 @@ watch(
|
|||
<Button :disabled="!aiHasChanges()" @click="aiApplySettings">{{ t("settings.apply") }}</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter
|
||||
v-else-if="activeSettingsTab === 'sync'"
|
||||
class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 gap-3 sm:gap-3"
|
||||
>
|
||||
<Button variant="outline" @click="emit('update:open', false)">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<p
|
||||
v-if="webdavMessage"
|
||||
class="text-xs self-center truncate max-w-[280px]"
|
||||
:class="webdavError ? 'text-destructive' : 'text-green-500'"
|
||||
>
|
||||
{{ webdavMessage }}
|
||||
</p>
|
||||
<div class="flex-1" />
|
||||
<Button variant="outline" :disabled="!webdavReady" @click="testWebDav">
|
||||
<Loader2 v-if="webdavBusy === 'test'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
{{ t("settings.syncTest") }}
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="!webdavReady" @click="downloadWebDavSnapshot">
|
||||
<Loader2 v-if="webdavBusy === 'download'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("settings.syncDownload") }}
|
||||
</Button>
|
||||
<Button :disabled="!webdavReady" @click="uploadWebDavSnapshot">
|
||||
<Loader2 v-if="webdavBusy === 'upload'" class="mr-1 h-3 w-3 animate-spin" />
|
||||
<Upload v-else class="mr-1 h-3 w-3" />
|
||||
{{ t("settings.syncUpload") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter
|
||||
v-else-if="activeSettingsTab === 'security' && isWeb"
|
||||
class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3"
|
||||
|
|
|
|||
|
|
@ -1317,6 +1317,7 @@ export default {
|
|||
redisTab: "Redis",
|
||||
shortcutsTab: "Shortcuts",
|
||||
snippetsTab: "Snippets",
|
||||
syncTab: "Sync",
|
||||
aiTab: "AI",
|
||||
jdbcTab: "JDBC Drivers",
|
||||
securityTab: "Security",
|
||||
|
|
@ -1368,6 +1369,34 @@ export default {
|
|||
snippetsRestoreDefaults: "Restore Defaults",
|
||||
snippetsAddTitle: "Add Snippet",
|
||||
snippetsEditTitle: "Edit Snippet",
|
||||
syncWebDavTitle: "WebDAV Sync",
|
||||
syncWebDavDescription: "Upload or restore a DBX snapshot from a WebDAV-compatible storage service.",
|
||||
syncEndpoint: "WebDAV URL",
|
||||
syncUsername: "Username",
|
||||
syncPassword: "Password",
|
||||
syncRememberWebDavPassword: "Remember WebDAV app password",
|
||||
syncSavedPassword: "(saved)",
|
||||
syncRememberWebDavPasswordDescription:
|
||||
"The password is encrypted and stored on this device only. It is not synced to WebDAV and does not replace the sync password.",
|
||||
syncRemotePath: "Remote snapshot path",
|
||||
syncRemotePathDescription: "DBX will create missing parent folders when uploading.",
|
||||
syncSecretNotice:
|
||||
"By default, DBX syncs connection details and settings only. Database passwords, SSH passwords, proxy passwords, connection strings, and AI API keys stay local.",
|
||||
syncSecrets: "Sync encrypted secrets",
|
||||
syncSecretsDescription:
|
||||
"When enabled, DBX encrypts database passwords, SSH passwords, and AI API keys before uploading them to WebDAV.",
|
||||
syncSecretsPassphrase: "Sync password",
|
||||
syncSecretsPassphraseDescription:
|
||||
"This password is only used to encrypt and restore sensitive data. DBX does not save it; enter it again on each device.",
|
||||
syncTest: "Test",
|
||||
syncUpload: "Upload",
|
||||
syncDownload: "Download",
|
||||
syncTestSuccess: "WebDAV target is reachable.",
|
||||
syncUploadSuccess: "Uploaded {bytes} bytes to {path}.",
|
||||
syncDownloadSuccess: "Downloaded and applied {bytes} bytes from {path}.",
|
||||
syncSecretsApplied: "Encrypted secrets were restored.",
|
||||
syncSecretsSkipped: "Encrypted secrets were present but were not restored.",
|
||||
syncDownloadConfirm: "Download and apply the remote DBX snapshot? Local metadata and saved SQL will be replaced.",
|
||||
apply: "Apply",
|
||||
reset: "Reset",
|
||||
resetDefaults: "Reset Defaults",
|
||||
|
|
|
|||
|
|
@ -1212,6 +1212,8 @@ export default {
|
|||
navigationTab: "Navegación",
|
||||
redisTab: "Redis",
|
||||
shortcutsTab: "Atajos",
|
||||
snippetsTab: "Fragmentos",
|
||||
syncTab: "Sync",
|
||||
aiTab: "IA",
|
||||
jdbcTab: "Drivers JDBC",
|
||||
securityTab: "Seguridad",
|
||||
|
|
@ -1252,6 +1254,46 @@ export default {
|
|||
sidebarHiddenTablePrefixesDescription:
|
||||
"Un prefijo por linea. Solo acorta etiquetas de tablas, vistas y colecciones en la barra lateral; las acciones y ayudas usan el nombre completo.",
|
||||
sidebarHiddenTablePrefixesPlaceholder: "Ejemplo:\nODS_\nT8Y2_LONG_",
|
||||
snippetsDescription: "Personaliza plantillas SQL activadas en el editor.",
|
||||
snippetsAdd: "Agregar fragmento",
|
||||
snippetsLabel: "Etiqueta",
|
||||
snippetsPrefix: "Prefijo",
|
||||
snippetsBody: "SQL",
|
||||
snippetsLabelPlaceholder: "p. ej. select *",
|
||||
snippetsPrefixPlaceholder: "p. ej. sel",
|
||||
snippetsBodyPlaceholder: "SELECT *\nFROM table\nLIMIT 100;",
|
||||
snippetsRestoreDefaults: "Restaurar valores por defecto",
|
||||
snippetsAddTitle: "Agregar fragmento",
|
||||
snippetsEditTitle: "Editar fragmento",
|
||||
syncWebDavTitle: "Sincronización WebDAV",
|
||||
syncWebDavDescription: "Sube o restaura una instantánea de DBX desde almacenamiento compatible con WebDAV.",
|
||||
syncEndpoint: "URL WebDAV",
|
||||
syncUsername: "Usuario",
|
||||
syncPassword: "Contraseña",
|
||||
syncRememberWebDavPassword: "Recordar contraseña de app WebDAV",
|
||||
syncSavedPassword: "(guardada)",
|
||||
syncRememberWebDavPasswordDescription:
|
||||
"La contraseña se cifra y se guarda solo en este dispositivo. No se sincroniza a WebDAV ni reemplaza la contraseña de sync.",
|
||||
syncRemotePath: "Ruta remota",
|
||||
syncRemotePathDescription: "DBX creará las carpetas padre faltantes al subir.",
|
||||
syncSecretNotice:
|
||||
"De forma predeterminada, DBX solo sincroniza detalles de conexión y ajustes. Las contraseñas de base de datos, SSH, proxy, cadenas de conexión y API keys de IA quedan locales.",
|
||||
syncSecrets: "Sincronizar secretos cifrados",
|
||||
syncSecretsDescription:
|
||||
"Al activarlo, DBX cifra contraseñas de base de datos, contraseñas SSH y API keys de IA antes de subirlas a WebDAV.",
|
||||
syncSecretsPassphrase: "Contraseña de sync",
|
||||
syncSecretsPassphraseDescription:
|
||||
"Esta contraseña solo se usa para cifrar y restaurar datos sensibles. DBX no la guarda; tendrás que ingresarla en cada dispositivo.",
|
||||
syncTest: "Probar",
|
||||
syncUpload: "Subir",
|
||||
syncDownload: "Descargar",
|
||||
syncTestSuccess: "El destino WebDAV está disponible.",
|
||||
syncUploadSuccess: "Se subieron {bytes} bytes a {path}.",
|
||||
syncDownloadSuccess: "Se descargaron y aplicaron {bytes} bytes desde {path}.",
|
||||
syncSecretsApplied: "Los secretos cifrados fueron restaurados.",
|
||||
syncSecretsSkipped: "Había secretos cifrados, pero no fueron restaurados.",
|
||||
syncDownloadConfirm:
|
||||
"¿Descargar y aplicar la instantánea remota de DBX? Se reemplazarán metadatos locales y SQL guardado.",
|
||||
apply: "Aplicar",
|
||||
reset: "Restablecer",
|
||||
resetDefaults: "Restablecer valores por defecto",
|
||||
|
|
|
|||
|
|
@ -1294,6 +1294,7 @@ export default {
|
|||
redisTab: "Redis",
|
||||
shortcutsTab: "快捷键",
|
||||
snippetsTab: "代码片段",
|
||||
syncTab: "同步",
|
||||
aiTab: "AI",
|
||||
jdbcTab: "JDBC 驱动",
|
||||
securityTab: "安全",
|
||||
|
|
@ -1342,6 +1343,30 @@ export default {
|
|||
snippetsRestoreDefaults: "恢复默认",
|
||||
snippetsAddTitle: "添加片段",
|
||||
snippetsEditTitle: "编辑片段",
|
||||
syncWebDavTitle: "WebDAV 同步",
|
||||
syncWebDavDescription: "将 DBX 快照上传到兼容 WebDAV 的存储服务,或从远端恢复。",
|
||||
syncEndpoint: "WebDAV 地址",
|
||||
syncUsername: "用户名",
|
||||
syncPassword: "密码",
|
||||
syncRememberWebDavPassword: "记住 WebDAV 应用密码",
|
||||
syncSavedPassword: "(已保存)",
|
||||
syncRememberWebDavPasswordDescription: "密码会加密保存在本机,不会同步到 WebDAV,也不会替代同步密码。",
|
||||
syncRemotePath: "远端快照路径",
|
||||
syncRemotePathDescription: "上传时 DBX 会自动创建缺失的父目录。",
|
||||
syncSecretNotice: "默认只同步连接信息和设置,不会同步数据库密码、SSH 密码、代理密码、连接串或 AI API Key。",
|
||||
syncSecrets: "同步加密后的敏感信息",
|
||||
syncSecretsDescription: "开启后,DBX 会先加密数据库密码、SSH 密码和 AI API Key,再上传到 WebDAV。",
|
||||
syncSecretsPassphrase: "同步密码",
|
||||
syncSecretsPassphraseDescription: "这个密码只用于加密和恢复敏感信息,DBX 不会保存;换设备恢复时需要再次输入。",
|
||||
syncTest: "测试",
|
||||
syncUpload: "上传",
|
||||
syncDownload: "下载",
|
||||
syncTestSuccess: "WebDAV 目标可访问。",
|
||||
syncUploadSuccess: "已上传 {bytes} 字节到 {path}。",
|
||||
syncDownloadSuccess: "已从 {path} 下载并应用 {bytes} 字节。",
|
||||
syncSecretsApplied: "已恢复加密敏感信息。",
|
||||
syncSecretsSkipped: "远端包含加密敏感信息,但本次未恢复。",
|
||||
syncDownloadConfirm: "要下载并应用远端 DBX 快照吗?本地元信息和保存的 SQL 会被替换。",
|
||||
apply: "应用",
|
||||
reset: "重置",
|
||||
resetDefaults: "恢复默认",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ export const loadDesktopSettings = forward("loadDesktopSettings");
|
|||
export const saveDesktopSettings = forward("saveDesktopSettings");
|
||||
export const loadPinnedTreeNodeIds = forward("loadPinnedTreeNodeIds");
|
||||
export const savePinnedTreeNodeIds = forward("savePinnedTreeNodeIds");
|
||||
export const webdavSyncTest = forward("webdavSyncTest");
|
||||
export const webdavPasswordStatus = forward("webdavPasswordStatus");
|
||||
export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword");
|
||||
export const forgetWebdavSavedPassword = forward("forgetWebdavSavedPassword");
|
||||
export const webdavSyncUpload = forward("webdavSyncUpload");
|
||||
export const webdavSyncDownload = forward("webdavSyncDownload");
|
||||
export const saveAiConversation = forward("saveAiConversation");
|
||||
export const loadAiConversations = forward("loadAiConversations");
|
||||
export const deleteAiConversation = forward("deleteAiConversation");
|
||||
|
|
@ -237,6 +243,10 @@ export type {
|
|||
JavaRuntimeMode,
|
||||
JavaRuntimeConfig,
|
||||
DriverInstallProgress,
|
||||
WebDavConfig,
|
||||
WebDavPasswordStatus,
|
||||
WebDavSyncSummary,
|
||||
WebDavDownloadResult,
|
||||
UpdateInfo,
|
||||
RedisDatabaseInfo,
|
||||
RedisKeyInfo,
|
||||
|
|
|
|||
|
|
@ -776,6 +776,65 @@ export async function saveDesktopSettings(_settings: DesktopSettings): Promise<v
|
|||
return;
|
||||
}
|
||||
|
||||
export interface WebDavConfig {
|
||||
endpoint: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
remotePath?: string;
|
||||
}
|
||||
|
||||
export interface WebDavSyncSummary {
|
||||
remotePath: string;
|
||||
bytes: number;
|
||||
exportedAt?: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface WebDavDownloadResult {
|
||||
summary: WebDavSyncSummary;
|
||||
editorSettings?: unknown;
|
||||
desktopSettings: DesktopSettings;
|
||||
applySummary: {
|
||||
encryptedSecretsPresent: boolean;
|
||||
secretsApplied: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebDavPasswordStatus {
|
||||
hasSavedPassword: boolean;
|
||||
}
|
||||
|
||||
export async function webdavSyncTest(_config: WebDavConfig): Promise<void> {
|
||||
throw new Error("WebDAV sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function webdavPasswordStatus(_config: WebDavConfig): Promise<WebDavPasswordStatus> {
|
||||
return { hasSavedPassword: false };
|
||||
}
|
||||
|
||||
export async function saveWebdavSavedPassword(_config: WebDavConfig, _password: string): Promise<void> {
|
||||
throw new Error("WebDAV sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function forgetWebdavSavedPassword(_config: WebDavConfig): Promise<void> {
|
||||
throw new Error("WebDAV sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function webdavSyncUpload(
|
||||
_config: WebDavConfig,
|
||||
_editorSettings?: unknown,
|
||||
_secretsPassphrase?: string,
|
||||
): Promise<WebDavSyncSummary> {
|
||||
throw new Error("WebDAV sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function webdavSyncDownload(
|
||||
_config: WebDavConfig,
|
||||
_secretsPassphrase?: string,
|
||||
): Promise<WebDavDownloadResult> {
|
||||
throw new Error("WebDAV sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function loadPinnedTreeNodeIds(): Promise<string[]> {
|
||||
return get("/api/app-settings/pinned-tree-node-ids");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,34 @@ export interface DesktopSettings {
|
|||
show_tray_icon: boolean;
|
||||
}
|
||||
|
||||
export interface WebDavConfig {
|
||||
endpoint: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
remotePath?: string;
|
||||
}
|
||||
|
||||
export interface WebDavSyncSummary {
|
||||
remotePath: string;
|
||||
bytes: number;
|
||||
exportedAt?: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface WebDavDownloadResult {
|
||||
summary: WebDavSyncSummary;
|
||||
editorSettings?: unknown;
|
||||
desktopSettings: DesktopSettings;
|
||||
applySummary: {
|
||||
encryptedSecretsPresent: boolean;
|
||||
secretsApplied: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebDavPasswordStatus {
|
||||
hasSavedPassword: boolean;
|
||||
}
|
||||
|
||||
export interface QueryPagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
|
@ -236,6 +264,37 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
|
|||
return invoke("save_desktop_settings", { settings });
|
||||
}
|
||||
|
||||
export async function webdavSyncTest(config: WebDavConfig): Promise<void> {
|
||||
return invoke("webdav_sync_test", { config });
|
||||
}
|
||||
|
||||
export async function webdavPasswordStatus(config: WebDavConfig): Promise<WebDavPasswordStatus> {
|
||||
return invoke("webdav_password_status", { config });
|
||||
}
|
||||
|
||||
export async function saveWebdavSavedPassword(config: WebDavConfig, password: string): Promise<void> {
|
||||
return invoke("save_webdav_saved_password", { config, password });
|
||||
}
|
||||
|
||||
export async function forgetWebdavSavedPassword(config: WebDavConfig): Promise<void> {
|
||||
return invoke("forget_webdav_saved_password", { config });
|
||||
}
|
||||
|
||||
export async function webdavSyncUpload(
|
||||
config: WebDavConfig,
|
||||
editorSettings?: unknown,
|
||||
secretsPassphrase?: string,
|
||||
): Promise<WebDavSyncSummary> {
|
||||
return invoke("webdav_sync_upload", { config, editorSettings, secretsPassphrase });
|
||||
}
|
||||
|
||||
export async function webdavSyncDownload(
|
||||
config: WebDavConfig,
|
||||
secretsPassphrase?: string,
|
||||
): Promise<WebDavDownloadResult> {
|
||||
return invoke("webdav_sync_download", { config, secretsPassphrase });
|
||||
}
|
||||
|
||||
export async function loadPinnedTreeNodeIds(): Promise<string[]> {
|
||||
return invoke("load_pinned_tree_node_ids");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ portpicker = "0.1.1"
|
|||
csv = "1"
|
||||
calamine = "0.30.1"
|
||||
base64 = "0.22"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
async-trait = "0.1"
|
||||
bytes = "1"
|
||||
font-kit = "0.14.3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,565 @@
|
|||
use aes_gcm::{
|
||||
aead::{rand_core::RngCore, Aead, OsRng},
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
};
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::Utc;
|
||||
use reqwest::{header, Client, Method, StatusCode, Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::ai::AiConfig;
|
||||
use crate::models::connection::ConnectionConfig;
|
||||
use crate::saved_sql::SavedSqlLibrary;
|
||||
use crate::storage::{DesktopSettings, Storage};
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
|
||||
const DEFAULT_REMOTE_PATH: &str = "DBX/sync/snapshot.json";
|
||||
const SECRET_KEYS: &[&str] = &[
|
||||
"password",
|
||||
"ssh_password",
|
||||
"ssh_key_passphrase",
|
||||
"proxy_password",
|
||||
"redis_sentinel_password",
|
||||
"connection_string",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavConfig {
|
||||
pub endpoint: String,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub remote_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavPasswordStatus {
|
||||
pub has_saved_password: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncSnapshot {
|
||||
pub schema_version: u32,
|
||||
pub exported_at: String,
|
||||
pub app_version: String,
|
||||
pub connections: Vec<ConnectionConfig>,
|
||||
pub sidebar_layout: Option<serde_json::Value>,
|
||||
pub pinned_tree_node_ids: Vec<String>,
|
||||
pub saved_sql: SavedSqlLibrary,
|
||||
pub desktop_settings: DesktopSettings,
|
||||
pub editor_settings: Option<serde_json::Value>,
|
||||
pub encrypted_secrets: Option<EncryptedSecretsBlob>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EncryptedSecretsBlob {
|
||||
pub version: u32,
|
||||
pub kdf: String,
|
||||
pub cipher: String,
|
||||
pub salt: String,
|
||||
pub nonce: String,
|
||||
pub ciphertext: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SensitiveSyncPayload {
|
||||
pub connection_secrets: Vec<ConnectionSecretSnapshot>,
|
||||
pub ai_config: Option<AiConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionSecretSnapshot {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ApplySnapshotOptions<'a> {
|
||||
pub secrets_passphrase: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplySnapshotSummary {
|
||||
pub encrypted_secrets_present: bool,
|
||||
pub secrets_applied: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavSyncSummary {
|
||||
pub remote_path: String,
|
||||
pub bytes: usize,
|
||||
pub exported_at: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn build_sync_snapshot(
|
||||
storage: &Storage,
|
||||
app_version: impl Into<String>,
|
||||
editor_settings: Option<serde_json::Value>,
|
||||
secrets_passphrase: Option<&str>,
|
||||
) -> Result<SyncSnapshot, String> {
|
||||
let mut connections = storage.load_connections().await?;
|
||||
let encrypted_secrets = match normalized_passphrase(secrets_passphrase) {
|
||||
Some(passphrase) => {
|
||||
Some(encrypt_sensitive_payload(&build_sensitive_payload(storage, &connections).await?, passphrase)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
for config in &mut connections {
|
||||
scrub_connection_secrets(config);
|
||||
}
|
||||
|
||||
Ok(SyncSnapshot {
|
||||
schema_version: SNAPSHOT_SCHEMA_VERSION,
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
app_version: app_version.into(),
|
||||
connections,
|
||||
sidebar_layout: storage.load_sidebar_layout().await?,
|
||||
pinned_tree_node_ids: storage.load_pinned_tree_node_ids().await?,
|
||||
saved_sql: storage.load_saved_sql_library().await?,
|
||||
desktop_settings: storage.load_desktop_settings().await?,
|
||||
editor_settings,
|
||||
encrypted_secrets,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn apply_sync_snapshot(
|
||||
storage: &Storage,
|
||||
snapshot: &SyncSnapshot,
|
||||
options: ApplySnapshotOptions<'_>,
|
||||
) -> Result<ApplySnapshotSummary, String> {
|
||||
if snapshot.schema_version != SNAPSHOT_SCHEMA_VERSION {
|
||||
return Err(format!("Unsupported sync snapshot schema version: {}", snapshot.schema_version));
|
||||
}
|
||||
|
||||
let encrypted_secrets_present = snapshot.encrypted_secrets.is_some();
|
||||
let sensitive_payload = match (&snapshot.encrypted_secrets, normalized_passphrase(options.secrets_passphrase)) {
|
||||
(Some(blob), Some(passphrase)) => Some(decrypt_sensitive_payload(blob, passphrase)?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut connections = snapshot.connections.clone();
|
||||
for config in &mut connections {
|
||||
scrub_connection_secrets(config);
|
||||
}
|
||||
|
||||
storage.save_connection_metadata_preserving_secrets(&connections).await?;
|
||||
if let Some(layout) = &snapshot.sidebar_layout {
|
||||
storage.save_sidebar_layout(layout).await?;
|
||||
}
|
||||
storage.save_pinned_tree_node_ids(&snapshot.pinned_tree_node_ids).await?;
|
||||
storage.replace_saved_sql_library(&snapshot.saved_sql).await?;
|
||||
storage.save_desktop_settings(&snapshot.desktop_settings).await?;
|
||||
if let Some(payload) = &sensitive_payload {
|
||||
clear_connection_secrets(storage, &connections).await?;
|
||||
apply_sensitive_payload(storage, payload).await?;
|
||||
}
|
||||
Ok(ApplySnapshotSummary { encrypted_secrets_present, secrets_applied: sensitive_payload.is_some() })
|
||||
}
|
||||
|
||||
pub struct WebDavClient {
|
||||
http: Client,
|
||||
config: WebDavConfig,
|
||||
}
|
||||
|
||||
pub async fn webdav_saved_password_status(
|
||||
storage: &Storage,
|
||||
config: &WebDavConfig,
|
||||
) -> Result<WebDavPasswordStatus, String> {
|
||||
let account = webdav_password_account(config);
|
||||
Ok(WebDavPasswordStatus { has_saved_password: storage.load_webdav_password_blob(&account).await?.is_some() })
|
||||
}
|
||||
|
||||
pub async fn save_webdav_password(storage: &Storage, config: &WebDavConfig, password: &str) -> Result<(), String> {
|
||||
let secret = storage.load_or_create_local_device_secret().await?;
|
||||
let blob = encrypt_text_with_secret(password, &secret)?;
|
||||
let value = serde_json::to_value(blob).map_err(|e| e.to_string())?;
|
||||
storage.save_webdav_password_blob(&webdav_password_account(config), &value).await
|
||||
}
|
||||
|
||||
pub async fn forget_webdav_password(storage: &Storage, config: &WebDavConfig) -> Result<(), String> {
|
||||
storage.delete_webdav_password_blob(&webdav_password_account(config)).await
|
||||
}
|
||||
|
||||
pub async fn resolve_webdav_password(storage: &Storage, config: &mut WebDavConfig) -> Result<(), String> {
|
||||
if config.password.as_deref().is_some_and(|password| !password.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(value) = storage.load_webdav_password_blob(&webdav_password_account(config)).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let blob: EncryptedSecretsBlob = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
let secret = storage.load_or_create_local_device_secret().await?;
|
||||
config.password = Some(decrypt_text_with_secret(&blob, &secret)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl WebDavClient {
|
||||
pub fn new(config: WebDavConfig) -> Self {
|
||||
Self { http: Client::new(), config }
|
||||
}
|
||||
|
||||
pub fn remote_path(&self) -> String {
|
||||
normalized_remote_path(self.config.remote_path.as_deref())
|
||||
}
|
||||
|
||||
pub async fn test(&self) -> Result<(), String> {
|
||||
let method = Method::from_bytes(b"PROPFIND").map_err(|e| e.to_string())?;
|
||||
let response = self.request(method, "")?.header("Depth", "0").send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("WebDAV test failed with HTTP {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn put_snapshot(&self, snapshot: &SyncSnapshot) -> Result<WebDavSyncSummary, String> {
|
||||
let remote_path = self.remote_path();
|
||||
self.ensure_parent_collections(&remote_path).await?;
|
||||
let bytes = serde_json::to_vec_pretty(snapshot).map_err(|e| e.to_string())?;
|
||||
let response = self
|
||||
.request(Method::PUT, &remote_path)?
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("WebDAV upload failed with HTTP {status}"));
|
||||
}
|
||||
Ok(WebDavSyncSummary {
|
||||
remote_path,
|
||||
bytes: bytes.len(),
|
||||
exported_at: Some(snapshot.exported_at.clone()),
|
||||
app_version: Some(snapshot.app_version.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(&self) -> Result<(SyncSnapshot, WebDavSyncSummary), String> {
|
||||
let remote_path = self.remote_path();
|
||||
let response = self.request(Method::GET, &remote_path)?.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("WebDAV download failed with HTTP {status}"));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
let snapshot: SyncSnapshot = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
let summary = WebDavSyncSummary {
|
||||
remote_path,
|
||||
bytes: bytes.len(),
|
||||
exported_at: Some(snapshot.exported_at.clone()),
|
||||
app_version: Some(snapshot.app_version.clone()),
|
||||
};
|
||||
Ok((snapshot, summary))
|
||||
}
|
||||
|
||||
async fn ensure_parent_collections(&self, remote_path: &str) -> Result<(), String> {
|
||||
let method = Method::from_bytes(b"MKCOL").map_err(|e| e.to_string())?;
|
||||
for parent in parent_collection_paths(remote_path) {
|
||||
let response = self.request(method.clone(), &parent)?.send().await.map_err(|e| e.to_string())?;
|
||||
let status = response.status();
|
||||
if status.is_success() || status == StatusCode::METHOD_NOT_ALLOWED {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("Failed to create WebDAV collection '{parent}' with HTTP {status}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, remote_path: &str) -> Result<reqwest::RequestBuilder, String> {
|
||||
let url = self.remote_url(remote_path)?;
|
||||
let mut request = self.http.request(method, url);
|
||||
if let Some(username) = self.config.username.as_deref().filter(|value| !value.is_empty()) {
|
||||
request = request.basic_auth(username, self.config.password.clone());
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn remote_url(&self, remote_path: &str) -> Result<Url, String> {
|
||||
let endpoint = self.config.endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
return Err("WebDAV endpoint is required".to_string());
|
||||
}
|
||||
let base = if endpoint.ends_with('/') { endpoint.to_string() } else { format!("{endpoint}/") };
|
||||
let base = Url::parse(&base).map_err(|e| e.to_string())?;
|
||||
base.join(remote_path.trim_start_matches('/')).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_connection_secrets(config: &mut ConnectionConfig) {
|
||||
config.password.clear();
|
||||
config.ssh_password.clear();
|
||||
config.ssh_key_passphrase.clear();
|
||||
config.proxy_password.clear();
|
||||
config.redis_sentinel_password.clear();
|
||||
config.connection_string = None;
|
||||
}
|
||||
|
||||
fn webdav_password_account(config: &WebDavConfig) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(config.endpoint.trim().as_bytes());
|
||||
hasher.update(b"\n");
|
||||
hasher.update(config.username.as_deref().unwrap_or("").trim().as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
async fn build_sensitive_payload(
|
||||
storage: &Storage,
|
||||
connections: &[ConnectionConfig],
|
||||
) -> Result<SensitiveSyncPayload, String> {
|
||||
let mut connection_secrets = Vec::new();
|
||||
for config in connections {
|
||||
push_secret(&mut connection_secrets, &config.id, "password", &config.password);
|
||||
push_secret(&mut connection_secrets, &config.id, "ssh_password", &config.ssh_password);
|
||||
push_secret(&mut connection_secrets, &config.id, "ssh_key_passphrase", &config.ssh_key_passphrase);
|
||||
push_secret(&mut connection_secrets, &config.id, "proxy_password", &config.proxy_password);
|
||||
push_secret(&mut connection_secrets, &config.id, "redis_sentinel_password", &config.redis_sentinel_password);
|
||||
if let Some(connection_string) = &config.connection_string {
|
||||
push_secret(&mut connection_secrets, &config.id, "connection_string", connection_string);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SensitiveSyncPayload { connection_secrets, ai_config: storage.load_ai_config().await? })
|
||||
}
|
||||
|
||||
fn push_secret(secrets: &mut Vec<ConnectionSecretSnapshot>, connection_id: &str, key: &str, secret: &str) {
|
||||
if secret.is_empty() {
|
||||
return;
|
||||
}
|
||||
secrets.push(ConnectionSecretSnapshot {
|
||||
connection_id: connection_id.to_string(),
|
||||
key: key.to_string(),
|
||||
secret: secret.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
storage.set_secret(&secret.connection_id, &secret.key, &secret.secret).await?;
|
||||
}
|
||||
if let Some(ai_config) = &payload.ai_config {
|
||||
storage.save_ai_config(ai_config).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
for config in connections {
|
||||
for key in SECRET_KEYS {
|
||||
storage.delete_secret(&config.id, key).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encrypt_sensitive_payload(payload: &SensitiveSyncPayload, passphrase: &str) -> Result<EncryptedSecretsBlob, String> {
|
||||
let plaintext = serde_json::to_vec(payload).map_err(|e| e.to_string())?;
|
||||
encrypt_bytes_with_secret(&plaintext, passphrase)
|
||||
}
|
||||
|
||||
fn decrypt_sensitive_payload(blob: &EncryptedSecretsBlob, passphrase: &str) -> Result<SensitiveSyncPayload, String> {
|
||||
let plaintext = decrypt_bytes_with_secret(blob, passphrase)
|
||||
.map_err(|_| "Failed to decrypt synced secrets. Check the sync password.".to_string())?;
|
||||
serde_json::from_slice(&plaintext).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn encrypt_text_with_secret(value: &str, secret: &str) -> Result<EncryptedSecretsBlob, String> {
|
||||
encrypt_bytes_with_secret(value.as_bytes(), secret)
|
||||
}
|
||||
|
||||
fn decrypt_text_with_secret(blob: &EncryptedSecretsBlob, secret: &str) -> Result<String, String> {
|
||||
let plaintext = decrypt_bytes_with_secret(blob, secret)?;
|
||||
String::from_utf8(plaintext).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn encrypt_bytes_with_secret(plaintext: &[u8], secret: &str) -> Result<EncryptedSecretsBlob, String> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = derive_secret_key(secret, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?;
|
||||
let ciphertext = cipher.encrypt(Nonce::from_slice(&nonce), plaintext).map_err(|e| e.to_string())?;
|
||||
Ok(EncryptedSecretsBlob {
|
||||
version: 1,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "aes-256-gcm".to_string(),
|
||||
salt: BASE64.encode(salt),
|
||||
nonce: BASE64.encode(nonce),
|
||||
ciphertext: BASE64.encode(ciphertext),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_bytes_with_secret(blob: &EncryptedSecretsBlob, secret: &str) -> Result<Vec<u8>, String> {
|
||||
if blob.version != 1 || blob.kdf != "argon2id" || blob.cipher != "aes-256-gcm" {
|
||||
return Err("Unsupported encrypted secrets format".to_string());
|
||||
}
|
||||
let salt = BASE64.decode(&blob.salt).map_err(|e| e.to_string())?;
|
||||
let nonce = BASE64.decode(&blob.nonce).map_err(|e| e.to_string())?;
|
||||
let ciphertext = BASE64.decode(&blob.ciphertext).map_err(|e| e.to_string())?;
|
||||
if nonce.len() != 12 {
|
||||
return Err("Invalid encrypted secrets nonce".to_string());
|
||||
}
|
||||
let key = derive_secret_key(secret, &salt)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?;
|
||||
cipher
|
||||
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| "Failed to decrypt saved secret.".to_string())
|
||||
}
|
||||
|
||||
fn derive_secret_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], String> {
|
||||
let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| e.to_string())?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
argon2.hash_password_into(passphrase.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn normalized_passphrase(passphrase: Option<&str>) -> Option<&str> {
|
||||
passphrase.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalized_remote_path(value: Option<&str>) -> String {
|
||||
let value = value.unwrap_or(DEFAULT_REMOTE_PATH).trim().trim_start_matches('/');
|
||||
if value.is_empty() {
|
||||
DEFAULT_REMOTE_PATH.to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_collection_paths(remote_path: &str) -> Vec<String> {
|
||||
let parts = remote_path.trim_matches('/').split('/').filter(|part| !part.is_empty()).collect::<Vec<_>>();
|
||||
if parts.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut paths = Vec::with_capacity(parts.len() - 1);
|
||||
for index in 1..parts.len() {
|
||||
paths.push(parts[..index].join("/"));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
decrypt_sensitive_payload, encrypt_sensitive_payload, normalized_remote_path, parent_collection_paths,
|
||||
scrub_connection_secrets, ConnectionSecretSnapshot, SensitiveSyncPayload,
|
||||
};
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType, ProxyType};
|
||||
|
||||
#[test]
|
||||
fn normalizes_empty_remote_path_to_default() {
|
||||
assert_eq!(normalized_remote_path(None), "DBX/sync/snapshot.json");
|
||||
assert_eq!(normalized_remote_path(Some("")), "DBX/sync/snapshot.json");
|
||||
assert_eq!(normalized_remote_path(Some("/custom/snapshot.json")), "custom/snapshot.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_parent_collection_paths_from_leaf() {
|
||||
assert_eq!(parent_collection_paths("dbx/sync/snapshot.json"), vec!["dbx".to_string(), "dbx/sync".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubs_connection_secret_fields() {
|
||||
let mut config = ConnectionConfig {
|
||||
id: "id".to_string(),
|
||||
name: "name".to_string(),
|
||||
db_type: DatabaseType::Postgres,
|
||||
driver_profile: None,
|
||||
driver_label: None,
|
||||
url_params: None,
|
||||
host: "localhost".to_string(),
|
||||
port: 5432,
|
||||
username: "user".to_string(),
|
||||
password: "secret".to_string(),
|
||||
database: None,
|
||||
visible_databases: None,
|
||||
attached_databases: Vec::new(),
|
||||
color: None,
|
||||
ssh_enabled: false,
|
||||
ssh_host: String::new(),
|
||||
ssh_port: 22,
|
||||
ssh_user: String::new(),
|
||||
ssh_password: "ssh".to_string(),
|
||||
ssh_key_path: String::new(),
|
||||
ssh_key_passphrase: "key".to_string(),
|
||||
ssh_expose_lan: false,
|
||||
ssh_connect_timeout_secs: 5,
|
||||
proxy_enabled: false,
|
||||
proxy_type: ProxyType::Socks5,
|
||||
proxy_host: String::new(),
|
||||
proxy_port: 1080,
|
||||
proxy_username: String::new(),
|
||||
proxy_password: "proxy".to_string(),
|
||||
ssl: false,
|
||||
ca_cert_path: String::new(),
|
||||
sysdba: false,
|
||||
oracle_connection_type: None,
|
||||
connection_string: Some("postgres://secret".to_string()),
|
||||
redis_connection_mode: None,
|
||||
redis_sentinel_master: String::new(),
|
||||
redis_sentinel_nodes: String::new(),
|
||||
redis_sentinel_username: String::new(),
|
||||
redis_sentinel_password: "sentinel".to_string(),
|
||||
redis_sentinel_tls: false,
|
||||
external_config: None,
|
||||
jdbc_driver_class: None,
|
||||
jdbc_driver_paths: Vec::new(),
|
||||
one_time: false,
|
||||
};
|
||||
scrub_connection_secrets(&mut config);
|
||||
assert!(config.password.is_empty());
|
||||
assert!(config.ssh_password.is_empty());
|
||||
assert!(config.ssh_key_passphrase.is_empty());
|
||||
assert!(config.proxy_password.is_empty());
|
||||
assert!(config.redis_sentinel_password.is_empty());
|
||||
assert!(config.connection_string.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_sensitive_payload_round_trips() {
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![ConnectionSecretSnapshot {
|
||||
connection_id: "c1".to_string(),
|
||||
key: "password".to_string(),
|
||||
secret: "secret".to_string(),
|
||||
}],
|
||||
ai_config: None,
|
||||
};
|
||||
let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap();
|
||||
assert_ne!(encrypted.ciphertext, "secret");
|
||||
let decrypted = decrypt_sensitive_payload(&encrypted, "sync-pass").unwrap();
|
||||
assert_eq!(decrypted.connection_secrets[0].secret, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_sensitive_payload_rejects_wrong_passphrase() {
|
||||
let payload = SensitiveSyncPayload {
|
||||
connection_secrets: vec![ConnectionSecretSnapshot {
|
||||
connection_id: "c1".to_string(),
|
||||
key: "password".to_string(),
|
||||
secret: "secret".to_string(),
|
||||
}],
|
||||
ai_config: None,
|
||||
};
|
||||
let encrypted = encrypt_sensitive_payload(&payload, "sync-pass").unwrap();
|
||||
assert!(decrypt_sensitive_payload(&encrypted, "wrong-pass").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod agent_manager;
|
||||
pub mod agent_service;
|
||||
pub mod ai;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
pub mod connection_secrets;
|
||||
pub mod csv_export;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::Path;
|
|||
|
||||
use rusqlite::{params, params_from_iter, Connection, OptionalExtension, ToSql};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::{AiChatMessage, AiConfig, AiConversation};
|
||||
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
|
||||
|
|
@ -355,6 +356,48 @@ impl Storage {
|
|||
};
|
||||
Ok(array.iter().filter_map(|item| item.as_str().map(|value| value.to_string())).collect())
|
||||
}
|
||||
|
||||
pub async fn load_or_create_local_device_secret(&self) -> Result<String, String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
if let Some(secret) = settings.get("local_device_secret").and_then(|value| value.as_str()) {
|
||||
if !secret.is_empty() {
|
||||
return Ok(secret.to_string());
|
||||
}
|
||||
}
|
||||
let secret = Uuid::new_v4().to_string();
|
||||
settings.insert("local_device_secret".to_string(), serde_json::Value::String(secret.clone()));
|
||||
self.save_app_settings_json(&settings).await?;
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
pub async fn save_webdav_password_blob(&self, account: &str, blob: &serde_json::Value) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
let mut credentials =
|
||||
settings.remove("webdav_passwords").and_then(|value| value.as_object().cloned()).unwrap_or_default();
|
||||
credentials.insert(account.to_string(), blob.clone());
|
||||
settings.insert("webdav_passwords".to_string(), serde_json::Value::Object(credentials));
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
|
||||
pub async fn load_webdav_password_blob(&self, account: &str) -> Result<Option<serde_json::Value>, String> {
|
||||
let settings = self.load_app_settings_json().await?;
|
||||
Ok(settings
|
||||
.get("webdav_passwords")
|
||||
.and_then(|value| value.as_object())
|
||||
.and_then(|credentials| credentials.get(account))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub async fn delete_webdav_password_blob(&self, account: &str) -> Result<(), String> {
|
||||
let mut settings = self.load_app_settings_json().await?;
|
||||
let Some(mut credentials) = settings.remove("webdav_passwords").and_then(|value| value.as_object().cloned())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
credentials.remove(account);
|
||||
settings.insert("webdav_passwords".to_string(), serde_json::Value::Object(credentials));
|
||||
self.save_app_settings_json(&settings).await
|
||||
}
|
||||
}
|
||||
|
||||
// AI Conversations
|
||||
|
|
@ -432,6 +475,45 @@ impl Storage {
|
|||
// Connections
|
||||
|
||||
impl Storage {
|
||||
pub async fn save_connection_metadata_preserving_secrets(
|
||||
&self,
|
||||
configs: &[ConnectionConfig],
|
||||
) -> Result<(), String> {
|
||||
let configs = configs.to_vec();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?;
|
||||
|
||||
for config in &configs {
|
||||
let config = config.canonicalized();
|
||||
let config_id = config.id.clone();
|
||||
let mut sanitized = config;
|
||||
sanitized.password = String::new();
|
||||
sanitized.ssh_password = String::new();
|
||||
sanitized.ssh_key_passphrase = String::new();
|
||||
sanitized.proxy_password = String::new();
|
||||
sanitized.redis_sentinel_password = String::new();
|
||||
sanitized.connection_string = None;
|
||||
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])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if configs.is_empty() {
|
||||
tx.execute("DELETE FROM connection_secrets", []).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let placeholders = vec!["?"; configs.len()].join(",");
|
||||
let sql = format!("DELETE FROM connection_secrets WHERE connection_id NOT IN ({placeholders})");
|
||||
let ids = configs.iter().map(|config| &config.id as &dyn ToSql);
|
||||
tx.execute(&sql, params_from_iter(ids)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn save_connections(&self, configs: &[ConnectionConfig]) -> Result<(), String> {
|
||||
let configs = configs.to_vec();
|
||||
self.with_conn(move |conn| {
|
||||
|
|
@ -512,6 +594,47 @@ impl Storage {
|
|||
// Saved SQL
|
||||
|
||||
impl Storage {
|
||||
pub async fn replace_saved_sql_library(&self, library: &SavedSqlLibrary) -> Result<(), String> {
|
||||
let library = library.clone();
|
||||
self.with_conn(move |conn| {
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM saved_sql_files", []).map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM saved_sql_folders", []).map_err(|e| e.to_string())?;
|
||||
|
||||
for folder in &library.folders {
|
||||
tx.execute(
|
||||
"INSERT INTO saved_sql_folders (id, connection_id, name, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
params![folder.id, folder.connection_id, folder.name, folder.created_at, folder.updated_at],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
for file in &library.files {
|
||||
tx.execute(
|
||||
"INSERT INTO saved_sql_files \
|
||||
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
file.id,
|
||||
file.connection_id,
|
||||
file.folder_id,
|
||||
file.name,
|
||||
file.database,
|
||||
file.schema,
|
||||
file.sql,
|
||||
file.created_at,
|
||||
file.updated_at
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_saved_sql_library(&self) -> Result<SavedSqlLibrary, String> {
|
||||
self.with_conn(|conn| {
|
||||
let mut folder_stmt = conn
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
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,
|
||||
};
|
||||
use dbx_core::storage::DesktopSettings;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use dbx_core::connection::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebDavDownloadResult {
|
||||
pub summary: WebDavSyncSummary,
|
||||
pub editor_settings: Option<serde_json::Value>,
|
||||
pub desktop_settings: DesktopSettings,
|
||||
pub apply_summary: ApplySnapshotSummary,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_test(state: State<'_, Arc<AppState>>, mut config: WebDavConfig) -> Result<(), String> {
|
||||
resolve_webdav_password(&state.storage, &mut config).await?;
|
||||
WebDavClient::new(config).test().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_password_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
config: WebDavConfig,
|
||||
) -> Result<WebDavPasswordStatus, String> {
|
||||
webdav_saved_password_status(&state.storage, &config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_webdav_saved_password(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
config: WebDavConfig,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
save_webdav_password(&state.storage, &config, &password).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn forget_webdav_saved_password(state: State<'_, Arc<AppState>>, config: WebDavConfig) -> Result<(), String> {
|
||||
forget_webdav_password(&state.storage, &config).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_upload(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
mut config: WebDavConfig,
|
||||
editor_settings: Option<serde_json::Value>,
|
||||
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?;
|
||||
WebDavClient::new(config).put_snapshot(&snapshot).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_download(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
mut config: WebDavConfig,
|
||||
secrets_passphrase: Option<String>,
|
||||
) -> Result<WebDavDownloadResult, String> {
|
||||
resolve_webdav_password(&state.storage, &mut config).await?;
|
||||
let (snapshot, summary) = WebDavClient::new(config).get_snapshot().await?;
|
||||
let apply_summary = apply_sync_snapshot(
|
||||
&state.storage,
|
||||
&snapshot,
|
||||
ApplySnapshotOptions { secrets_passphrase: secrets_passphrase.as_deref() },
|
||||
)
|
||||
.await?;
|
||||
Ok(WebDavDownloadResult {
|
||||
summary,
|
||||
editor_settings: snapshot.editor_settings,
|
||||
desktop_settings: snapshot.desktop_settings,
|
||||
apply_summary,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod agents;
|
||||
pub mod ai;
|
||||
pub mod app_settings;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
#[allow(dead_code, unused_imports)]
|
||||
mod connection_secrets;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,12 @@ pub fn run() {
|
|||
commands::app_settings::save_desktop_settings,
|
||||
commands::app_settings::load_pinned_tree_node_ids,
|
||||
commands::app_settings::save_pinned_tree_node_ids,
|
||||
commands::cloud_sync::webdav_sync_test,
|
||||
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_upload,
|
||||
commands::cloud_sync::webdav_sync_download,
|
||||
commands::connection::test_connection,
|
||||
commands::connection::connect_db,
|
||||
commands::connection::disconnect_db,
|
||||
|
|
|
|||
Loading…
Reference in New Issue