mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 05:02:44 +08:00
- 变更点:新增mysqlworkbench的xml文件导入,并当没有密码时,提示用户,而不是直接使用空密码进行直接连接,更友好 - 影响范围:仅导入受到影响 - 验证方式:点击导入,用mysqlworkbench的xml进行导入即可
515 lines
15 KiB
Go
515 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"GoNavi-Wails/internal/connection"
|
|
"GoNavi-Wails/internal/secretstore"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem {
|
|
return connectionPackageItem{
|
|
ID: view.ID,
|
|
Name: view.Name,
|
|
IncludeDatabases: cloneStringSlice(view.IncludeDatabases),
|
|
IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases),
|
|
IconType: view.IconType,
|
|
IconColor: view.IconColor,
|
|
Config: stripConnectionSecretFields(view.Config),
|
|
Secrets: bundle,
|
|
}
|
|
}
|
|
|
|
func (a *App) buildConnectionPackagePayload() (connectionPackagePayload, error) {
|
|
repo := a.savedConnectionRepository()
|
|
items, err := repo.List()
|
|
if err != nil {
|
|
return connectionPackagePayload{}, err
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
return connectionPackagePayload{
|
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Connections: connections,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) buildExportedConnectionPackage(options ConnectionExportOptions) ([]byte, error) {
|
|
payload, err := a.buildConnectionPackagePayload()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !options.IncludeSecrets {
|
|
for index := range payload.Connections {
|
|
payload.Connections[index].Secrets = connectionSecretBundle{}
|
|
}
|
|
}
|
|
|
|
normalizedPassword := normalizeConnectionPackagePassword(options.FilePassword)
|
|
if !options.IncludeSecrets || normalizedPassword == "" {
|
|
file, err := encryptConnectionPackageV2AppManaged(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(file, "", " ")
|
|
}
|
|
|
|
file, err := encryptConnectionPackageV2Protected(payload, normalizedPassword)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.MarshalIndent(file, "", " ")
|
|
}
|
|
|
|
func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connection.SavedConnectionInput {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" {
|
|
id = strings.TrimSpace(item.Config.ID)
|
|
}
|
|
|
|
config := item.Config
|
|
config.ID = id
|
|
config.SavePassword = false
|
|
|
|
secrets := item.Secrets
|
|
config.Password = secrets.Password
|
|
config.SSH.Password = secrets.SSHPassword
|
|
config.Proxy.Password = secrets.ProxyPassword
|
|
config.HTTPTunnel.Password = secrets.HTTPTunnelPassword
|
|
config.MySQLReplicaPassword = secrets.MySQLReplicaPassword
|
|
config.MongoReplicaPassword = secrets.MongoReplicaPassword
|
|
config.URI = secrets.OpaqueURI
|
|
config.DSN = secrets.OpaqueDSN
|
|
|
|
return connection.SavedConnectionInput{
|
|
ID: id,
|
|
Name: item.Name,
|
|
Config: config,
|
|
IncludeDatabases: cloneStringSlice(item.IncludeDatabases),
|
|
IncludeRedisDatabases: cloneIntSlice(item.IncludeRedisDatabases),
|
|
IconType: item.IconType,
|
|
IconColor: item.IconColor,
|
|
// 连接恢复包以最新导入文件为准;载荷中缺失的密文字段需要显式清空旧值。
|
|
ClearPrimaryPassword: strings.TrimSpace(secrets.Password) == "",
|
|
ClearSSHPassword: strings.TrimSpace(secrets.SSHPassword) == "",
|
|
ClearProxyPassword: strings.TrimSpace(secrets.ProxyPassword) == "",
|
|
ClearHTTPTunnelPassword: strings.TrimSpace(secrets.HTTPTunnelPassword) == "",
|
|
ClearMySQLReplicaPassword: strings.TrimSpace(secrets.MySQLReplicaPassword) == "",
|
|
ClearMongoReplicaPassword: strings.TrimSpace(secrets.MongoReplicaPassword) == "",
|
|
ClearOpaqueURI: strings.TrimSpace(secrets.OpaqueURI) == "",
|
|
ClearOpaqueDSN: strings.TrimSpace(secrets.OpaqueDSN) == "",
|
|
}
|
|
}
|
|
|
|
func dedupeImportedSavedConnectionViews(views []connection.SavedConnectionView) []connection.SavedConnectionView {
|
|
if len(views) < 2 {
|
|
return views
|
|
}
|
|
|
|
lastIndexByID := make(map[string]int, len(views))
|
|
for index, view := range views {
|
|
id := strings.TrimSpace(view.ID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
lastIndexByID[id] = index
|
|
}
|
|
|
|
result := make([]connection.SavedConnectionView, 0, len(views))
|
|
for index, view := range views {
|
|
id := strings.TrimSpace(view.ID)
|
|
if id != "" && lastIndexByID[id] != index {
|
|
continue
|
|
}
|
|
result = append(result, view)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func dedupeImportedSavedConnectionInputs(inputs []connection.SavedConnectionInput) []connection.SavedConnectionInput {
|
|
if len(inputs) < 2 {
|
|
return inputs
|
|
}
|
|
|
|
lastIndexByID := make(map[string]int, len(inputs))
|
|
for index, input := range inputs {
|
|
id := strings.TrimSpace(input.ID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
lastIndexByID[id] = index
|
|
}
|
|
|
|
result := make([]connection.SavedConnectionInput, 0, len(inputs))
|
|
for index, input := range inputs {
|
|
id := strings.TrimSpace(input.ID)
|
|
if id != "" && lastIndexByID[id] != index {
|
|
continue
|
|
}
|
|
result = append(result, input)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func normalizeImportedSavedConnectionInput(input connection.SavedConnectionInput) connection.SavedConnectionInput {
|
|
if strings.TrimSpace(input.ID) == "" && strings.TrimSpace(input.Config.ID) == "" {
|
|
input.ID = "conn-" + uuid.New().String()[:8]
|
|
}
|
|
if strings.TrimSpace(input.ID) == "" {
|
|
input.ID = strings.TrimSpace(input.Config.ID)
|
|
}
|
|
input.Config.ID = input.ID
|
|
return input
|
|
}
|
|
|
|
func (a *App) importSavedConnectionsAtomically(inputs []connection.SavedConnectionInput) ([]connection.SavedConnectionView, error) {
|
|
repo := a.savedConnectionRepository()
|
|
normalizedInputs := make([]connection.SavedConnectionInput, 0, len(inputs))
|
|
for _, input := range inputs {
|
|
normalizedInputs = append(normalizedInputs, normalizeImportedSavedConnectionInput(input))
|
|
}
|
|
finalInputs := dedupeImportedSavedConnectionInputs(normalizedInputs)
|
|
rollbackSnapshot, err := captureConnectionImportRollbackSnapshot(a, finalInputs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]connection.SavedConnectionView, 0, len(finalInputs))
|
|
for _, input := range finalInputs {
|
|
view, err := repo.Save(input)
|
|
if err != nil {
|
|
if rollbackErr := rollbackSnapshot.restore(a); rollbackErr != nil {
|
|
return nil, errors.Join(err, fmt.Errorf("restore connection import rollback: %w", rollbackErr))
|
|
}
|
|
return nil, err
|
|
}
|
|
result = append(result, view)
|
|
}
|
|
return dedupeImportedSavedConnectionViews(result), nil
|
|
}
|
|
|
|
func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ([]connection.SavedConnectionView, error) {
|
|
inputs := make([]connection.SavedConnectionInput, 0, len(payload.Connections))
|
|
for _, item := range payload.Connections {
|
|
inputs = append(inputs, newSavedConnectionInputFromPackageItem(item))
|
|
}
|
|
return a.importSavedConnectionsAtomically(inputs)
|
|
}
|
|
|
|
func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return nil, errConnectionPackageUnsupported
|
|
}
|
|
if len(trimmed) > connectionImportMaxFileBytes {
|
|
return nil, errConnectionImportFileTooLarge
|
|
}
|
|
|
|
if isConnectionPackageV2AppManaged(trimmed) {
|
|
var file connectionPackageFileV2
|
|
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
|
return nil, errConnectionPackageUnsupported
|
|
}
|
|
payload, err := decryptConnectionPackageV2AppManaged(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views, err := a.importConnectionPackagePayload(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeSavedConnectionViews(views), nil
|
|
}
|
|
|
|
if isConnectionPackageV2Protected(trimmed) {
|
|
var file connectionPackageFileV2Protected
|
|
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
|
return nil, errConnectionPackageUnsupported
|
|
}
|
|
payload, err := decryptConnectionPackageV2Protected(file, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views, err := a.importConnectionPackagePayload(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeSavedConnectionViews(views), nil
|
|
}
|
|
|
|
if isConnectionPackageEnvelope(trimmed) {
|
|
var file connectionPackageFile
|
|
if err := json.Unmarshal([]byte(trimmed), &file); err != nil {
|
|
return nil, errConnectionPackageUnsupported
|
|
}
|
|
payload, err := decryptConnectionPackage(file, password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
views, err := a.importConnectionPackagePayload(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeSavedConnectionViews(views), nil
|
|
}
|
|
|
|
if isMySQLWorkbenchXML(trimmed) {
|
|
inputs, err := parseMySQLWorkbenchXML(trimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析 MySQL Workbench XML 失败: %w", err)
|
|
}
|
|
if len(inputs) == 0 {
|
|
return nil, fmt.Errorf("未在 XML 中找到有效的连接配置")
|
|
}
|
|
views, err := a.importSavedConnectionsAtomically(inputs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sanitizeSavedConnectionViews(views), nil
|
|
}
|
|
|
|
var legacy []connection.LegacySavedConnection
|
|
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
|
|
return nil, errConnectionPackageUnsupported
|
|
}
|
|
return a.ImportLegacyConnections(legacy)
|
|
}
|
|
|
|
type connectionPackageImportRollbackSnapshot struct {
|
|
connectionsFileExists bool
|
|
connectionsFileData []byte
|
|
connectionSecrets map[string]securityUpdateSecretSnapshot
|
|
connectionCleanupRefs []string
|
|
}
|
|
|
|
func captureConnectionImportRollbackSnapshot(a *App, inputs []connection.SavedConnectionInput) (connectionPackageImportRollbackSnapshot, error) {
|
|
snapshot := connectionPackageImportRollbackSnapshot{
|
|
connectionSecrets: make(map[string]securityUpdateSecretSnapshot),
|
|
}
|
|
|
|
repo := a.savedConnectionRepository()
|
|
connectionFileData, connectionFileExists, err := readOptionalFile(repo.connectionsPath())
|
|
if err != nil {
|
|
return snapshot, err
|
|
}
|
|
snapshot.connectionsFileExists = connectionFileExists
|
|
snapshot.connectionsFileData = connectionFileData
|
|
|
|
existingConnections, err := repo.load()
|
|
if err != nil {
|
|
return snapshot, err
|
|
}
|
|
existingConnectionsByID := make(map[string]connection.SavedConnectionView, len(existingConnections))
|
|
for _, item := range existingConnections {
|
|
existingConnectionsByID[item.ID] = item
|
|
}
|
|
|
|
cleanupSet := make(map[string]struct{})
|
|
seenIDs := make(map[string]struct{})
|
|
for _, input := range inputs {
|
|
connectionID := strings.TrimSpace(input.ID)
|
|
if connectionID == "" {
|
|
connectionID = strings.TrimSpace(input.Config.ID)
|
|
}
|
|
if connectionID == "" {
|
|
continue
|
|
}
|
|
if _, alreadySeen := seenIDs[connectionID]; alreadySeen {
|
|
continue
|
|
}
|
|
seenIDs[connectionID] = struct{}{}
|
|
|
|
defaultRef, refErr := secretstore.BuildRef(savedConnectionSecretKind, connectionID)
|
|
if refErr == nil {
|
|
cleanupSet[defaultRef] = struct{}{}
|
|
}
|
|
|
|
existing, ok := existingConnectionsByID[connectionID]
|
|
if !ok || !savedConnectionViewHasSecrets(existing) {
|
|
continue
|
|
}
|
|
|
|
ref := strings.TrimSpace(existing.SecretRef)
|
|
if ref == "" {
|
|
ref = defaultRef
|
|
}
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
|
|
secretSnapshot, captureErr := captureSecurityUpdateSecretSnapshot(a.secretStore, ref)
|
|
if captureErr != nil {
|
|
return snapshot, captureErr
|
|
}
|
|
snapshot.connectionSecrets[ref] = secretSnapshot
|
|
cleanupSet[ref] = struct{}{}
|
|
}
|
|
|
|
snapshot.connectionCleanupRefs = make([]string, 0, len(cleanupSet))
|
|
for ref := range cleanupSet {
|
|
snapshot.connectionCleanupRefs = append(snapshot.connectionCleanupRefs, ref)
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func (s connectionPackageImportRollbackSnapshot) restore(a *App) error {
|
|
repo := a.savedConnectionRepository()
|
|
if err := restoreOptionalFile(repo.connectionsPath(), s.connectionsFileExists, s.connectionsFileData); err != nil {
|
|
return err
|
|
}
|
|
for ref, secretSnapshot := range s.connectionSecrets {
|
|
if err := restoreSecurityUpdateSecretSnapshot(a.secretStore, ref, secretSnapshot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, ref := range s.connectionCleanupRefs {
|
|
if _, alreadyRestored := s.connectionSecrets[ref]; alreadyRestored {
|
|
continue
|
|
}
|
|
if err := deleteSecurityUpdateSecretRef(a.secretStore, ref); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- MySQL Workbench XML import ---
|
|
|
|
func isMySQLWorkbenchXML(content string) bool {
|
|
return strings.Contains(content, "<data") && strings.Contains(content, "grt_format") && strings.Contains(content, "db.mgmt.Connection")
|
|
}
|
|
|
|
// mysqlWorkbenchData is the root XML element.
|
|
type mysqlWorkbenchData struct {
|
|
XMLName xml.Name `xml:"data"`
|
|
Value mysqlWorkbenchTopValue `xml:"value"`
|
|
}
|
|
|
|
type mysqlWorkbenchTopValue struct {
|
|
Values []mysqlWorkbenchConnection `xml:"value"`
|
|
}
|
|
|
|
type mysqlWorkbenchConnection struct {
|
|
StructName string `xml:"struct-name,attr"`
|
|
Values []mysqlWorkbenchValue `xml:"value"`
|
|
Links []mysqlWorkbenchLinkValue `xml:"link"`
|
|
}
|
|
|
|
type mysqlWorkbenchValue struct {
|
|
Type string `xml:"type,attr"`
|
|
Key string `xml:"key,attr"`
|
|
StructName string `xml:"struct-name,attr"`
|
|
Content string `xml:",chardata"`
|
|
Children []mysqlWorkbenchValue `xml:"value"`
|
|
}
|
|
|
|
type mysqlWorkbenchLinkValue struct {
|
|
Key string `xml:"key,attr"`
|
|
Content string `xml:",chardata"`
|
|
}
|
|
|
|
func parseMySQLWorkbenchXML(content string) ([]connection.SavedConnectionInput, error) {
|
|
var data mysqlWorkbenchData
|
|
if err := xml.Unmarshal([]byte(content), &data); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var inputs []connection.SavedConnectionInput
|
|
for _, conn := range data.Value.Values {
|
|
if conn.StructName != "db.mgmt.Connection" {
|
|
continue
|
|
}
|
|
|
|
input := parseMySQLWorkbenchConnection(conn)
|
|
inputs = append(inputs, input)
|
|
}
|
|
return inputs, nil
|
|
}
|
|
|
|
func parseMySQLWorkbenchConnection(conn mysqlWorkbenchConnection) connection.SavedConnectionInput {
|
|
params := make(map[string]string)
|
|
connName := ""
|
|
driverKey := ""
|
|
|
|
for _, v := range conn.Values {
|
|
key := strings.TrimSpace(v.Key)
|
|
switch {
|
|
case key == "name" && v.Type == "string":
|
|
connName = strings.TrimSpace(v.Content)
|
|
case key == "parameterValues" && v.Type == "dict":
|
|
for _, child := range v.Children {
|
|
childKey := strings.TrimSpace(child.Key)
|
|
if childKey == "" {
|
|
continue
|
|
}
|
|
params[childKey] = strings.TrimSpace(child.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, link := range conn.Links {
|
|
if strings.TrimSpace(link.Key) == "driver" {
|
|
driverKey = strings.TrimSpace(link.Content)
|
|
}
|
|
}
|
|
|
|
host := params["hostName"]
|
|
port := 3306
|
|
if p, err := strconv.Atoi(params["port"]); err == nil && p > 0 {
|
|
port = p
|
|
}
|
|
user := params["userName"]
|
|
schema := params["schema"]
|
|
password := params["password"]
|
|
|
|
useSSL := false
|
|
if v, err := strconv.Atoi(params["useSSL"]); err == nil && v > 0 {
|
|
useSSL = true
|
|
}
|
|
|
|
dbType := "mysql"
|
|
if strings.Contains(driverKey, "mariadb") {
|
|
dbType = "mariadb"
|
|
}
|
|
|
|
connID := "conn-" + uuid.New().String()[:8]
|
|
|
|
config := connection.ConnectionConfig{
|
|
ID: connID,
|
|
Type: dbType,
|
|
Host: host,
|
|
Port: port,
|
|
User: user,
|
|
Password: password,
|
|
Database: schema,
|
|
UseSSL: useSSL,
|
|
}
|
|
|
|
if connName == "" {
|
|
connName = fmt.Sprintf("%s@%s:%d", user, host, port)
|
|
}
|
|
|
|
return connection.SavedConnectionInput{
|
|
ID: connID,
|
|
Name: connName,
|
|
Config: config,
|
|
}
|
|
}
|