Files
MyGoNavi/internal/app/cloud_backup.go
AutumnNazi ee53a56183 feat(cloud-backup): 支持加密云端备份与选择性恢复
- 新增 WebDAV 与 S3 独立配置、凭据和远端状态管理
- 使用 Argon2id 与 AES-256-GCM 加密连接及配置备份
- 支持分类备份、自动同步、远端预览和一次性确认恢复
- 连接与已保存查询按 ID 合并恢复,并保留本地独有数据
- 补充前后端回归测试、六语言文案和恢复失败回滚
2026-07-27 20:20:00 +08:00

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)
}
}