mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 00:19:35 +00:00
feat: add configuration export/import with passphrase encryption
This commit is contained in:
+58
-3
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 设置",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user