mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
- 新增 WebDAV 与 S3 独立配置、凭据和远端状态管理 - 使用 Argon2id 与 AES-256-GCM 加密连接及配置备份 - 支持分类备份、自动同步、远端预览和一次性确认恢复 - 连接与已保存查询按 ID 合并恢复,并保留本地独有数据 - 补充前后端回归测试、六语言文案和恢复失败回滚
1551 lines
52 KiB
Go
1551 lines
52 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"GoNavi-Wails/internal/appdata"
|
|
"GoNavi-Wails/internal/cloudbackup"
|
|
"GoNavi-Wails/internal/connection"
|
|
"GoNavi-Wails/internal/dailysecret"
|
|
"GoNavi-Wails/internal/logger"
|
|
"GoNavi-Wails/internal/secretstore"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
cloudBackupConfigFileName = "cloud_backup.json"
|
|
cloudBackupSecretKind = "cloud-backup"
|
|
cloudBackupLegacySecretID = "default"
|
|
cloudBackupWebDAVSecretID = "webdav"
|
|
cloudBackupS3SecretID = "s3"
|
|
cloudBackupEncryptionID = "encryption"
|
|
cloudBackupConfigSchemaVersion = 3
|
|
cloudBackupLegacyConfigSchema = 1
|
|
cloudBackupSplitConfigSchema = 2
|
|
cloudBackupPayloadSchemaVersion = 1
|
|
cloudBackupDefaultWebDAVFilePath = "gonavi/backup.gonavi"
|
|
cloudBackupDefaultS3ObjectKey = "gonavi/backup.gonavi"
|
|
cloudBackupMaxFileBytes = 32 * 1024 * 1024
|
|
defaultCloudBackupRestoreConfirmationTokenTTL = 10 * time.Minute
|
|
)
|
|
|
|
// NewCloudBackupChangeHandler returns a callback for services that persist
|
|
// files included in the cloud backup payload.
|
|
func NewCloudBackupChangeHandler(a *App) func() {
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
return a.markCloudBackupDirty
|
|
}
|
|
|
|
type cloudBackupSecrets struct {
|
|
WebDAVUsername string `json:"webdavUsername,omitempty"`
|
|
WebDAVPassword string `json:"webdavPassword,omitempty"`
|
|
S3AccessKey string `json:"s3AccessKey,omitempty"`
|
|
S3SecretKey string `json:"s3SecretKey,omitempty"`
|
|
EncryptionPassword string `json:"encryptionPassword,omitempty"`
|
|
}
|
|
|
|
type cloudBackupWebDAVSecrets struct {
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
type cloudBackupS3Secrets struct {
|
|
AccessKey string `json:"accessKey,omitempty"`
|
|
SecretKey string `json:"secretKey,omitempty"`
|
|
}
|
|
|
|
type cloudBackupEncryptionSecret struct {
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
type cloudBackupSecretSnapshot struct {
|
|
ref string
|
|
payload []byte
|
|
exists bool
|
|
}
|
|
|
|
type cloudBackupPersisted struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
Config CloudBackupConfig `json:"config"`
|
|
}
|
|
|
|
type cloudBackupFile struct {
|
|
Path string `json:"path"`
|
|
Data []byte `json:"data"`
|
|
}
|
|
|
|
type cloudBackupPayload struct {
|
|
SchemaVersion int `json:"schemaVersion"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Connections connectionPackagePayload `json:"connections"`
|
|
Files []cloudBackupFile `json:"files,omitempty"`
|
|
}
|
|
|
|
type cloudBackupRestoreTarget struct {
|
|
target string
|
|
data []byte
|
|
mode os.FileMode
|
|
}
|
|
|
|
type cloudBackupFileSnapshot struct {
|
|
target string
|
|
data []byte
|
|
mode os.FileMode
|
|
exists bool
|
|
}
|
|
|
|
type cloudBackupConnectionFilesSnapshot struct {
|
|
connectionsData []byte
|
|
connectionsExists bool
|
|
dailySecretsData []byte
|
|
dailySecretsExists bool
|
|
}
|
|
|
|
type cloudBackupRestoreConfirmationToken struct {
|
|
payloadHash string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
var cloudBackupCategoryOrder = []string{
|
|
CloudBackupCategoryConnections,
|
|
CloudBackupCategorySavedQueries,
|
|
CloudBackupCategoryAISettings,
|
|
CloudBackupCategoryProxySettings,
|
|
CloudBackupCategoryDailySecrets,
|
|
CloudBackupCategoryUpdateSettings,
|
|
}
|
|
|
|
func defaultCloudBackupCategories() []string {
|
|
return append([]string(nil), cloudBackupCategoryOrder...)
|
|
}
|
|
|
|
func normalizeCloudBackupCategories(categories []string) []string {
|
|
selected := make(map[string]struct{}, len(categories))
|
|
for _, category := range categories {
|
|
selected[strings.TrimSpace(category)] = struct{}{}
|
|
}
|
|
normalized := make([]string, 0, len(selected))
|
|
for _, category := range cloudBackupCategoryOrder {
|
|
if _, ok := selected[category]; ok {
|
|
normalized = append(normalized, category)
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func normalizeCloudBackupConfig(config CloudBackupConfig) CloudBackupConfig {
|
|
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
|
if config.Provider != CloudBackupProviderS3 && config.Provider != CloudBackupProviderWebDAV {
|
|
config.Provider = CloudBackupProviderWebDAV
|
|
}
|
|
config.WebDAVEndpoint = strings.TrimRight(strings.TrimSpace(config.WebDAVEndpoint), "/")
|
|
config.WebDAVFilePath = strings.Trim(strings.TrimSpace(config.WebDAVFilePath), "/")
|
|
config.S3Endpoint = strings.TrimRight(strings.TrimSpace(config.S3Endpoint), "/")
|
|
config.S3Bucket = strings.TrimSpace(config.S3Bucket)
|
|
config.S3Region = strings.TrimSpace(config.S3Region)
|
|
config.S3ObjectKey = strings.Trim(strings.TrimSpace(config.S3ObjectKey), "/")
|
|
config.BackupCategories = normalizeCloudBackupCategories(config.BackupCategories)
|
|
if config.WebDAVFilePath == "" {
|
|
config.WebDAVFilePath = cloudBackupDefaultWebDAVFilePath
|
|
}
|
|
if config.S3ObjectKey == "" {
|
|
config.S3ObjectKey = cloudBackupDefaultS3ObjectKey
|
|
}
|
|
switch config.Schedule {
|
|
case CloudBackupScheduleImmediate, CloudBackupSchedule10Minutes, CloudBackupSchedule30Minutes, CloudBackupSchedule1Hour, CloudBackupScheduleOnExit:
|
|
default:
|
|
config.Schedule = CloudBackupScheduleManual
|
|
}
|
|
return config
|
|
}
|
|
|
|
func (a *App) cloudBackupConfigPath() string {
|
|
root := strings.TrimSpace(a.configDir)
|
|
if root == "" {
|
|
root = resolveAppConfigDir()
|
|
}
|
|
return filepath.Join(root, cloudBackupConfigFileName)
|
|
}
|
|
|
|
func cloudBackupSecretRef(id string) (string, error) {
|
|
return secretstore.BuildRef(cloudBackupSecretKind, id)
|
|
}
|
|
|
|
func (a *App) loadCloudBackupConfig() (CloudBackupConfig, error) {
|
|
data, err := os.ReadFile(a.cloudBackupConfigPath())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return normalizeCloudBackupConfig(CloudBackupConfig{BackupCategories: defaultCloudBackupCategories()}), nil
|
|
}
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
var persisted cloudBackupPersisted
|
|
if err := json.Unmarshal(data, &persisted); err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
if persisted.SchemaVersion != cloudBackupLegacyConfigSchema && persisted.SchemaVersion != cloudBackupSplitConfigSchema && persisted.SchemaVersion != cloudBackupConfigSchemaVersion {
|
|
return CloudBackupConfig{}, errors.New("unsupported cloud backup configuration")
|
|
}
|
|
if persisted.SchemaVersion < cloudBackupConfigSchemaVersion && len(persisted.Config.BackupCategories) == 0 {
|
|
persisted.Config.BackupCategories = defaultCloudBackupCategories()
|
|
}
|
|
return normalizeCloudBackupConfig(persisted.Config), nil
|
|
}
|
|
|
|
func (a *App) loadCloudBackupSecrets() (cloudBackupSecrets, error) {
|
|
a.cloudBackupSecretMu.Lock()
|
|
defer a.cloudBackupSecretMu.Unlock()
|
|
return a.loadCloudBackupSecretsUnlocked(true, true, true)
|
|
}
|
|
|
|
func (a *App) loadCloudBackupProviderSecrets(provider string) (cloudBackupSecrets, error) {
|
|
a.cloudBackupSecretMu.Lock()
|
|
defer a.cloudBackupSecretMu.Unlock()
|
|
if provider == CloudBackupProviderS3 {
|
|
return a.loadCloudBackupSecretsUnlocked(false, true, true)
|
|
}
|
|
return a.loadCloudBackupSecretsUnlocked(true, false, true)
|
|
}
|
|
|
|
func (a *App) loadCloudBackupSecretsUnlocked(includeWebDAV, includeS3, includeEncryption bool) (cloudBackupSecrets, error) {
|
|
var secrets cloudBackupSecrets
|
|
webDAVLoaded, s3Loaded, encryptionLoaded := !includeWebDAV, !includeS3, !includeEncryption
|
|
if includeWebDAV {
|
|
var value cloudBackupWebDAVSecrets
|
|
loaded, err := a.readCloudBackupSecret(cloudBackupWebDAVSecretID, &value)
|
|
if err != nil {
|
|
return cloudBackupSecrets{}, err
|
|
}
|
|
webDAVLoaded = loaded
|
|
if loaded {
|
|
secrets.WebDAVUsername, secrets.WebDAVPassword = value.Username, value.Password
|
|
}
|
|
}
|
|
if includeS3 {
|
|
var value cloudBackupS3Secrets
|
|
loaded, err := a.readCloudBackupSecret(cloudBackupS3SecretID, &value)
|
|
if err != nil {
|
|
return cloudBackupSecrets{}, err
|
|
}
|
|
s3Loaded = loaded
|
|
if loaded {
|
|
secrets.S3AccessKey, secrets.S3SecretKey = value.AccessKey, value.SecretKey
|
|
}
|
|
}
|
|
if includeEncryption {
|
|
var value cloudBackupEncryptionSecret
|
|
loaded, err := a.readCloudBackupSecret(cloudBackupEncryptionID, &value)
|
|
if err != nil {
|
|
return cloudBackupSecrets{}, err
|
|
}
|
|
encryptionLoaded = loaded
|
|
if loaded {
|
|
secrets.EncryptionPassword = value.Password
|
|
}
|
|
}
|
|
if webDAVLoaded && s3Loaded && encryptionLoaded {
|
|
return secrets, nil
|
|
}
|
|
|
|
var legacy cloudBackupSecrets
|
|
legacyLoaded, err := a.readCloudBackupSecret(cloudBackupLegacySecretID, &legacy)
|
|
if err != nil {
|
|
return cloudBackupSecrets{}, err
|
|
}
|
|
if !legacyLoaded {
|
|
return secrets, nil
|
|
}
|
|
if includeWebDAV && !webDAVLoaded {
|
|
secrets.WebDAVUsername, secrets.WebDAVPassword = legacy.WebDAVUsername, legacy.WebDAVPassword
|
|
}
|
|
if includeS3 && !s3Loaded {
|
|
secrets.S3AccessKey, secrets.S3SecretKey = legacy.S3AccessKey, legacy.S3SecretKey
|
|
}
|
|
if includeEncryption && !encryptionLoaded {
|
|
secrets.EncryptionPassword = legacy.EncryptionPassword
|
|
}
|
|
return secrets, nil
|
|
}
|
|
|
|
func (a *App) readCloudBackupSecret(id string, target any) (bool, error) {
|
|
ref, err := cloudBackupSecretRef(id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
payload, err := a.secretStore.Get(ref)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
if err := json.Unmarshal(payload, target); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (a *App) saveCloudBackupState(config CloudBackupConfig) error {
|
|
a.cloudBackupStateMu.Lock()
|
|
defer a.cloudBackupStateMu.Unlock()
|
|
return a.saveCloudBackupStateUnlocked(config)
|
|
}
|
|
|
|
func (a *App) saveCloudBackupStateUnlocked(config CloudBackupConfig) error {
|
|
if err := os.MkdirAll(filepath.Dir(a.cloudBackupConfigPath()), 0o755); err != nil {
|
|
return err
|
|
}
|
|
config = normalizeCloudBackupConfig(config)
|
|
// Credential markers are derived from the OS keyring and must never become
|
|
// persisted state. They are populated only by CloudBackupGetConfig.
|
|
config.HasWebDAVCredential = false
|
|
config.HasS3Credential = false
|
|
config.HasEncryptionKey = false
|
|
payload, err := json.MarshalIndent(cloudBackupPersisted{SchemaVersion: cloudBackupConfigSchemaVersion, Config: config}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporary, err := os.CreateTemp(filepath.Dir(a.cloudBackupConfigPath()), ".cloud-backup-*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer func() {
|
|
_ = temporary.Close()
|
|
_ = os.Remove(temporaryPath)
|
|
}()
|
|
if _, err := temporary.Write(payload); err != nil {
|
|
return err
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temporaryPath, a.cloudBackupConfigPath())
|
|
}
|
|
|
|
func (a *App) updateCloudBackupState(update func(*CloudBackupConfig) error) (CloudBackupConfig, error) {
|
|
a.cloudBackupStateMu.Lock()
|
|
defer a.cloudBackupStateMu.Unlock()
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
if err := update(&config); err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
if err := a.saveCloudBackupStateUnlocked(config); err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
return config, nil
|
|
}
|
|
|
|
func (a *App) saveCloudBackupSecrets(secrets cloudBackupSecrets) error {
|
|
a.cloudBackupSecretMu.Lock()
|
|
defer a.cloudBackupSecretMu.Unlock()
|
|
|
|
values := []struct {
|
|
id string
|
|
value any
|
|
}{
|
|
{cloudBackupWebDAVSecretID, cloudBackupWebDAVSecrets{Username: secrets.WebDAVUsername, Password: secrets.WebDAVPassword}},
|
|
{cloudBackupS3SecretID, cloudBackupS3Secrets{AccessKey: secrets.S3AccessKey, SecretKey: secrets.S3SecretKey}},
|
|
{cloudBackupEncryptionID, cloudBackupEncryptionSecret{Password: secrets.EncryptionPassword}},
|
|
}
|
|
entries := make([]cloudBackupSecretSnapshot, 0, len(values)+1)
|
|
for _, value := range values {
|
|
ref, err := cloudBackupSecretRef(value.id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.Marshal(value.value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries = append(entries, cloudBackupSecretSnapshot{ref: ref, payload: payload})
|
|
}
|
|
legacyRef, err := cloudBackupSecretRef(cloudBackupLegacySecretID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
snapshots := make([]cloudBackupSecretSnapshot, 0, len(entries)+1)
|
|
for _, entry := range append(entries, cloudBackupSecretSnapshot{ref: legacyRef}) {
|
|
payload, getErr := a.secretStore.Get(entry.ref)
|
|
if getErr != nil && !os.IsNotExist(getErr) {
|
|
return getErr
|
|
}
|
|
snapshots = append(snapshots, cloudBackupSecretSnapshot{ref: entry.ref, payload: payload, exists: getErr == nil})
|
|
}
|
|
rollback := func(saveErr error) error {
|
|
if rollbackErr := a.restoreCloudBackupSecretSnapshots(snapshots); rollbackErr != nil {
|
|
return fmt.Errorf("%w (secret rollback failed: %v)", saveErr, rollbackErr)
|
|
}
|
|
return saveErr
|
|
}
|
|
for _, entry := range entries {
|
|
if err := a.secretStore.Put(entry.ref, entry.payload); err != nil {
|
|
return rollback(err)
|
|
}
|
|
}
|
|
if err := a.secretStore.Delete(legacyRef); err != nil && !os.IsNotExist(err) {
|
|
return rollback(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) restoreCloudBackupSecretSnapshots(snapshots []cloudBackupSecretSnapshot) error {
|
|
var firstErr error
|
|
for index := len(snapshots) - 1; index >= 0; index-- {
|
|
snapshot := snapshots[index]
|
|
var err error
|
|
if snapshot.exists {
|
|
err = a.secretStore.Put(snapshot.ref, snapshot.payload)
|
|
} else {
|
|
err = a.secretStore.Delete(snapshot.ref)
|
|
if os.IsNotExist(err) {
|
|
err = nil
|
|
}
|
|
}
|
|
if err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (a *App) cloudBackupView(config CloudBackupConfig) CloudBackupConfig {
|
|
config = normalizeCloudBackupConfig(config)
|
|
return config
|
|
}
|
|
|
|
func (a *App) CloudBackupGetConfig() (CloudBackupConfig, error) {
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
secrets, err := a.loadCloudBackupSecrets()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
config.HasWebDAVCredential = cloudBackupHasWebDAVCredential(secrets)
|
|
config.HasS3Credential = cloudBackupHasS3Credential(secrets)
|
|
config.HasEncryptionKey = strings.TrimSpace(secrets.EncryptionPassword) != ""
|
|
return a.cloudBackupView(config), nil
|
|
}
|
|
|
|
func cloudBackupHasWebDAVCredential(secrets cloudBackupSecrets) bool {
|
|
return strings.TrimSpace(secrets.WebDAVUsername) != "" && strings.TrimSpace(secrets.WebDAVPassword) != ""
|
|
}
|
|
|
|
func cloudBackupHasS3Credential(secrets cloudBackupSecrets) bool {
|
|
return strings.TrimSpace(secrets.S3AccessKey) != "" && strings.TrimSpace(secrets.S3SecretKey) != ""
|
|
}
|
|
|
|
func cloudBackupHasProviderCredential(provider string, secrets cloudBackupSecrets) bool {
|
|
if provider == CloudBackupProviderS3 {
|
|
return cloudBackupHasS3Credential(secrets)
|
|
}
|
|
return cloudBackupHasWebDAVCredential(secrets)
|
|
}
|
|
|
|
func clearCloudBackupWebDAVState(config *CloudBackupConfig) {
|
|
config.WebDAVLastSyncAt = ""
|
|
config.WebDAVLastSyncSuccess = false
|
|
config.WebDAVLastSyncError = ""
|
|
config.WebDAVRemoteAvailable = false
|
|
config.WebDAVRemoteUpdatedAt = ""
|
|
}
|
|
|
|
func clearCloudBackupS3State(config *CloudBackupConfig) {
|
|
config.S3LastSyncAt = ""
|
|
config.S3LastSyncSuccess = false
|
|
config.S3LastSyncError = ""
|
|
config.S3RemoteAvailable = false
|
|
config.S3RemoteUpdatedAt = ""
|
|
}
|
|
|
|
func applyCloudBackupConfigInput(previous CloudBackupConfig, input CloudBackupConfigInput) CloudBackupConfig {
|
|
previous = normalizeCloudBackupConfig(previous)
|
|
config := previous
|
|
config.Enabled = input.Enabled
|
|
config.Provider = input.Provider
|
|
config.WebDAVEndpoint = input.WebDAVEndpoint
|
|
config.WebDAVFilePath = input.WebDAVFilePath
|
|
config.S3Endpoint = input.S3Endpoint
|
|
config.S3Bucket = input.S3Bucket
|
|
config.S3Region = input.S3Region
|
|
config.S3ObjectKey = input.S3ObjectKey
|
|
config.Schedule = input.Schedule
|
|
if input.BackupCategories != nil {
|
|
config.BackupCategories = append([]string(nil), input.BackupCategories...)
|
|
}
|
|
config = normalizeCloudBackupConfig(config)
|
|
if previous.WebDAVEndpoint != config.WebDAVEndpoint || previous.WebDAVFilePath != config.WebDAVFilePath {
|
|
clearCloudBackupWebDAVState(&config)
|
|
}
|
|
if previous.S3Endpoint != config.S3Endpoint || previous.S3Bucket != config.S3Bucket || previous.S3Region != config.S3Region || previous.S3ObjectKey != config.S3ObjectKey {
|
|
clearCloudBackupS3State(&config)
|
|
}
|
|
return config
|
|
}
|
|
|
|
func (a *App) SaveCloudBackupConfig(input CloudBackupConfigInput) (CloudBackupConfig, error) {
|
|
previousConfig, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
config := applyCloudBackupConfigInput(previousConfig, input)
|
|
secrets, err := a.loadCloudBackupSecrets()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
previousSecrets := secrets
|
|
if input.ClearRemoteSecret || input.ClearWebDAVCredential {
|
|
secrets.WebDAVUsername, secrets.WebDAVPassword = "", ""
|
|
}
|
|
if input.ClearRemoteSecret || input.ClearS3Credential {
|
|
secrets.S3AccessKey, secrets.S3SecretKey = "", ""
|
|
}
|
|
if input.ClearEncryptionKey {
|
|
secrets.EncryptionPassword = ""
|
|
}
|
|
if strings.TrimSpace(input.WebDAVUsername) != "" {
|
|
secrets.WebDAVUsername = strings.TrimSpace(input.WebDAVUsername)
|
|
}
|
|
if strings.TrimSpace(input.WebDAVPassword) != "" {
|
|
secrets.WebDAVPassword = input.WebDAVPassword
|
|
}
|
|
if strings.TrimSpace(input.S3AccessKey) != "" {
|
|
secrets.S3AccessKey = strings.TrimSpace(input.S3AccessKey)
|
|
}
|
|
if strings.TrimSpace(input.S3SecretKey) != "" {
|
|
secrets.S3SecretKey = input.S3SecretKey
|
|
}
|
|
if strings.TrimSpace(input.EncryptionPassword) != "" {
|
|
secrets.EncryptionPassword = input.EncryptionPassword
|
|
}
|
|
if len(config.BackupCategories) == 0 {
|
|
return CloudBackupConfig{}, errors.New("select at least one cloud backup category")
|
|
}
|
|
if config.Enabled {
|
|
if err := a.secretStore.HealthCheck(); err != nil {
|
|
return CloudBackupConfig{}, fmt.Errorf("OS keyring unavailable: %w", err)
|
|
}
|
|
if !cloudBackupHasProviderCredential(config.Provider, secrets) {
|
|
return CloudBackupConfig{}, errors.New("cloud backup remote credentials are required")
|
|
}
|
|
if strings.TrimSpace(secrets.EncryptionPassword) == "" {
|
|
return CloudBackupConfig{}, errors.New("cloud backup encryption password is required")
|
|
}
|
|
}
|
|
if err := a.saveCloudBackupSecrets(secrets); err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
if _, err := a.updateCloudBackupState(func(current *CloudBackupConfig) error {
|
|
*current = applyCloudBackupConfigInput(*current, input)
|
|
return nil
|
|
}); err != nil {
|
|
if rollbackErr := a.saveCloudBackupSecrets(previousSecrets); rollbackErr != nil {
|
|
return CloudBackupConfig{}, fmt.Errorf("save cloud backup configuration failed: %w (secret rollback failed: %v)", err, rollbackErr)
|
|
}
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
a.restartCloudBackupScheduler()
|
|
current, err := a.CloudBackupGetConfig()
|
|
if err != nil {
|
|
return CloudBackupConfig{}, err
|
|
}
|
|
// Saving settings must not wait for a remote request. Immediate schedules
|
|
// are handled by the existing asynchronous dirty-state sync path.
|
|
a.markCloudBackupDirty()
|
|
return current, nil
|
|
}
|
|
|
|
func (a *App) buildCloudBackupPayload(config CloudBackupConfig) ([]byte, error) {
|
|
selected := make(map[string]struct{}, len(config.BackupCategories))
|
|
for _, category := range normalizeCloudBackupCategories(config.BackupCategories) {
|
|
selected[category] = struct{}{}
|
|
}
|
|
if len(selected) == 0 {
|
|
return nil, errors.New("select at least one cloud backup category")
|
|
}
|
|
connections := connectionPackagePayload{}
|
|
if _, ok := selected[CloudBackupCategoryConnections]; ok {
|
|
var err error
|
|
connections, err = a.buildConnectionPackagePayload(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
files, err := a.collectCloudBackupFiles(selected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := cloudBackupPayload{SchemaVersion: cloudBackupPayloadSchemaVersion, CreatedAt: time.Now().UTC().Format(time.RFC3339), Connections: connections, Files: files}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
func (a *App) collectCloudBackupFiles(selected map[string]struct{}) ([]cloudBackupFile, error) {
|
|
root := strings.TrimSpace(a.configDir)
|
|
if root == "" {
|
|
root = resolveAppConfigDir()
|
|
}
|
|
paths := []string{"ai_config.json", "global_proxy.json", "daily_secrets.json", "saved_queries.json", "update_channel.json"}
|
|
files := make([]cloudBackupFile, 0, len(paths))
|
|
var total int64
|
|
for _, name := range paths {
|
|
category, categoryErr := cloudBackupRestoreCategoryForFile(name)
|
|
if categoryErr != nil {
|
|
return nil, categoryErr
|
|
}
|
|
if _, ok := selected[category]; !ok {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, name))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
total += int64(len(data))
|
|
if total > cloudBackupMaxFileBytes {
|
|
return nil, errors.New("cloud backup files exceed size limit")
|
|
}
|
|
files = append(files, cloudBackupFile{Path: name, Data: data})
|
|
}
|
|
if _, ok := selected[CloudBackupCategorySavedQueries]; !ok {
|
|
return files, nil
|
|
}
|
|
savedQueryDir, err := appdata.ResolveSavedQueryDirectory(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := filepath.WalkDir(savedQueryDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil || entry == nil || entry.IsDir() {
|
|
return walkErr
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
total += int64(len(data))
|
|
if total > cloudBackupMaxFileBytes {
|
|
return errors.New("cloud backup files exceed size limit")
|
|
}
|
|
relative, relErr := filepath.Rel(savedQueryDir, path)
|
|
if relErr != nil {
|
|
return relErr
|
|
}
|
|
files = append(files, cloudBackupFile{Path: filepath.ToSlash(filepath.Join("saved_queries", relative)), Data: data})
|
|
return nil
|
|
}); err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (a *App) cloudBackupRemote(config CloudBackupConfig, secrets cloudBackupSecrets) (cloudbackup.Remote, error) {
|
|
remoteConfig := cloudbackup.RemoteConfig{Provider: config.Provider}
|
|
credentials := cloudbackup.Credentials{}
|
|
if config.Provider == CloudBackupProviderS3 {
|
|
remoteConfig.Endpoint = config.S3Endpoint
|
|
remoteConfig.Bucket = config.S3Bucket
|
|
remoteConfig.Region = config.S3Region
|
|
remoteConfig.ObjectKey = config.S3ObjectKey
|
|
credentials.AccessKey = secrets.S3AccessKey
|
|
credentials.SecretKey = secrets.S3SecretKey
|
|
} else {
|
|
remoteConfig.Endpoint = config.WebDAVEndpoint
|
|
remoteConfig.ObjectKey = config.WebDAVFilePath
|
|
credentials.Username = secrets.WebDAVUsername
|
|
credentials.Password = secrets.WebDAVPassword
|
|
}
|
|
return cloudbackup.NewRemote(remoteConfig, credentials, newHTTPClientWithGlobalProxy(45*time.Second))
|
|
}
|
|
|
|
func (a *App) CloudBackupSyncNow() (CloudBackupStatus, error) {
|
|
return a.cloudBackupSync(context.Background())
|
|
}
|
|
|
|
func (a *App) cloudBackupSync(parent context.Context) (CloudBackupStatus, error) {
|
|
a.cloudBackupSyncMu.Lock()
|
|
defer a.cloudBackupSyncMu.Unlock()
|
|
_, dirtyRevision := a.cloudBackupDirtyState()
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return CloudBackupStatus{}, err
|
|
}
|
|
secrets, err := a.loadCloudBackupProviderSecrets(config.Provider)
|
|
if err != nil {
|
|
return CloudBackupStatus{}, err
|
|
}
|
|
dirty, _ := a.cloudBackupDirtyState()
|
|
status := a.cloudBackupStatusFromConfig(config, dirty)
|
|
if !config.Enabled {
|
|
return status, errors.New("cloud backup is disabled")
|
|
}
|
|
remote, err := a.cloudBackupRemote(config, secrets)
|
|
if err != nil {
|
|
return a.recordCloudBackupFailure(config, err)
|
|
}
|
|
payload, err := a.buildCloudBackupPayload(config)
|
|
if err != nil {
|
|
return a.recordCloudBackupFailure(config, err)
|
|
}
|
|
envelope, err := cloudbackup.Encrypt(payload, secrets.EncryptionPassword)
|
|
if err != nil {
|
|
return a.recordCloudBackupFailure(config, err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, 45*time.Second)
|
|
defer cancel()
|
|
metadata, err := remote.Put(ctx, envelope)
|
|
if err != nil {
|
|
return a.recordCloudBackupFailure(config, err)
|
|
}
|
|
stateUpdated := false
|
|
updatedConfig, stateErr := a.updateCloudBackupState(func(current *CloudBackupConfig) error {
|
|
if !cloudBackupDestinationMatches(*current, config) {
|
|
return nil
|
|
}
|
|
current.setCloudBackupSyncSuccess(config.Provider, metadata)
|
|
stateUpdated = true
|
|
return nil
|
|
})
|
|
if stateErr != nil {
|
|
return CloudBackupStatus{}, stateErr
|
|
}
|
|
if stateUpdated {
|
|
a.clearCloudBackupDirty(dirtyRevision)
|
|
}
|
|
dirty, _ = a.cloudBackupDirtyState()
|
|
return a.cloudBackupStatusFromConfig(updatedConfig, dirty), nil
|
|
}
|
|
|
|
func (a *App) recordCloudBackupFailure(config CloudBackupConfig, syncErr error) (CloudBackupStatus, error) {
|
|
if updatedConfig, err := a.updateCloudBackupState(func(current *CloudBackupConfig) error {
|
|
if cloudBackupDestinationMatches(*current, config) {
|
|
current.setCloudBackupSyncFailure(config.Provider, syncErr)
|
|
}
|
|
return nil
|
|
}); err == nil {
|
|
config = updatedConfig
|
|
}
|
|
dirty, _ := a.cloudBackupDirtyState()
|
|
return a.cloudBackupStatusFromConfig(config, dirty), syncErr
|
|
}
|
|
|
|
func (a *App) CloudBackupGetStatus() (CloudBackupStatus, error) {
|
|
config, err := a.CloudBackupGetConfig()
|
|
if err != nil {
|
|
return CloudBackupStatus{}, err
|
|
}
|
|
dirty, _ := a.cloudBackupDirtyState()
|
|
return a.cloudBackupStatusFromConfig(config, dirty), nil
|
|
}
|
|
|
|
func (a *App) cloudBackupStatusFromConfig(config CloudBackupConfig, dirty bool) CloudBackupStatus {
|
|
endpoint := config.WebDAVEndpoint
|
|
lastSyncAt, lastSyncSuccess, lastSyncError := config.WebDAVLastSyncAt, config.WebDAVLastSyncSuccess, config.WebDAVLastSyncError
|
|
remoteAvailable, remoteUpdatedAt := config.WebDAVRemoteAvailable, config.WebDAVRemoteUpdatedAt
|
|
if config.Provider == CloudBackupProviderS3 {
|
|
endpoint = config.S3Endpoint
|
|
lastSyncAt, lastSyncSuccess, lastSyncError = config.S3LastSyncAt, config.S3LastSyncSuccess, config.S3LastSyncError
|
|
remoteAvailable, remoteUpdatedAt = config.S3RemoteAvailable, config.S3RemoteUpdatedAt
|
|
}
|
|
return CloudBackupStatus{Configured: strings.TrimSpace(endpoint) != "", Enabled: config.Enabled, Provider: config.Provider, LastSyncAt: lastSyncAt, LastSyncSuccess: lastSyncSuccess, LastSyncError: lastSyncError, RemoteAvailable: remoteAvailable, RemoteUpdatedAt: remoteUpdatedAt, Dirty: dirty}
|
|
}
|
|
|
|
func cloudBackupDestinationMatches(current, operation CloudBackupConfig) bool {
|
|
if operation.Provider == CloudBackupProviderS3 {
|
|
return current.S3Endpoint == operation.S3Endpoint && current.S3Bucket == operation.S3Bucket && current.S3Region == operation.S3Region && current.S3ObjectKey == operation.S3ObjectKey
|
|
}
|
|
return current.WebDAVEndpoint == operation.WebDAVEndpoint && current.WebDAVFilePath == operation.WebDAVFilePath
|
|
}
|
|
|
|
func (config *CloudBackupConfig) setCloudBackupSyncSuccess(provider string, metadata cloudbackup.ObjectMetadata) {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if provider == CloudBackupProviderS3 {
|
|
config.S3LastSyncAt, config.S3LastSyncSuccess, config.S3LastSyncError = now, true, ""
|
|
config.S3RemoteAvailable, config.S3RemoteUpdatedAt = true, metadata.LastModified
|
|
return
|
|
}
|
|
config.WebDAVLastSyncAt, config.WebDAVLastSyncSuccess, config.WebDAVLastSyncError = now, true, ""
|
|
config.WebDAVRemoteAvailable, config.WebDAVRemoteUpdatedAt = true, metadata.LastModified
|
|
}
|
|
|
|
func (config *CloudBackupConfig) setCloudBackupSyncFailure(provider string, syncErr error) {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if provider == CloudBackupProviderS3 {
|
|
config.S3LastSyncAt, config.S3LastSyncSuccess, config.S3LastSyncError = now, false, syncErr.Error()
|
|
return
|
|
}
|
|
config.WebDAVLastSyncAt, config.WebDAVLastSyncSuccess, config.WebDAVLastSyncError = now, false, syncErr.Error()
|
|
}
|
|
|
|
func (config *CloudBackupConfig) setCloudBackupRemoteAvailability(provider string, available bool, updatedAt string) {
|
|
if provider == CloudBackupProviderS3 {
|
|
config.S3RemoteAvailable, config.S3RemoteUpdatedAt = available, updatedAt
|
|
return
|
|
}
|
|
config.WebDAVRemoteAvailable, config.WebDAVRemoteUpdatedAt = available, updatedAt
|
|
}
|
|
|
|
func (a *App) recordCloudBackupRemoteAvailability(operation CloudBackupConfig, available bool, updatedAt string) {
|
|
_, _ = a.updateCloudBackupState(func(current *CloudBackupConfig) error {
|
|
if cloudBackupDestinationMatches(*current, operation) {
|
|
current.setCloudBackupRemoteAvailability(operation.Provider, available, updatedAt)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (a *App) CloudBackupListRestorePoints() ([]CloudBackupRestorePoint, error) {
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
secrets, err := a.loadCloudBackupProviderSecrets(config.Provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remote, err := a.cloudBackupRemote(config, secrets)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
metadata, err := remote.Head(ctx)
|
|
if err != nil {
|
|
a.recordCloudBackupRemoteAvailability(config, false, "")
|
|
return nil, err
|
|
}
|
|
a.recordCloudBackupRemoteAvailability(config, true, metadata.LastModified)
|
|
objectKey := config.WebDAVFilePath
|
|
if config.Provider == CloudBackupProviderS3 {
|
|
objectKey = config.S3ObjectKey
|
|
}
|
|
return []CloudBackupRestorePoint{{ObjectKey: objectKey, LastModified: metadata.LastModified, Size: metadata.Size, ETag: metadata.ETag}}, nil
|
|
}
|
|
|
|
func (a *App) readCloudBackupPayload() (cloudBackupPayload, error) {
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil {
|
|
return cloudBackupPayload{}, err
|
|
}
|
|
secrets, err := a.loadCloudBackupProviderSecrets(config.Provider)
|
|
if err != nil {
|
|
return cloudBackupPayload{}, err
|
|
}
|
|
remote, err := a.cloudBackupRemote(config, secrets)
|
|
if err != nil {
|
|
return cloudBackupPayload{}, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
envelope, metadata, err := remote.Get(ctx)
|
|
if err != nil {
|
|
a.recordCloudBackupRemoteAvailability(config, false, "")
|
|
return cloudBackupPayload{}, err
|
|
}
|
|
a.recordCloudBackupRemoteAvailability(config, true, metadata.LastModified)
|
|
plain, err := cloudbackup.Decrypt(envelope, secrets.EncryptionPassword)
|
|
if err != nil {
|
|
return cloudBackupPayload{}, err
|
|
}
|
|
var payload cloudBackupPayload
|
|
if err := json.Unmarshal(plain, &payload); err != nil || payload.SchemaVersion != cloudBackupPayloadSchemaVersion {
|
|
return cloudBackupPayload{}, errors.New("unsupported cloud backup payload")
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func (a *App) CloudBackupPreviewRestore() (CloudBackupRestorePreview, error) {
|
|
payload, err := a.readCloudBackupPayload()
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
preview, err := buildCloudBackupRestorePreview(payload, nil)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
preview.ConfirmationToken, err = a.issueCloudBackupRestoreConfirmationToken(payload)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
return preview, nil
|
|
}
|
|
|
|
func (a *App) CloudBackupRestore(request CloudBackupRestoreRequest) (CloudBackupRestorePreview, error) {
|
|
payload, err := a.readCloudBackupPayload()
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
restoreConnections, files, selectedCategories, err := selectCloudBackupRestorePayload(payload, request.Categories)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
resultPreview, err := buildCloudBackupRestorePreview(payload, selectedCategories)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
|
|
settingsFiles := make([]cloudBackupFile, 0, len(files))
|
|
savedQueryFiles := make([]cloudBackupFile, 0, len(files))
|
|
for _, file := range files {
|
|
category, categoryErr := cloudBackupRestoreCategoryForFile(file.Path)
|
|
if categoryErr != nil {
|
|
return CloudBackupRestorePreview{}, categoryErr
|
|
}
|
|
if category == CloudBackupCategorySavedQueries {
|
|
savedQueryFiles = append(savedQueryFiles, file)
|
|
continue
|
|
}
|
|
settingsFiles = append(settingsFiles, file)
|
|
}
|
|
|
|
var savedQueryPayload connection.SavedQueryImportPayload
|
|
if len(savedQueryFiles) > 0 {
|
|
savedQueryPayload, err = buildCloudBackupSavedQueryImportPayload(savedQueryFiles)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
for _, item := range payload.Connections.Connections {
|
|
savedQueryPayload.LegacyConnections = append(savedQueryPayload.LegacyConnections, newSavedConnectionInputFromPackageItem(item))
|
|
}
|
|
}
|
|
|
|
var connectionSnapshot cloudBackupConnectionFilesSnapshot
|
|
if restoreConnections {
|
|
connectionSnapshot, err = a.captureCloudBackupConnectionFilesSnapshot()
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
settingsFiles, err = a.preserveLocalOnlyConnectionSecrets(settingsFiles, payload.Connections)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
}
|
|
if err := a.consumeCloudBackupRestoreConfirmationToken(request.ConfirmationToken, payload); err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
|
|
rollbackSettings := func() error { return nil }
|
|
if len(settingsFiles) > 0 {
|
|
rollbackSettings, err = a.restoreCloudBackupFiles(settingsFiles)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
}
|
|
rollbackMutations := func() error {
|
|
var rollbackErr error
|
|
if restoreConnections {
|
|
rollbackErr = errors.Join(rollbackErr, connectionSnapshot.restore(a))
|
|
}
|
|
return errors.Join(rollbackErr, rollbackSettings())
|
|
}
|
|
if restoreConnections {
|
|
if _, err := a.importConnectionPackagePayload(payload.Connections); err != nil {
|
|
if rollbackErr := rollbackMutations(); rollbackErr != nil {
|
|
return CloudBackupRestorePreview{}, fmt.Errorf("restore connections failed: %w (rollback failed: %v)", err, rollbackErr)
|
|
}
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
}
|
|
if len(savedQueryFiles) > 0 {
|
|
currentConnections, listErr := a.savedConnectionRepository().List()
|
|
if listErr != nil {
|
|
if rollbackErr := rollbackMutations(); rollbackErr != nil {
|
|
return CloudBackupRestorePreview{}, fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", listErr, rollbackErr)
|
|
}
|
|
return CloudBackupRestorePreview{}, listErr
|
|
}
|
|
if _, err := a.savedQueryRepository().Import(savedQueryPayload, currentConnections); err != nil {
|
|
if rollbackErr := rollbackMutations(); rollbackErr != nil {
|
|
return CloudBackupRestorePreview{}, fmt.Errorf("restore saved queries failed: %w (rollback failed: %v)", err, rollbackErr)
|
|
}
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
}
|
|
|
|
a.markCloudBackupDirty()
|
|
return resultPreview, nil
|
|
}
|
|
|
|
func buildCloudBackupRestorePreview(payload cloudBackupPayload, selectedCategories map[string]struct{}) (CloudBackupRestorePreview, error) {
|
|
fileGroups := make(map[string][]cloudBackupFile)
|
|
for _, file := range payload.Files {
|
|
category, err := cloudBackupRestoreCategoryForFile(file.Path)
|
|
if err != nil {
|
|
return CloudBackupRestorePreview{}, err
|
|
}
|
|
fileGroups[category] = append(fileGroups[category], file)
|
|
}
|
|
|
|
preview := CloudBackupRestorePreview{CreatedAt: payload.CreatedAt}
|
|
for _, categoryID := range cloudBackupCategoryOrder {
|
|
if selectedCategories != nil {
|
|
if _, selected := selectedCategories[categoryID]; !selected {
|
|
continue
|
|
}
|
|
}
|
|
category := CloudBackupCategory{ID: categoryID}
|
|
if categoryID == CloudBackupCategoryConnections {
|
|
category.ItemCount = len(payload.Connections.Connections)
|
|
if category.ItemCount == 0 {
|
|
continue
|
|
}
|
|
preview.ConnectionCount = category.ItemCount
|
|
category.Connections = make([]CloudBackupConnectionSummary, 0, category.ItemCount)
|
|
for _, item := range payload.Connections.Connections {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" {
|
|
id = strings.TrimSpace(item.Config.ID)
|
|
}
|
|
category.Connections = append(category.Connections, CloudBackupConnectionSummary{
|
|
ID: id,
|
|
Name: strings.TrimSpace(item.Name),
|
|
Host: strings.TrimSpace(item.Config.Host),
|
|
})
|
|
}
|
|
} else {
|
|
files := fileGroups[categoryID]
|
|
if len(files) == 0 {
|
|
continue
|
|
}
|
|
category.ItemCount = len(files)
|
|
category.Files = make([]string, 0, len(files))
|
|
for _, file := range files {
|
|
category.Files = append(category.Files, file.Path)
|
|
preview.Files = append(preview.Files, file.Path)
|
|
}
|
|
sort.Strings(category.Files)
|
|
category.RestartRequired = cloudBackupRestoreRequiresRestart(files)
|
|
preview.FileCount += len(files)
|
|
preview.RestartRequired = preview.RestartRequired || category.RestartRequired
|
|
}
|
|
preview.Categories = append(preview.Categories, category)
|
|
}
|
|
sort.Strings(preview.Files)
|
|
return preview, nil
|
|
}
|
|
|
|
func selectCloudBackupRestorePayload(payload cloudBackupPayload, requested []string) (bool, []cloudBackupFile, map[string]struct{}, error) {
|
|
if len(requested) == 0 {
|
|
return false, nil, nil, errors.New("select at least one cloud backup category to restore")
|
|
}
|
|
availablePreview, err := buildCloudBackupRestorePreview(payload, nil)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
available := make(map[string]struct{}, len(availablePreview.Categories))
|
|
for _, category := range availablePreview.Categories {
|
|
available[category.ID] = struct{}{}
|
|
}
|
|
|
|
selected := make(map[string]struct{}, len(requested))
|
|
for _, rawCategory := range requested {
|
|
category := strings.TrimSpace(rawCategory)
|
|
if _, ok := available[category]; !ok {
|
|
return false, nil, nil, fmt.Errorf("cloud backup category is unavailable: %s", rawCategory)
|
|
}
|
|
selected[category] = struct{}{}
|
|
}
|
|
|
|
files := make([]cloudBackupFile, 0, len(payload.Files))
|
|
for _, file := range payload.Files {
|
|
category, err := cloudBackupRestoreCategoryForFile(file.Path)
|
|
if err != nil {
|
|
return false, nil, nil, err
|
|
}
|
|
if _, ok := selected[category]; ok {
|
|
files = append(files, file)
|
|
}
|
|
}
|
|
_, restoreConnections := selected[CloudBackupCategoryConnections]
|
|
return restoreConnections, files, selected, nil
|
|
}
|
|
|
|
func buildCloudBackupSavedQueryImportPayload(files []cloudBackupFile) (connection.SavedQueryImportPayload, error) {
|
|
var metadata []byte
|
|
sqlFiles := make(map[string][]byte)
|
|
for _, file := range files {
|
|
clean := filepath.Clean(filepath.FromSlash(file.Path))
|
|
if clean == savedQueriesFileName {
|
|
if metadata != nil {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("duplicate backup file: %s", file.Path)
|
|
}
|
|
metadata = append([]byte(nil), file.Data...)
|
|
continue
|
|
}
|
|
if filepath.Dir(clean) != "saved_queries" || !strings.EqualFold(filepath.Ext(clean), ".sql") {
|
|
continue
|
|
}
|
|
fileName, err := normalizeSavedQueryDiskFileName(filepath.Base(clean))
|
|
if err != nil {
|
|
return connection.SavedQueryImportPayload{}, err
|
|
}
|
|
key := savedQuerySQLFileNameKey(fileName)
|
|
if _, exists := sqlFiles[key]; exists {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("duplicate saved query sql file: %s", file.Path)
|
|
}
|
|
sqlFiles[key] = append([]byte(nil), file.Data...)
|
|
}
|
|
if metadata == nil {
|
|
return connection.SavedQueryImportPayload{}, errors.New("saved query backup metadata is missing")
|
|
}
|
|
|
|
var diskFile savedQueriesDiskFile
|
|
if err := json.Unmarshal(metadata, &diskFile); err != nil {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("decode saved query backup metadata: %w", err)
|
|
}
|
|
if diskFile.Version < 0 || diskFile.Version > savedQueriesFormatVersion {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("unsupported saved query backup version: %d", diskFile.Version)
|
|
}
|
|
queries := make([]connection.SavedQuery, 0, len(diskFile.Queries))
|
|
seenIDs := make(map[string]struct{}, len(diskFile.Queries))
|
|
for index, record := range diskFile.Queries {
|
|
queryID := strings.TrimSpace(record.ID)
|
|
if queryID == "" {
|
|
return connection.SavedQueryImportPayload{}, errors.New("saved query backup contains an empty query id")
|
|
}
|
|
if _, exists := seenIDs[queryID]; exists {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("saved query backup contains a duplicate query id: %s", queryID)
|
|
}
|
|
seenIDs[queryID] = struct{}{}
|
|
|
|
sqlText := record.LegacySQL
|
|
if strings.TrimSpace(record.FileName) != "" {
|
|
fileName, err := normalizeSavedQueryDiskFileName(record.FileName)
|
|
if err != nil {
|
|
return connection.SavedQueryImportPayload{}, err
|
|
}
|
|
content, exists := sqlFiles[savedQuerySQLFileNameKey(fileName)]
|
|
if !exists {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("saved query sql file is missing: %s", fileName)
|
|
}
|
|
sqlText = string(content)
|
|
}
|
|
query, ok := sanitizeSavedQuery(savedQueryFromDiskRecord(record, sqlText), index, false)
|
|
if !ok {
|
|
return connection.SavedQueryImportPayload{}, fmt.Errorf("saved query backup contains an invalid query: %s", queryID)
|
|
}
|
|
queries = append(queries, query)
|
|
}
|
|
if err := validateSavedQueryGroupsQueryIDs(diskFile.Groups, queries); err != nil {
|
|
return connection.SavedQueryImportPayload{}, err
|
|
}
|
|
return connection.SavedQueryImportPayload{
|
|
Queries: queries,
|
|
Groups: append([]connection.SavedQueryGroup(nil), diskFile.Groups...),
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) captureCloudBackupConnectionFilesSnapshot() (cloudBackupConnectionFilesSnapshot, error) {
|
|
repo := a.savedConnectionRepository()
|
|
snapshot := cloudBackupConnectionFilesSnapshot{}
|
|
var err error
|
|
snapshot.connectionsData, snapshot.connectionsExists, err = readOptionalFile(repo.connectionsPath())
|
|
if err != nil {
|
|
return cloudBackupConnectionFilesSnapshot{}, err
|
|
}
|
|
snapshot.dailySecretsData, snapshot.dailySecretsExists, err = readOptionalFile(repo.dailySecrets().Path())
|
|
if err != nil {
|
|
return cloudBackupConnectionFilesSnapshot{}, err
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func (snapshot cloudBackupConnectionFilesSnapshot) restore(a *App) error {
|
|
repo := a.savedConnectionRepository()
|
|
return errors.Join(
|
|
restoreCloudBackupOptionalFile(repo.connectionsPath(), snapshot.connectionsExists, snapshot.connectionsData, 0o644),
|
|
restoreCloudBackupOptionalFile(repo.dailySecrets().Path(), snapshot.dailySecretsExists, snapshot.dailySecretsData, 0o600),
|
|
)
|
|
}
|
|
|
|
func (a *App) preserveLocalOnlyConnectionSecrets(files []cloudBackupFile, connections connectionPackagePayload) ([]cloudBackupFile, error) {
|
|
remoteConnectionIDs := make(map[string]struct{}, len(connections.Connections))
|
|
for _, item := range connections.Connections {
|
|
id := strings.TrimSpace(item.ID)
|
|
if id == "" {
|
|
id = strings.TrimSpace(item.Config.ID)
|
|
}
|
|
if id != "" {
|
|
remoteConnectionIDs[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
localSecrets, err := a.savedConnectionRepository().dailySecrets().Load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := append([]cloudBackupFile(nil), files...)
|
|
for index := range result {
|
|
clean := filepath.Clean(filepath.FromSlash(result[index].Path))
|
|
if clean != "daily_secrets.json" {
|
|
continue
|
|
}
|
|
var remoteSecrets dailysecret.File
|
|
if err := json.Unmarshal(result[index].Data, &remoteSecrets); err != nil {
|
|
return nil, fmt.Errorf("decode daily secrets backup: %w", err)
|
|
}
|
|
if remoteSecrets.Connections == nil {
|
|
remoteSecrets.Connections = make(map[string]dailysecret.ConnectionBundle)
|
|
}
|
|
for id, bundle := range localSecrets.Connections {
|
|
if _, restored := remoteConnectionIDs[id]; restored {
|
|
continue
|
|
}
|
|
if _, suppliedByBackup := remoteSecrets.Connections[id]; !suppliedByBackup {
|
|
remoteSecrets.Connections[id] = bundle
|
|
}
|
|
}
|
|
data, err := json.MarshalIndent(remoteSecrets, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[index].Data = data
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func restoreCloudBackupOptionalFile(path string, exists bool, data []byte, mode os.FileMode) error {
|
|
if !exists {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return writeCloudBackupFile(path, data, mode)
|
|
}
|
|
|
|
func cloudBackupRestoreCategoryForFile(path string) (string, error) {
|
|
clean := filepath.Clean(filepath.FromSlash(path))
|
|
switch {
|
|
case clean == "saved_queries.json", filepath.Dir(clean) == "saved_queries", strings.HasPrefix(clean, "saved_queries"+string(os.PathSeparator)):
|
|
return CloudBackupCategorySavedQueries, nil
|
|
case clean == "ai_config.json":
|
|
return CloudBackupCategoryAISettings, nil
|
|
case clean == "global_proxy.json":
|
|
return CloudBackupCategoryProxySettings, nil
|
|
case clean == "daily_secrets.json":
|
|
return CloudBackupCategoryDailySecrets, nil
|
|
case clean == "update_channel.json":
|
|
return CloudBackupCategoryUpdateSettings, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported backup file: %s", path)
|
|
}
|
|
}
|
|
|
|
func cloudBackupPayloadHash(payload cloudBackupPayload) (string, error) {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
return string(digest[:]), nil
|
|
}
|
|
|
|
func (a *App) issueCloudBackupRestoreConfirmationToken(payload cloudBackupPayload) (string, error) {
|
|
payloadHash, err := cloudBackupPayloadHash(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build cloud backup restore confirmation: %w", err)
|
|
}
|
|
token := uuid.NewString()
|
|
now := time.Now()
|
|
ttl := a.cloudBackupRestoreTokenTTL
|
|
if ttl <= 0 {
|
|
ttl = defaultCloudBackupRestoreConfirmationTokenTTL
|
|
}
|
|
|
|
a.cloudBackupRestoreTokenMu.Lock()
|
|
defer a.cloudBackupRestoreTokenMu.Unlock()
|
|
if a.cloudBackupRestoreTokens == nil {
|
|
a.cloudBackupRestoreTokens = make(map[string]cloudBackupRestoreConfirmationToken)
|
|
}
|
|
a.pruneExpiredCloudBackupRestoreConfirmationTokensLocked(now)
|
|
a.cloudBackupRestoreTokens[token] = cloudBackupRestoreConfirmationToken{
|
|
payloadHash: payloadHash,
|
|
expiresAt: now.Add(ttl),
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (a *App) consumeCloudBackupRestoreConfirmationToken(rawToken string, payload cloudBackupPayload) error {
|
|
token := strings.TrimSpace(rawToken)
|
|
if token == "" {
|
|
return errors.New("cloud backup restore requires a preview confirmation token")
|
|
}
|
|
payloadHash, err := cloudBackupPayloadHash(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("validate cloud backup restore confirmation: %w", err)
|
|
}
|
|
now := time.Now()
|
|
|
|
a.cloudBackupRestoreTokenMu.Lock()
|
|
if a.cloudBackupRestoreTokens == nil {
|
|
a.cloudBackupRestoreTokens = make(map[string]cloudBackupRestoreConfirmationToken)
|
|
}
|
|
entry, ok := a.cloudBackupRestoreTokens[token]
|
|
if ok {
|
|
delete(a.cloudBackupRestoreTokens, token)
|
|
}
|
|
a.pruneExpiredCloudBackupRestoreConfirmationTokensLocked(now)
|
|
a.cloudBackupRestoreTokenMu.Unlock()
|
|
|
|
if !ok || !entry.expiresAt.After(now) || subtle.ConstantTimeCompare([]byte(entry.payloadHash), []byte(payloadHash)) != 1 {
|
|
return errors.New("cloud backup restore confirmation is invalid or expired; preview the backup again")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) pruneExpiredCloudBackupRestoreConfirmationTokensLocked(now time.Time) {
|
|
for token, entry := range a.cloudBackupRestoreTokens {
|
|
if !entry.expiresAt.After(now) {
|
|
delete(a.cloudBackupRestoreTokens, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cloudBackupRestoreRequiresRestart(files []cloudBackupFile) bool {
|
|
for _, file := range files {
|
|
clean := filepath.Clean(filepath.FromSlash(file.Path))
|
|
if clean == "ai_config.json" || clean == "global_proxy.json" || clean == "daily_secrets.json" || clean == "update_channel.json" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a *App) restoreCloudBackupFiles(files []cloudBackupFile) (func() error, error) {
|
|
root := strings.TrimSpace(a.configDir)
|
|
if root == "" {
|
|
root = resolveAppConfigDir()
|
|
}
|
|
savedQueryDir, err := appdata.ResolveSavedQueryDirectory(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
targets := make([]cloudBackupRestoreTarget, 0, len(files))
|
|
seenTargets := make(map[string]struct{}, len(files))
|
|
for _, file := range files {
|
|
clean := filepath.Clean(filepath.FromSlash(file.Path))
|
|
var target string
|
|
baseDir := root
|
|
mode := os.FileMode(0o644)
|
|
switch {
|
|
case clean == "ai_config.json" || clean == "global_proxy.json" || clean == "saved_queries.json" || clean == "update_channel.json":
|
|
target = filepath.Join(root, clean)
|
|
case clean == "daily_secrets.json":
|
|
target = filepath.Join(root, clean)
|
|
mode = 0o600
|
|
case filepath.Dir(clean) == "saved_queries" || strings.HasPrefix(clean, "saved_queries"+string(os.PathSeparator)):
|
|
relative := strings.TrimPrefix(clean, "saved_queries"+string(os.PathSeparator))
|
|
if relative == "" || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
|
|
return nil, fmt.Errorf("unsupported backup file: %s", file.Path)
|
|
}
|
|
target = filepath.Join(savedQueryDir, relative)
|
|
baseDir = savedQueryDir
|
|
default:
|
|
return nil, fmt.Errorf("unsupported backup file: %s", file.Path)
|
|
}
|
|
rel, relErr := filepath.Rel(baseDir, target)
|
|
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
|
return nil, fmt.Errorf("unsupported backup file: %s", file.Path)
|
|
}
|
|
if _, ok := seenTargets[target]; ok {
|
|
return nil, fmt.Errorf("duplicate backup file: %s", file.Path)
|
|
}
|
|
seenTargets[target] = struct{}{}
|
|
targets = append(targets, cloudBackupRestoreTarget{target: target, data: append([]byte(nil), file.Data...), mode: mode})
|
|
}
|
|
|
|
snapshots := make([]cloudBackupFileSnapshot, 0, len(targets))
|
|
for _, target := range targets {
|
|
info, statErr := os.Stat(target.target)
|
|
if statErr == nil {
|
|
if info.IsDir() {
|
|
return nil, fmt.Errorf("backup target is a directory: %s", target.target)
|
|
}
|
|
data, readErr := os.ReadFile(target.target)
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
snapshots = append(snapshots, cloudBackupFileSnapshot{target: target.target, data: data, mode: info.Mode().Perm(), exists: true})
|
|
continue
|
|
}
|
|
if !os.IsNotExist(statErr) {
|
|
return nil, statErr
|
|
}
|
|
snapshots = append(snapshots, cloudBackupFileSnapshot{target: target.target})
|
|
}
|
|
|
|
rollback := func() error {
|
|
var rollbackErr error
|
|
for _, snapshot := range snapshots {
|
|
if !snapshot.exists {
|
|
if err := os.Remove(snapshot.target); err != nil && !os.IsNotExist(err) && rollbackErr == nil {
|
|
rollbackErr = err
|
|
}
|
|
continue
|
|
}
|
|
if err := writeCloudBackupFile(snapshot.target, snapshot.data, snapshot.mode); err != nil && rollbackErr == nil {
|
|
rollbackErr = err
|
|
}
|
|
}
|
|
return rollbackErr
|
|
}
|
|
|
|
for _, target := range targets {
|
|
if err := writeCloudBackupFile(target.target, target.data, target.mode); err != nil {
|
|
_ = rollback()
|
|
return nil, err
|
|
}
|
|
}
|
|
return rollback, nil
|
|
}
|
|
|
|
func writeCloudBackupFile(target string, data []byte, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(target, data, mode); err != nil {
|
|
return err
|
|
}
|
|
return os.Chmod(target, mode)
|
|
}
|
|
|
|
func (a *App) initializeCloudBackup(ctx context.Context) {
|
|
if _, err := a.CloudBackupGetConfig(); err != nil {
|
|
logger.Warnf("加载云端备份配置失败:%v", err)
|
|
}
|
|
a.restartCloudBackupScheduler()
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err == nil && config.Enabled {
|
|
go func() {
|
|
if _, checkErr := a.CloudBackupListRestorePoints(); checkErr != nil {
|
|
logger.Warnf("启动时检查云端备份失败:%v", checkErr)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (a *App) restartCloudBackupScheduler() {
|
|
a.cloudBackupSchedulerMu.Lock()
|
|
if a.cloudBackupSchedulerCancel != nil {
|
|
a.cloudBackupSchedulerCancel()
|
|
}
|
|
a.cloudBackupSchedulerCancel = nil
|
|
a.cloudBackupSchedulerMu.Unlock()
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil || !config.Enabled {
|
|
return
|
|
}
|
|
interval := cloudBackupScheduleInterval(config.Schedule)
|
|
if interval <= 0 {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
a.cloudBackupSchedulerMu.Lock()
|
|
a.cloudBackupSchedulerCancel = cancel
|
|
a.cloudBackupSchedulerMu.Unlock()
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if _, err := a.cloudBackupSync(ctx); err != nil {
|
|
logger.Warnf("自动同步云端备份失败:%v", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func cloudBackupScheduleInterval(schedule string) time.Duration {
|
|
switch schedule {
|
|
case CloudBackupSchedule10Minutes:
|
|
return 10 * time.Minute
|
|
case CloudBackupSchedule30Minutes:
|
|
return 30 * time.Minute
|
|
case CloudBackupSchedule1Hour:
|
|
return time.Hour
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (a *App) markCloudBackupDirty() {
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil || !config.Enabled {
|
|
return
|
|
}
|
|
a.cloudBackupDirtyMu.Lock()
|
|
a.cloudBackupDirty = true
|
|
a.cloudBackupDirtyRevision++
|
|
a.cloudBackupDirtyMu.Unlock()
|
|
if config.Schedule != CloudBackupScheduleImmediate {
|
|
return
|
|
}
|
|
go func() {
|
|
if _, err := a.cloudBackupSync(context.Background()); err != nil {
|
|
logger.Warnf("修改后同步云端备份失败:%v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (a *App) cloudBackupDirtyState() (bool, uint64) {
|
|
a.cloudBackupDirtyMu.Lock()
|
|
defer a.cloudBackupDirtyMu.Unlock()
|
|
return a.cloudBackupDirty, a.cloudBackupDirtyRevision
|
|
}
|
|
|
|
func (a *App) clearCloudBackupDirty(revision uint64) {
|
|
a.cloudBackupDirtyMu.Lock()
|
|
if a.cloudBackupDirtyRevision == revision {
|
|
a.cloudBackupDirty = false
|
|
}
|
|
a.cloudBackupDirtyMu.Unlock()
|
|
}
|
|
|
|
func (a *App) shutdownCloudBackup() {
|
|
a.cloudBackupSchedulerMu.Lock()
|
|
if a.cloudBackupSchedulerCancel != nil {
|
|
a.cloudBackupSchedulerCancel()
|
|
a.cloudBackupSchedulerCancel = nil
|
|
}
|
|
a.cloudBackupSchedulerMu.Unlock()
|
|
config, err := a.loadCloudBackupConfig()
|
|
if err != nil || !config.Enabled || config.Schedule != CloudBackupScheduleOnExit {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if _, err := a.cloudBackupSync(ctx); err != nil {
|
|
logger.Warnf("退出前同步云端备份失败:%v", err)
|
|
}
|
|
}
|