mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
🐛 fix(connection-package): 导出/导入连接包携带 Redis DB 别名
- 连接包 payload 与导出选项支持 redisDbAliases(连接 ID → db 序号 → 别名) - 导入返回 ConnectionPackageImportResult,一并恢复别名映射 - 前端导出时注入 appearance.redisDbAliases,导入后 merge 回 appearance - 补充别名往返测试与 mergeRedisDbAliases 单元测试
This commit is contained in:
@@ -65,6 +65,11 @@ import {
|
||||
resolveConnectionPackageExportResult,
|
||||
normalizeConnectionPackagePassword,
|
||||
} from './utils/connectionExport';
|
||||
import {
|
||||
mergeRedisDbAliases,
|
||||
sanitizeRedisDbAliases,
|
||||
type RedisDbAliasMap,
|
||||
} from './utils/redisDbAlias';
|
||||
import {
|
||||
bootstrapSecureConfig,
|
||||
finalizeSecurityUpdateStatus,
|
||||
@@ -400,6 +405,32 @@ const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnec
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
|
||||
type ConnectionPackageImportPayload = {
|
||||
connections: SavedConnection[];
|
||||
redisDbAliases: RedisDbAliasMap;
|
||||
};
|
||||
|
||||
/** Normalize ImportConnectionsPayload results: object (new) or bare array (legacy/mock). */
|
||||
const normalizeConnectionPackageImportPayload = (value: unknown): ConnectionPackageImportPayload | null => {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
connections: value as SavedConnection[],
|
||||
redisDbAliases: {},
|
||||
};
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const record = value as { connections?: unknown; redisDbAliases?: unknown };
|
||||
if (!Array.isArray(record.connections)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
connections: record.connections as SavedConnection[],
|
||||
redisDbAliases: sanitizeRedisDbAliases(record.redisDbAliases),
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectionPackageDialogMode = 'import' | 'export';
|
||||
type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';
|
||||
type ToolCenterPaneKey =
|
||||
@@ -2363,9 +2394,9 @@ function App() {
|
||||
throw new Error(t('app.connection_package.error.import_capability_unavailable'));
|
||||
}
|
||||
|
||||
let importedViews: unknown;
|
||||
let importedRaw: unknown;
|
||||
try {
|
||||
importedViews = await backendApp.ImportConnectionsPayload(raw, password);
|
||||
importedRaw = await backendApp.ImportConnectionsPayload(raw, password);
|
||||
} catch (error) {
|
||||
if (isConnectionPackagePasswordRequiredError(error)) {
|
||||
throw error;
|
||||
@@ -2377,11 +2408,19 @@ function App() {
|
||||
: t('app.connection_package.message.import_failed'),
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(importedViews)) {
|
||||
const imported = normalizeConnectionPackageImportPayload(importedRaw);
|
||||
if (!imported) {
|
||||
throw new Error(t('app.connection_package.error.import_no_connections'));
|
||||
}
|
||||
await refreshConnectionsAfterImport(importedViews as SavedConnection[]);
|
||||
return importedViews as SavedConnection[];
|
||||
await refreshConnectionsAfterImport(imported.connections);
|
||||
// Redis DB 别名存在前端 appearance,需随连接包一并恢复
|
||||
if (Object.keys(imported.redisDbAliases).length > 0) {
|
||||
const currentAliases = useStore.getState().appearance.redisDbAliases;
|
||||
useStore.getState().setAppearance({
|
||||
redisDbAliases: mergeRedisDbAliases(currentAliases, imported.redisDbAliases),
|
||||
});
|
||||
}
|
||||
return imported.connections;
|
||||
}, [refreshConnectionsAfterImport, t]);
|
||||
|
||||
const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
|
||||
@@ -2502,6 +2541,8 @@ function App() {
|
||||
connectionPackageDialog.includeSecrets
|
||||
&& connectionPackageDialog.useFilePassword
|
||||
) ? password : '',
|
||||
// Redis DB 别名仅存前端,导出时注入连接包
|
||||
redisDbAliases: useStore.getState().appearance.redisDbAliases,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error ?? '').trim();
|
||||
|
||||
@@ -420,14 +420,29 @@ if (
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => saveMockConnection(item));
|
||||
return {
|
||||
connections: parsed.map((item) => saveMockConnection(item)),
|
||||
redisDbAliases: {},
|
||||
};
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.connections)) {
|
||||
return {
|
||||
connections: parsed.connections.map((item: unknown) => saveMockConnection(item)),
|
||||
redisDbAliases: parsed.redisDbAliases && typeof parsed.redisDbAliases === 'object'
|
||||
? parsed.redisDbAliases
|
||||
: {},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
throw new Error(t('app.browser_mock.import_connection_package_unsupported'));
|
||||
}
|
||||
throw new Error(t('app.browser_mock.import_connection_package_unsupported'));
|
||||
},
|
||||
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: t('app.browser_mock.export_connection_package_unsupported') }),
|
||||
ExportConnectionsPackage: async (_options?: {
|
||||
includeSecrets?: boolean;
|
||||
filePassword?: string;
|
||||
redisDbAliases?: Record<string, Record<string, string>>;
|
||||
}) => ({ success: false, message: t('app.browser_mock.export_connection_package_unsupported') }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
|
||||
SetUpdateChannel: async (channel: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MAX_REDIS_DB_ALIAS_LENGTH,
|
||||
buildRedisDbNodeLabel,
|
||||
getRedisDbAlias,
|
||||
mergeRedisDbAliases,
|
||||
sanitizeRedisDbAlias,
|
||||
sanitizeRedisDbAliases,
|
||||
setRedisDbAlias,
|
||||
@@ -76,4 +77,20 @@ describe('redisDbAlias helpers', () => {
|
||||
it('does not append key counts to the sidebar label', () => {
|
||||
expect(buildRedisDbNodeLabel(0, '12')).toBe('db0 12');
|
||||
});
|
||||
|
||||
it('merges imported aliases over local ones without dropping unrelated labels', () => {
|
||||
const current = {
|
||||
'conn-a': { '0': 'local-cache', '1': 'local-queue' },
|
||||
'conn-b': { '0': 'sessions' },
|
||||
};
|
||||
const incoming = {
|
||||
'conn-a': { '0': 'imported-cache' },
|
||||
'conn-c': { '2': 'metrics' },
|
||||
};
|
||||
expect(mergeRedisDbAliases(current, incoming)).toEqual({
|
||||
'conn-a': { '0': 'imported-cache', '1': 'local-queue' },
|
||||
'conn-b': { '0': 'sessions' },
|
||||
'conn-c': { '2': 'metrics' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,26 @@ export const setRedisDbAlias = (
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge imported Redis DB aliases into the local map. Imported labels win for
|
||||
* the same connection + DB index; unrelated local aliases are preserved.
|
||||
*/
|
||||
export const mergeRedisDbAliases = (
|
||||
current: RedisDbAliasMap | undefined,
|
||||
incoming: RedisDbAliasMap | undefined,
|
||||
): RedisDbAliasMap => {
|
||||
const base = sanitizeRedisDbAliases(current);
|
||||
const imported = sanitizeRedisDbAliases(incoming);
|
||||
const next: RedisDbAliasMap = { ...base };
|
||||
Object.entries(imported).forEach(([connectionId, aliases]) => {
|
||||
next[connectionId] = {
|
||||
...(base[connectionId] || {}),
|
||||
...aliases,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the sidebar label for a Redis DB node. Returns `dbN` when there is no
|
||||
* alias, and `dbN alias` when one is set. Key counts are intentionally not
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -174,7 +174,7 @@ export function GetUpdateChannel():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConfigFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
|
||||
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<app.ConnectionPackageImportResult>;
|
||||
|
||||
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
|
||||
@@ -443,6 +443,7 @@ export namespace app {
|
||||
export class ConnectionExportOptions {
|
||||
includeSecrets: boolean;
|
||||
filePassword?: string;
|
||||
redisDbAliases?: Record<string, any>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConnectionExportOptions(source);
|
||||
@@ -452,8 +453,41 @@ export namespace app {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.includeSecrets = source["includeSecrets"];
|
||||
this.filePassword = source["filePassword"];
|
||||
this.redisDbAliases = source["redisDbAliases"];
|
||||
}
|
||||
}
|
||||
export class ConnectionPackageImportResult {
|
||||
connections: connection.SavedConnectionView[];
|
||||
redisDbAliases?: Record<string, any>;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ConnectionPackageImportResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connections = this.convertValues(source["connections"], connection.SavedConnectionView);
|
||||
this.redisDbAliases = source["redisDbAliases"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class ExportFileOptions {
|
||||
format: string;
|
||||
xlsxMaxRowsPerSheet?: number;
|
||||
|
||||
@@ -131,11 +131,12 @@ func encryptConnectionPackageV2AppManaged(payload connectionPackagePayload) (con
|
||||
}
|
||||
|
||||
return connectionPackageFileV2{
|
||||
V: connectionPackageSchemaVersionV2,
|
||||
Kind: connectionPackageKind,
|
||||
P: connectionPackageProtectionAppManaged,
|
||||
ExportedAt: encryptedPayload.ExportedAt,
|
||||
Connections: encryptedPayload.Connections,
|
||||
V: connectionPackageSchemaVersionV2,
|
||||
Kind: connectionPackageKind,
|
||||
P: connectionPackageProtectionAppManaged,
|
||||
ExportedAt: encryptedPayload.ExportedAt,
|
||||
Connections: encryptedPayload.Connections,
|
||||
RedisDbAliases: encryptedPayload.RedisDbAliases,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -212,8 +213,9 @@ func decryptConnectionPackageV2AppManaged(file connectionPackageFileV2) (connect
|
||||
}
|
||||
|
||||
payload, err := decryptConnectionPackagePayloadSecrets(connectionPackagePayload{
|
||||
ExportedAt: file.ExportedAt,
|
||||
Connections: file.Connections,
|
||||
ExportedAt: file.ExportedAt,
|
||||
Connections: file.Connections,
|
||||
RedisDbAliases: file.RedisDbAliases,
|
||||
}, appKey)
|
||||
if err != nil {
|
||||
return connectionPackagePayload{}, errConnectionPackageDecryptFailed
|
||||
@@ -538,12 +540,15 @@ func decryptConnectionPackageV2ProtectedPlaintext(file connectionPackageFileV2Pr
|
||||
|
||||
func encryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) {
|
||||
encrypted := connectionPackagePayload{
|
||||
ExportedAt: payload.ExportedAt,
|
||||
Connections: make([]connectionPackageItem, len(payload.Connections)),
|
||||
ExportedAt: payload.ExportedAt,
|
||||
Connections: make([]connectionPackageItem, len(payload.Connections)),
|
||||
RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases),
|
||||
}
|
||||
|
||||
for index, item := range payload.Connections {
|
||||
encryptedItem := item
|
||||
// 确保别名被拷贝进加密后的连接项(不加密,仅展示偏好)
|
||||
encryptedItem.RedisDbAliases = cloneStringMap(item.RedisDbAliases)
|
||||
bundle, err := encryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item))
|
||||
if err != nil {
|
||||
return connectionPackagePayload{}, err
|
||||
@@ -557,12 +562,14 @@ func encryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, ap
|
||||
|
||||
func decryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) {
|
||||
decrypted := connectionPackagePayload{
|
||||
ExportedAt: payload.ExportedAt,
|
||||
Connections: make([]connectionPackageItem, len(payload.Connections)),
|
||||
ExportedAt: payload.ExportedAt,
|
||||
Connections: make([]connectionPackageItem, len(payload.Connections)),
|
||||
RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases),
|
||||
}
|
||||
|
||||
for index, item := range payload.Connections {
|
||||
decryptedItem := item
|
||||
decryptedItem.RedisDbAliases = cloneStringMap(item.RedisDbAliases)
|
||||
bundle, err := decryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item))
|
||||
if err != nil {
|
||||
return connectionPackagePayload{}, err
|
||||
|
||||
@@ -15,12 +15,13 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem {
|
||||
func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle, redisDbAliases map[string]string) connectionPackageItem {
|
||||
return connectionPackageItem{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
IncludeDatabases: cloneStringSlice(view.IncludeDatabases),
|
||||
IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases),
|
||||
RedisDbAliases: cloneStringMap(redisDbAliases),
|
||||
IconType: view.IconType,
|
||||
IconColor: view.IconColor,
|
||||
Config: stripConnectionSecretFields(view.Config),
|
||||
@@ -28,30 +29,98 @@ func newConnectionPackageItem(view connection.SavedConnectionView, bundle connec
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) buildConnectionPackagePayload() (connectionPackagePayload, error) {
|
||||
func sanitizeConnectionPackageRedisDbAliases(value map[string]map[string]string) map[string]map[string]string {
|
||||
if len(value) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]map[string]string, len(value))
|
||||
for connectionID, aliases := range value {
|
||||
id := strings.TrimSpace(connectionID)
|
||||
if id == "" || len(aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
cleaned := make(map[string]string, len(aliases))
|
||||
for dbIndex, alias := range aliases {
|
||||
dbKey := strings.TrimSpace(dbIndex)
|
||||
if dbKey == "" {
|
||||
continue
|
||||
}
|
||||
// 仅接受数字下标,与前端 redisDbAlias 一致
|
||||
if _, err := strconv.Atoi(dbKey); err != nil {
|
||||
continue
|
||||
}
|
||||
label := strings.Join(strings.Fields(strings.TrimSpace(alias)), " ")
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
if len(label) > 64 {
|
||||
label = label[:64]
|
||||
}
|
||||
cleaned[dbKey] = label
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
result[id] = cleaned
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectRedisDbAliasesFromPackagePayload(payload connectionPackagePayload) map[string]map[string]string {
|
||||
merged := make(map[string]map[string]string)
|
||||
// 顶层字段(若有)
|
||||
for connectionID, aliases := range sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases) {
|
||||
merged[connectionID] = cloneStringMap(aliases)
|
||||
}
|
||||
// 连接项内嵌字段优先覆盖(导出时与连接同级,更可靠)
|
||||
for _, item := range payload.Connections {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := sanitizeConnectionPackageRedisDbAliases(map[string]map[string]string{id: item.RedisDbAliases})
|
||||
if aliases, ok := cleaned[id]; ok && len(aliases) > 0 {
|
||||
merged[id] = aliases
|
||||
}
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func (a *App) buildConnectionPackagePayload(redisDbAliases map[string]map[string]string) (connectionPackagePayload, error) {
|
||||
repo := a.savedConnectionRepository()
|
||||
items, err := repo.List()
|
||||
if err != nil {
|
||||
return connectionPackagePayload{}, err
|
||||
}
|
||||
|
||||
aliasesByConnection := sanitizeConnectionPackageRedisDbAliases(redisDbAliases)
|
||||
connections := make([]connectionPackageItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
bundle, bundleErr := repo.loadSecretBundle(item)
|
||||
if bundleErr != nil {
|
||||
return connectionPackagePayload{}, bundleErr
|
||||
}
|
||||
connections = append(connections, newConnectionPackageItem(item, bundle))
|
||||
var itemAliases map[string]string
|
||||
if aliasesByConnection != nil {
|
||||
itemAliases = aliasesByConnection[strings.TrimSpace(item.ID)]
|
||||
}
|
||||
connections = append(connections, newConnectionPackageItem(item, bundle, itemAliases))
|
||||
}
|
||||
|
||||
return connectionPackagePayload{
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Connections: connections,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Connections: connections,
|
||||
RedisDbAliases: aliasesByConnection,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) buildExportedConnectionPackage(options ConnectionExportOptions) ([]byte, error) {
|
||||
payload, err := a.buildConnectionPackagePayload()
|
||||
payload, err := a.buildConnectionPackagePayload(options.RedisDbAliases)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -215,7 +284,14 @@ func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) (
|
||||
return a.importSavedConnectionsAtomically(inputs)
|
||||
}
|
||||
|
||||
func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) {
|
||||
func connectionPackageImportResultFromViews(views []connection.SavedConnectionView, redisDbAliases map[string]map[string]string) ConnectionPackageImportResult {
|
||||
return ConnectionPackageImportResult{
|
||||
Connections: sanitizeSavedConnectionViews(views),
|
||||
RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(redisDbAliases),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ImportConnectionsPayload(raw string, password string) (ConnectionPackageImportResult, error) {
|
||||
localizeError := func(err error) error {
|
||||
return localizeConnectionPackageError(a.appText, err)
|
||||
}
|
||||
@@ -223,97 +299,102 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
|
||||
return errors.New(a.appText(key, params))
|
||||
}
|
||||
|
||||
empty := ConnectionPackageImportResult{}
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, localizeError(errConnectionPackageUnsupported)
|
||||
return empty, localizeError(errConnectionPackageUnsupported)
|
||||
}
|
||||
if len(trimmed) > connectionImportMaxFileBytes {
|
||||
return nil, localizeError(errConnectionImportFileTooLarge)
|
||||
return empty, localizeError(errConnectionImportFileTooLarge)
|
||||
}
|
||||
|
||||
if isConnectionPackageV2AppManaged(trimmed) {
|
||||
var file connectionPackageFileV2
|
||||
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
||||
return nil, localizeError(errConnectionPackageUnsupported)
|
||||
return empty, localizeError(errConnectionPackageUnsupported)
|
||||
}
|
||||
payload, err := decryptConnectionPackageV2AppManaged(file)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
views, err := a.importConnectionPackagePayload(payload)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil
|
||||
}
|
||||
|
||||
if isConnectionPackageV2Protected(trimmed) {
|
||||
var file connectionPackageFileV2Protected
|
||||
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
||||
return nil, localizeError(errConnectionPackageUnsupported)
|
||||
return empty, localizeError(errConnectionPackageUnsupported)
|
||||
}
|
||||
payload, err := decryptConnectionPackageV2Protected(file, password)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
views, err := a.importConnectionPackagePayload(payload)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil
|
||||
}
|
||||
|
||||
if isConnectionPackageEnvelope(trimmed) {
|
||||
var file connectionPackageFile
|
||||
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
||||
return nil, localizeError(errConnectionPackageUnsupported)
|
||||
return empty, localizeError(errConnectionPackageUnsupported)
|
||||
}
|
||||
payload, err := decryptConnectionPackage(file, password)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
views, err := a.importConnectionPackagePayload(payload)
|
||||
if err != nil {
|
||||
return nil, localizeError(err)
|
||||
return empty, localizeError(err)
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil
|
||||
}
|
||||
|
||||
if isMySQLWorkbenchXML(trimmed) {
|
||||
inputs, err := parseMySQLWorkbenchXML(trimmed)
|
||||
if err != nil {
|
||||
return nil, mysqlWorkbenchError("file.backend.error.mysql_workbench_parse_failed", map[string]any{"detail": err.Error()})
|
||||
return empty, mysqlWorkbenchError("file.backend.error.mysql_workbench_parse_failed", map[string]any{"detail": err.Error()})
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, mysqlWorkbenchError("file.backend.error.mysql_workbench_no_connections", nil)
|
||||
return empty, mysqlWorkbenchError("file.backend.error.mysql_workbench_no_connections", nil)
|
||||
}
|
||||
views, err := a.importSavedConnectionsAtomically(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return empty, err
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
return connectionPackageImportResultFromViews(views, nil), nil
|
||||
}
|
||||
|
||||
if isNavicatNCX(trimmed) {
|
||||
inputs, err := parseNavicatNCXWithText(trimmed, a.appText)
|
||||
if err != nil {
|
||||
return nil, navicatWrapError(a.appText, "file.backend.error.navicat_ncx_parse_failed", nil, err)
|
||||
return empty, navicatWrapError(a.appText, "file.backend.error.navicat_ncx_parse_failed", nil, err)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, navicatError(a.appText, "file.backend.error.navicat_ncx_no_connections", nil)
|
||||
return empty, navicatError(a.appText, "file.backend.error.navicat_ncx_no_connections", nil)
|
||||
}
|
||||
views, err := a.importSavedConnectionsAtomically(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return empty, err
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
return connectionPackageImportResultFromViews(views, nil), nil
|
||||
}
|
||||
|
||||
var legacy []connection.LegacySavedConnection
|
||||
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
|
||||
return nil, localizeError(errConnectionPackageUnsupported)
|
||||
return empty, localizeError(errConnectionPackageUnsupported)
|
||||
}
|
||||
return a.ImportLegacyConnections(legacy)
|
||||
views, err := a.ImportLegacyConnections(legacy)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
return connectionPackageImportResultFromViews(views, nil), nil
|
||||
}
|
||||
|
||||
type connectionPackageImportRollbackSnapshot struct {
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestBuildConnectionPackagePayloadIncludesSecretBundles(t *testing.T) {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
payload, err := app.buildConnectionPackagePayload()
|
||||
payload, err := app.buildConnectionPackagePayload(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("buildConnectionPackagePayload returned error: %v", err)
|
||||
}
|
||||
@@ -128,7 +128,8 @@ func TestBuildExportedConnectionPackageWithoutSecretsUsesV2AppManagedAndImportsW
|
||||
importApp := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
importApp.configDir = t.TempDir()
|
||||
|
||||
imported, err := importApp.ImportConnectionsPayload(string(raw), "")
|
||||
importedResult, err := importApp.ImportConnectionsPayload(string(raw), "")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -353,7 +354,8 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T)
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("expected ImportConnectionsPayload to succeed without secret store, got %v", err)
|
||||
}
|
||||
@@ -574,7 +576,8 @@ func TestImportConnectionsPayloadLegacyJSONClearsExistingSecretWhenMissing(t *te
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -628,7 +631,8 @@ func TestImportConnectionsPayloadLegacyJSONLatestEntryWinsForSameID(t *testing.T
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -692,7 +696,8 @@ func TestImportConnectionsPayloadLegacyJSONLatestEntryWithoutPasswordDoesNotKeep
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -828,7 +833,8 @@ func TestImportConnectionsPayloadEnvelopeImportsAndOverwritesSecrets(t *testing.
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(string(raw), "package-password")
|
||||
importedResult, err := app.ImportConnectionsPayload(string(raw), "package-password")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -890,7 +896,8 @@ func TestBuildExportedConnectionPackageWithSecretsUsesV2AppManagedEncryption(t *
|
||||
}
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(rawString, "")
|
||||
importedResult, err := app.ImportConnectionsPayload(rawString, "")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -933,7 +940,8 @@ func TestBuildExportedConnectionPackageWithFilePasswordUsesV2ProtectedEnvelope(t
|
||||
t.Fatalf("wrong v2 p=2 password should return unified error, got %v", err)
|
||||
}
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(rawString, "package-password")
|
||||
importedResult, err := app.ImportConnectionsPayload(rawString, "package-password")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -962,6 +970,86 @@ func TestNormalizeConnectionPackageExportFilenameAddsExtension(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportedConnectionPackageCarriesRedisDbAliases(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
_, err := app.SaveConnection(connection.SavedConnectionInput{
|
||||
ID: "redis-1",
|
||||
Name: "Cache",
|
||||
Config: connection.ConnectionConfig{
|
||||
ID: "redis-1",
|
||||
Type: "redis",
|
||||
Host: "127.0.0.1",
|
||||
Port: 6379,
|
||||
},
|
||||
IncludeRedisDatabases: []int{0, 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConnection returned error: %v", err)
|
||||
}
|
||||
|
||||
aliases := map[string]map[string]string{
|
||||
"redis-1": {
|
||||
"0": "cache",
|
||||
"1": "sessions",
|
||||
},
|
||||
// malformed entries must be dropped by sanitize
|
||||
"": {"0": "orphan"},
|
||||
"other": {"x": "bad-index", "2": " "},
|
||||
}
|
||||
|
||||
raw, err := app.buildExportedConnectionPackage(ConnectionExportOptions{
|
||||
IncludeSecrets: false,
|
||||
RedisDbAliases: aliases,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildExportedConnectionPackage returned error: %v", err)
|
||||
}
|
||||
|
||||
importApp := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
importApp.configDir = t.TempDir()
|
||||
|
||||
importedResult, err := importApp.ImportConnectionsPayload(string(raw), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
if len(importedResult.Connections) != 1 {
|
||||
t.Fatalf("expected 1 imported connection, got %d", len(importedResult.Connections))
|
||||
}
|
||||
if importedResult.Connections[0].ID != "redis-1" {
|
||||
t.Fatalf("expected redis-1, got %q", importedResult.Connections[0].ID)
|
||||
}
|
||||
if importedResult.RedisDbAliases == nil {
|
||||
t.Fatal("expected RedisDbAliases in import result")
|
||||
}
|
||||
got := importedResult.RedisDbAliases["redis-1"]
|
||||
if got == nil {
|
||||
t.Fatalf("expected aliases for redis-1, got %#v", importedResult.RedisDbAliases)
|
||||
}
|
||||
if got["0"] != "cache" || got["1"] != "sessions" {
|
||||
t.Fatalf("unexpected redis aliases: %#v", got)
|
||||
}
|
||||
if _, ok := importedResult.RedisDbAliases[""]; ok {
|
||||
t.Fatal("empty connection id should not appear in imported aliases")
|
||||
}
|
||||
if _, ok := importedResult.RedisDbAliases["other"]; ok {
|
||||
t.Fatal("malformed aliases should be dropped")
|
||||
}
|
||||
|
||||
// payload 内连接项也应携带别名(便于单项迁移)
|
||||
payload, err := app.buildConnectionPackagePayload(aliases)
|
||||
if err != nil {
|
||||
t.Fatalf("buildConnectionPackagePayload returned error: %v", err)
|
||||
}
|
||||
if payload.Connections[0].RedisDbAliases["0"] != "cache" {
|
||||
t.Fatalf("expected item-level alias cache, got %#v", payload.Connections[0].RedisDbAliases)
|
||||
}
|
||||
if payload.RedisDbAliases["redis-1"]["1"] != "sessions" {
|
||||
t.Fatalf("expected top-level alias sessions, got %#v", payload.RedisDbAliases)
|
||||
}
|
||||
}
|
||||
|
||||
type failOnPutSecretStore struct {
|
||||
base *fakeAppSecretStore
|
||||
failRef string
|
||||
|
||||
@@ -133,11 +133,12 @@ type connectionPackageKDFSpec struct {
|
||||
}
|
||||
|
||||
type connectionPackageFileV2 struct {
|
||||
V int `json:"v"`
|
||||
Kind string `json:"kind"`
|
||||
P int `json:"p"`
|
||||
ExportedAt string `json:"exportedAt,omitempty"`
|
||||
Connections []connectionPackageItem `json:"connections"`
|
||||
V int `json:"v"`
|
||||
Kind string `json:"kind"`
|
||||
P int `json:"p"`
|
||||
ExportedAt string `json:"exportedAt,omitempty"`
|
||||
Connections []connectionPackageItem `json:"connections"`
|
||||
RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"`
|
||||
}
|
||||
|
||||
type connectionPackageFileV2Protected struct {
|
||||
@@ -158,8 +159,10 @@ type connectionPackageKDFSpecV2 struct {
|
||||
}
|
||||
|
||||
type connectionPackagePayload struct {
|
||||
ExportedAt string `json:"exportedAt,omitempty"`
|
||||
Connections []connectionPackageItem `json:"connections"`
|
||||
ExportedAt string `json:"exportedAt,omitempty"`
|
||||
Connections []connectionPackageItem `json:"connections"`
|
||||
// RedisDbAliases:连接 ID → (db 序号字符串 → 别名)。前端展示偏好,随连接包一并迁移。
|
||||
RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"`
|
||||
}
|
||||
|
||||
type connectionPackageItem struct {
|
||||
@@ -167,6 +170,8 @@ type connectionPackageItem struct {
|
||||
Name string `json:"name"`
|
||||
IncludeDatabases []string `json:"includeDatabases,omitempty"`
|
||||
IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"`
|
||||
// RedisDbAliases:该连接下 db 序号 → 别名(如 "0"→"cache"),与前端 redisDbAliases 对齐。
|
||||
RedisDbAliases map[string]string `json:"redisDbAliases,omitempty"`
|
||||
IconType string `json:"iconType,omitempty"`
|
||||
IconColor string `json:"iconColor,omitempty"`
|
||||
Config connection.ConnectionConfig `json:"config"`
|
||||
@@ -179,6 +184,7 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) {
|
||||
Name string `json:"name"`
|
||||
IncludeDatabases []string `json:"includeDatabases,omitempty"`
|
||||
IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"`
|
||||
RedisDbAliases map[string]string `json:"redisDbAliases,omitempty"`
|
||||
IconType string `json:"iconType,omitempty"`
|
||||
IconColor string `json:"iconColor,omitempty"`
|
||||
Config connection.ConnectionConfig `json:"config"`
|
||||
@@ -190,6 +196,7 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) {
|
||||
Name: i.Name,
|
||||
IncludeDatabases: i.IncludeDatabases,
|
||||
IncludeRedisDatabases: i.IncludeRedisDatabases,
|
||||
RedisDbAliases: cloneStringMap(i.RedisDbAliases),
|
||||
IconType: i.IconType,
|
||||
IconColor: i.IconColor,
|
||||
Config: i.Config,
|
||||
@@ -204,6 +211,15 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) {
|
||||
type ConnectionExportOptions struct {
|
||||
IncludeSecrets bool `json:"includeSecrets"`
|
||||
FilePassword string `json:"filePassword,omitempty"`
|
||||
// RedisDbAliases 由前端传入(appearance.redisDbAliases),导出时写入连接包。
|
||||
RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionPackageImportResult 连接包导入结果;兼容仅返回 connections 数组的旧前端解析逻辑时,
|
||||
// 新字段 redisDbAliases 可一并恢复 Redis DB 别名。
|
||||
type ConnectionPackageImportResult struct {
|
||||
Connections []connection.SavedConnectionView `json:"connections"`
|
||||
RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"`
|
||||
}
|
||||
|
||||
func defaultConnectionPackageKDFSpec() connectionPackageKDFSpec {
|
||||
|
||||
@@ -24,7 +24,8 @@ func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T
|
||||
<Connection ConnType="SQLITE" ConnectionName="History DB" DatabaseFileName="C:\navicat\history.db" />
|
||||
</Connections>`, mysqlPassword, postgresPassword, sshPassword, proxyPassword)
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(raw, "")
|
||||
importedResult, err := app.ImportConnectionsPayload(raw, "")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
@@ -225,7 +226,8 @@ func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T)
|
||||
<Connection ConnType="REDIS" ConnectionName="Redis Cache" Host="redis.local" Port="6379" Database="5" UserName="default" Password="%s" SavePassword="true" />
|
||||
</Connections>`, oraclePassword, redisPassword)
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(raw, "")
|
||||
importedResult, err := app.ImportConnectionsPayload(raw, "")
|
||||
imported := importedResult.Connections
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -148,6 +148,17 @@ func cloneIntSlice(input []int) []int {
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneStringMap(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.SavedConnectionView, connectionSecretBundle) {
|
||||
id := strings.TrimSpace(input.ID)
|
||||
if id == "" {
|
||||
|
||||
Reference in New Issue
Block a user