feat: add configuration export/import with passphrase encryption

This commit is contained in:
t8y2
2026-05-03 12:46:49 +08:00
parent 1b8d56a43b
commit a66cffb692
7 changed files with 347 additions and 11 deletions
+58 -3
View File
@@ -42,6 +42,7 @@ const TableImportDialog = defineAsyncComponent(() => import("@/components/import
const TableStructureEditorDialog = defineAsyncComponent(() => import("@/components/structure/TableStructureEditorDialog.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
import type { ConnectionConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
@@ -131,6 +132,10 @@ const showDiagramDialog = ref(false);
const showTableImportDialog = ref(false);
const showStructureEditorDialog = ref(false);
const showFieldLineageDialog = ref(false);
const showConfigPassphraseDialog = ref(false);
const configPassphraseMode = ref<"export" | "import">("export");
const configPassphraseError = ref("");
const pendingImportContent = ref("");
const transferPrefillConnectionId = ref("");
const transferPrefillDatabase = ref("");
const schemaDiffPrefillConnectionId = ref("");
@@ -318,6 +323,50 @@ async function openLineageTarget(target: LineageNavigationTarget) {
}
}
function onExportClick() {
configPassphraseMode.value = "export";
configPassphraseError.value = "";
showConfigPassphraseDialog.value = true;
}
async function onExportConfirm(passphrase: string) {
try {
await connectionStore.exportConnectionsToFile(passphrase);
showConfigPassphraseDialog.value = false;
toast(t("configExport.exportSuccess"), 2000);
} catch (e: any) {
configPassphraseError.value = e?.message || String(e);
}
}
async function onImportClick() {
try {
const result = await connectionStore.readImportFile();
if (!result) return;
pendingImportContent.value = result.content;
if (result.encrypted) {
configPassphraseMode.value = "import";
configPassphraseError.value = "";
showConfigPassphraseDialog.value = true;
} else {
const count = await connectionStore.importConnectionsFromFile(result.content, null);
toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000);
}
} catch (e: any) {
toast(e?.message || String(e), 4000);
}
}
async function onImportConfirm(passphrase: string) {
try {
const count = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase);
showConfigPassphraseDialog.value = false;
toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000);
} catch (e: any) {
configPassphraseError.value = e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : (e?.message || String(e));
}
}
function onConnectionConnectStarted(name: string) {
toast(t("connection.connecting", { name }), 30000);
}
@@ -969,7 +1018,7 @@ async function setupFileDrop() {
<span class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.importConnectionsFromFile()">
<Button variant="ghost" size="icon" class="h-5 w-5" @click="onImportClick">
<Upload class="h-3 w-3" />
</Button>
</TooltipTrigger>
@@ -977,7 +1026,7 @@ async function setupFileDrop() {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="connectionStore.exportConnectionsToFile()">
<Button variant="ghost" size="icon" class="h-5 w-5" @click="onExportClick">
<Download class="h-3 w-3" />
</Button>
</TooltipTrigger>
@@ -1393,7 +1442,7 @@ async function setupFileDrop() {
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="showHistory = true">
<History class="h-4 w-4" /> {{ t('history.title') }}
</button>
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="connectionStore.importConnectionsFromFile()">
<button class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm hover:bg-muted/50" @click="onImportClick">
<Upload class="h-4 w-4" /> {{ t('sidebar.import') }}
</button>
<div class="mt-2 rounded-md bg-muted/30 px-3 py-2 text-xs leading-5 text-muted-foreground">
@@ -1492,6 +1541,12 @@ async function setupFileDrop() {
:prefill-column="lineagePrefillColumn"
@open-target="openLineageTarget"
/>
<ConfigPassphraseDialog
v-model:open="showConfigPassphraseDialog"
:mode="configPassphraseMode"
:external-error="configPassphraseError"
@confirm="configPassphraseMode === 'export' ? onExportConfirm($event) : onImportConfirm($event)"
/>
<Dialog v-model:open="showUpdateDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Lock } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const props = defineProps<{
open: boolean;
mode: "export" | "import";
externalError?: string;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
"confirm": [passphrase: string];
}>();
const { t } = useI18n();
const dialogOpen = computed({
get: () => props.open,
set: (v) => emit("update:open", v),
});
const passphrase = ref("");
const passphraseConfirm = ref("");
const error = ref("");
watch(dialogOpen, (open) => {
if (open) {
passphrase.value = "";
passphraseConfirm.value = "";
error.value = "";
}
});
function confirm() {
if (!passphrase.value) {
error.value = t("configExport.passphraseRequired");
return;
}
if (props.mode === "export" && passphrase.value !== passphraseConfirm.value) {
error.value = t("configExport.passphraseMismatch");
return;
}
if (props.mode === "export" && passphrase.value.length < 4) {
error.value = t("configExport.passphraseTooShort");
return;
}
emit("confirm", passphrase.value);
}
const displayError = computed(() => error.value || props.externalError || "");
</script>
<template>
<Dialog v-model:open="dialogOpen">
<DialogContent class="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Lock class="h-5 w-5" />
{{ mode === 'export' ? t('configExport.passphraseTitle') : t('configExport.passphraseImportTitle') }}
</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<p class="text-sm text-muted-foreground">
{{ mode === 'export' ? t('configExport.passphraseExportHint') : t('configExport.passphraseImportHint') }}
</p>
<div class="grid gap-2">
<Label>{{ t('configExport.passphrase') }}</Label>
<Input
v-model="passphrase"
type="password"
:placeholder="t('configExport.passphrasePlaceholder')"
@keydown.enter="mode === 'import' ? confirm() : undefined"
/>
</div>
<div v-if="mode === 'export'" class="grid gap-2">
<Label>{{ t('configExport.passphraseConfirm') }}</Label>
<Input
v-model="passphraseConfirm"
type="password"
:placeholder="t('configExport.passphraseConfirmPlaceholder')"
@keydown.enter="confirm"
/>
</div>
<p v-if="displayError" class="text-sm text-destructive">{{ displayError }}</p>
</div>
<DialogFooter>
<Button @click="confirm">
{{ mode === 'export' ? t('configExport.exportEncrypted') : t('configExport.decryptImport') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
+19
View File
@@ -222,6 +222,25 @@ export default {
cancel: "Cancel",
refresh: "Analyze Again",
},
configExport: {
passphraseTitle: "Encrypt & Export",
passphraseImportTitle: "Decrypt & Import",
passphraseExportHint: "To include connection passwords in the export, set a passphrase for encryption. You will need the same passphrase to import.",
passphraseImportHint: "This file is encrypted. Enter the passphrase to decrypt and import.",
passphrase: "Passphrase",
passphraseConfirm: "Confirm Passphrase",
passphrasePlaceholder: "Enter passphrase",
passphraseConfirmPlaceholder: "Re-enter passphrase",
passphraseRequired: "Passphrase is required",
passphraseMismatch: "Passphrases do not match",
passphraseTooShort: "Passphrase must be at least 4 characters",
exportEncrypted: "Export",
decryptImport: "Decrypt & Import",
wrongPassphrase: "Wrong passphrase or corrupted file",
exportSuccess: "Connections exported successfully",
importSuccess: "Imported {count} connection(s)",
importNone: "No new connections to import",
},
ai: {
placeholder: "Describe your query in natural language...",
settings: "AI Settings",
+19
View File
@@ -222,6 +222,25 @@ export default {
cancel: "取消读取",
refresh: "重新分析",
},
configExport: {
passphraseTitle: "加密导出",
passphraseImportTitle: "解密导入",
passphraseExportHint: "如需导出连接密码,请设置密码短语进行加密保护。导入时需要输入相同的密码短语才能解密。",
passphraseImportHint: "此文件已加密,请输入密码短语来解密并导入。",
passphrase: "密码短语",
passphraseConfirm: "确认密码短语",
passphrasePlaceholder: "输入密码短语",
passphraseConfirmPlaceholder: "再次输入密码短语",
passphraseRequired: "请输入密码短语",
passphraseMismatch: "两次输入的密码短语不一致",
passphraseTooShort: "密码短语至少需要 4 个字符",
exportEncrypted: "导出",
decryptImport: "解密并导入",
wrongPassphrase: "密码短语错误或文件已损坏",
exportSuccess: "连接配置导出成功",
importSuccess: "已导入 {count} 个连接",
importNone: "没有新的连接需要导入",
},
ai: {
placeholder: "描述你想查什么...",
settings: "AI 设置",
+79
View File
@@ -0,0 +1,79 @@
export interface EncryptedPayload {
format: "dbx-encrypted";
version: 1;
salt: string;
iv: string;
data: string;
}
export interface PlainConfigPayload {
format: "dbx-config";
version: 1;
connections: unknown[];
}
const PBKDF2_ITERATIONS = 100_000;
async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
const baseKey = await crypto.subtle.importKey("raw", encoder.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function toBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function fromBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export async function encryptConfig(json: string, passphrase: string): Promise<EncryptedPayload> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(passphrase, salt);
const encoded = new TextEncoder().encode(json);
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
return {
format: "dbx-encrypted",
version: 1,
salt: toBase64(salt),
iv: toBase64(iv),
data: toBase64(ciphertext),
};
}
export async function decryptConfig(payload: EncryptedPayload, passphrase: string): Promise<string> {
const salt = fromBase64(payload.salt);
const iv = fromBase64(payload.iv);
const ciphertext = fromBase64(payload.data);
const key = await deriveKey(passphrase, salt);
try {
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plaintext);
} catch {
throw new Error("wrong_passphrase");
}
}
export function isEncryptedConfig(data: unknown): data is EncryptedPayload {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
return obj.format === "dbx-encrypted" && obj.version === 1 && typeof obj.salt === "string" && typeof obj.iv === "string" && typeof obj.data === "string";
}
+35 -8
View File
@@ -577,29 +577,55 @@ export const useConnectionStore = defineStore("connection", () => {
await api.saveConnections(nextConnections);
}
async function exportConnectionsToFile() {
async function exportConnectionsToFile(passphrase: string) {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const { encryptConfig } = await import("@/lib/configCrypto");
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
if (!path) return;
const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "", connection_string: undefined }));
await writeTextFile(path, JSON.stringify(data, null, 2));
const json = JSON.stringify(connections.value);
const payload = await encryptConfig(json, passphrase);
await writeTextFile(path, JSON.stringify(payload, null, 2));
}
async function importConnectionsFromFile() {
async function readImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false });
if (!path) return;
const json = await readTextFile(path as string);
const imported: ConnectionConfig[] = JSON.parse(json);
if (!path) return null;
const content = await readTextFile(path as string);
const { isEncryptedConfig } = await import("@/lib/configCrypto");
const parsed = JSON.parse(content);
return { content, encrypted: isEncryptedConfig(parsed) };
}
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<number> {
let imported: ConnectionConfig[];
const parsed = JSON.parse(content);
if (passphrase) {
const { decryptConfig } = await import("@/lib/configCrypto");
const json = await decryptConfig(parsed, passphrase);
imported = JSON.parse(json);
} else if (Array.isArray(parsed)) {
imported = parsed;
} else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
imported = parsed.connections;
} else {
imported = [];
}
let count = 0;
for (const config of imported) {
if (!connections.value.find((c) => c.id === config.id)) {
const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port);
if (!duplicate) {
config.id = crypto.randomUUID();
const normalized = normalizeConnection(config);
await addConnection(normalized);
count++;
}
}
return count;
}
async function initFromDisk() {
@@ -656,6 +682,7 @@ export const useConnectionStore = defineStore("connection", () => {
listCompletionTables,
listCompletionColumns,
exportConnectionsToFile,
readImportFile,
importConnectionsFromFile,
transferSource,
schemaDiffSource,