diff --git a/account/accounts.go b/account/accounts.go index dd5944abe64..6d2693b566b 100644 --- a/account/accounts.go +++ b/account/accounts.go @@ -21,6 +21,7 @@ import ( gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" gethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/status-im/status-go/account/generator" gocommon "github.com/status-im/status-go/common" "github.com/status-im/status-go/eth-node/crypto" diff --git a/api/geth_backend.go b/api/geth_backend.go index 37ddfcfd545..1cc2c7cd7a4 100644 --- a/api/geth_backend.go +++ b/api/geth_backend.go @@ -146,6 +146,7 @@ func (b *GethStatusBackend) initialize() { b.statusNode.SetMultiaccountsDB(b.multiaccountsDB) b.LocalPairingStateManager = new(statecontrol.ProcessStateManager) b.LocalPairingStateManager.SetPairing(false) + b.LocalPairingStateManager.SetMessageSyncEnabled(false) } // StatusNode returns reference to node manager @@ -2773,6 +2774,16 @@ func (b *GethStatusBackend) SelectAccount(loginParams account.LoginParams) error return err } + chatAccount, err := b.accountManager.SelectedChatAccount() + if err != nil { + return err + } + + if err = b.statusNode.StartLocalBackup(chatAccount.AccountKey.PrivateKey); err != nil { + b.logger.Error("failed to start local backup", zap.Error(err)) + // we don't return the error to avoid login failure + } + return nil } @@ -2881,6 +2892,7 @@ func (b *GethStatusBackend) ExtractGroupMembershipSignatures(signaturePairs [][2 // SignGroupMembership signs a piece of data containing membership information func (b *GethStatusBackend) SignGroupMembership(content string) (string, error) { + fmt.Println("SignGroupMembership") selectedChatAccount, err := b.accountManager.SelectedChatAccount() if err != nil { return "", err diff --git a/appdatabase/migrations/sql/1753221199_add_add_backup_path_setting.up.sql b/appdatabase/migrations/sql/1753221199_add_add_backup_path_setting.up.sql new file mode 100644 index 00000000000..258024ff2f3 --- /dev/null +++ b/appdatabase/migrations/sql/1753221199_add_add_backup_path_setting.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN backup_path VARCHAR DEFAULT ''; diff --git a/appdatabase/migrations/sql/1757511667_add_messages-backup_enabled.up.sql b/appdatabase/migrations/sql/1757511667_add_messages-backup_enabled.up.sql new file mode 100644 index 00000000000..554ae1b740f --- /dev/null +++ b/appdatabase/migrations/sql/1757511667_add_messages-backup_enabled.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN messages_backup_enabled BOOLEAN DEFAULT FALSE; diff --git a/mobile/status.go b/mobile/status.go index 75ab3807451..98d54698f56 100644 --- a/mobile/status.go +++ b/mobile/status.go @@ -2429,3 +2429,37 @@ func IntendedPanic(message string) string { panic(err) }) } + +func performLocalBackup() string { + filePath, err := statusBackend.StatusNode().PerformLocalBackup() + if err != nil { + return makeJSONResponse(err) + } + + respJSON, err := json.Marshal(map[string]interface{}{ + "filePath": filePath, + }) + if err != nil { + return makeJSONResponse(err) + } + + return string(respJSON) +} + +func PerformLocalBackup() string { + return callWithResponse(performLocalBackup) +} + +func LoadLocalBackup(requestJSON string) string { + var request requests.LoadLocalBackup + err := json.Unmarshal([]byte(requestJSON), &request) + if err != nil { + return makeJSONResponse(err) + } + err = request.Validate() + if err != nil { + return makeJSONResponse(err) + } + err = statusBackend.StatusNode().LoadLocalBackup(request.FilePath) + return makeJSONResponse(err) +} diff --git a/multiaccounts/settings/columns.go b/multiaccounts/settings/columns.go index 28cc8b93c1b..1b5fff97fd6 100644 --- a/multiaccounts/settings/columns.go +++ b/multiaccounts/settings/columns.go @@ -34,6 +34,10 @@ var ( dBColumnName: "backup_fetched", valueHandler: BoolHandler, } + BackupPath = SettingField{ + reactFieldName: "backup-path", + dBColumnName: "backup_path", + } ChaosMode = SettingField{ reactFieldName: "chaos-mode?", dBColumnName: "chaos_mode", @@ -181,6 +185,11 @@ var ( reactFieldName: "log-level", dBColumnName: "log_level", } + MessagesBackupEnabled = SettingField{ + reactFieldName: "messages-backup-enabled?", + dBColumnName: "messages_backup_enabled", + valueHandler: BoolHandler, + } MessagesFromContactsOnly = SettingField{ reactFieldName: "messages-from-contacts-only", dBColumnName: "messages_from_contacts_only", @@ -548,6 +557,7 @@ var ( AutoMessageEnabled, BackupEnabled, BackupFetched, + BackupPath, Bio, ChaosMode, CollectibleGroupByCollection, @@ -577,6 +587,7 @@ var ( LinkPreviewRequestEnabled, LinkPreviewsEnabledSites, LogLevel, + MessagesBackupEnabled, MessagesFromContactsOnly, Mnemonic, MnemonicRemoved, diff --git a/multiaccounts/settings/database.go b/multiaccounts/settings/database.go index 388ca9d5ae3..cd7b51cbe87 100644 --- a/multiaccounts/settings/database.go +++ b/multiaccounts/settings/database.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "sync" "time" @@ -11,7 +12,7 @@ import ( "github.com/status-im/status-go/common/dbsetup" "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/logutils" - "github.com/status-im/status-go/multiaccounts/errors" + maErrors "github.com/status-im/status-go/multiaccounts/errors" "github.com/status-im/status-go/nodecfg" "github.com/status-im/status-go/params" "github.com/status-im/status-go/sqlite" @@ -213,7 +214,7 @@ func (db *Database) getSettingFieldFromReactName(reactName string) (SettingField return s, nil } } - return SettingField{}, errors.ErrInvalidConfig + return SettingField{}, maErrors.ErrInvalidConfig } func (db *Database) makeSelectRow(setting SettingField) *sql.Row { @@ -243,12 +244,21 @@ func (db *Database) saveSetting(setting SettingField, value interface{}) error { return err } - _, err = update.Exec(value) + result, err := update.Exec(value) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() if err != nil { return err } + if rowsAffected == 0 { + // If no rows were affected, it means the setting does not exist + return errors.New("settings not initialized, please call CreateSettings first") + } + if db.notifier != nil { db.notifier(setting, value) } @@ -334,7 +344,7 @@ func (db *Database) SaveSyncSetting(setting SettingField, value interface{}, clo return err } if clock <= ls { - return errors.ErrNewClockOlderThanCurrent + return maErrors.ErrNewClockOlderThanCurrent } err = db.SetSettingLastSynced(setting, clock) @@ -398,7 +408,8 @@ func (db *Database) GetSettings() (Settings, error) { test_networks_enabled, mutual_contact_enabled, profile_migration_needed, wallet_token_preferences_group_by_community, url_unfurling_mode, mnemonic_was_not_shown, wallet_show_community_asset_when_sending_tokens, wallet_display_assets_below_balance, wallet_display_assets_below_balance_threshold, wallet_collectible_preferences_group_by_collection, wallet_collectible_preferences_group_by_community, - peer_syncing_enabled, auto_refresh_tokens_enabled, last_tokens_update, news_feed_enabled, news_feed_last_fetched_timestamp, news_rss_enabled + peer_syncing_enabled, auto_refresh_tokens_enabled, last_tokens_update, news_feed_enabled, news_feed_last_fetched_timestamp, news_rss_enabled, backup_path, + messages_backup_enabled FROM settings WHERE @@ -488,6 +499,8 @@ func (db *Database) GetSettings() (Settings, error) { &s.NewsFeedEnabled, &newsFeedLastFetchedTimestamp, &s.NewsRSSEnabled, + &s.BackupPath, + &s.MessagesBackupEnabled, ) if err != nil { @@ -929,3 +942,19 @@ func (db *Database) NewsRSSEnabled() (result bool, err error) { } return result, err } + +func (db *Database) BackupPath() (result string, err error) { + err = db.makeSelectRow(BackupPath).Scan(&result) + if err == sql.ErrNoRows { + return result, nil + } + return result, err +} + +func (db *Database) MessagesBackupEnabled() (result bool, err error) { + err = db.makeSelectRow(MessagesBackupEnabled).Scan(&result) + if err == sql.ErrNoRows { + return result, nil + } + return result, err +} diff --git a/multiaccounts/settings/database_settings_manager.go b/multiaccounts/settings/database_settings_manager.go index 1031d3bb770..21897f65625 100644 --- a/multiaccounts/settings/database_settings_manager.go +++ b/multiaccounts/settings/database_settings_manager.go @@ -63,6 +63,8 @@ type DatabaseSettingsManager interface { CanSyncOnMobileNetwork() (result bool, err error) ShouldBroadcastUserStatus() (result bool, err error) BackupEnabled() (result bool, err error) + BackupPath() (result string, err error) + MessagesBackupEnabled() (result bool, err error) AutoMessageEnabled() (result bool, err error) LastBackup() (result uint64, err error) BackupFetched() (result bool, err error) diff --git a/multiaccounts/settings/database_test.go b/multiaccounts/settings/database_test.go index 6436f095642..a0a0212e67f 100644 --- a/multiaccounts/settings/database_test.go +++ b/multiaccounts/settings/database_test.go @@ -2,10 +2,13 @@ package settings import ( "encoding/json" + "net/url" "os" "testing" "time" + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/require" "github.com/status-im/status-go/appdatabase" @@ -296,3 +299,43 @@ func TestDatabase_NewsRSSEnabled(t *testing.T) { require.NoError(t, err) require.Equal(t, false, settings.NewsRSSEnabled) } + +func TestDatabase_BackupPath(t *testing.T) { + db, stop := setupTestDB(t) + defer stop() + + require.NoError(t, db.CreateSettings(settings, config)) + + path, err := db.BackupPath() + require.NoError(t, err) + // The default backup path is empty + require.Equal(t, "", path) + + testPath, err := url.JoinPath(gofakeit.LetterN(3), gofakeit.LetterN(3)) + require.NoError(t, err) + require.NotEmpty(t, testPath) + err = db.SaveSetting(BackupPath.GetReactName(), testPath) + require.NoError(t, err) + + settings, err = db.GetSettings() + require.NoError(t, err) + require.Equal(t, testPath, settings.BackupPath) +} + +func TestDatabase_MessagesBackupEnabled(t *testing.T) { + db, stop := setupTestDB(t) + defer stop() + + require.NoError(t, db.CreateSettings(settings, config)) + + enabled, err := db.MessagesBackupEnabled() + require.NoError(t, err) + require.Equal(t, false, enabled) + + err = db.SaveSetting(MessagesBackupEnabled.GetReactName(), true) + require.NoError(t, err) + + settings, err = db.GetSettings() + require.NoError(t, err) + require.Equal(t, true, settings.MessagesBackupEnabled) +} diff --git a/multiaccounts/settings/structs.go b/multiaccounts/settings/structs.go index 9a0218fe9ab..a0a76a218cc 100644 --- a/multiaccounts/settings/structs.go +++ b/multiaccounts/settings/structs.go @@ -217,6 +217,8 @@ type Settings struct { LastBackup uint64 `json:"last-backup,omitempty"` BackupEnabled bool `json:"backup-enabled?,omitempty"` BackupFetched bool `json:"backup-fetched?,omitempty"` + BackupPath string `json:"backup-path,omitempty"` + MessagesBackupEnabled bool `json:"messages-backup-enabled?,omitempty"` AutoMessageEnabled bool `json:"auto-message-enabled?,omitempty"` GifAPIKey string `json:"gifs/api-key"` TestNetworksEnabled bool `json:"test-networks-enabled?,omitempty"` diff --git a/node/backup/controller.go b/node/backup/controller.go new file mode 100644 index 00000000000..551a7dbefbd --- /dev/null +++ b/node/backup/controller.go @@ -0,0 +1,144 @@ +package backup + +import ( + "errors" + "os" + "path/filepath" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/status-im/status-go/common" +) + +type BackupConfig struct { + PrivateKey []byte + FileNameGetter func() (string, error) + BackupEnabled bool + Interval time.Duration +} + +type BackupProvider interface { + ExportBackup() ([]byte, error) + ImportBackup(data []byte) error +} + +type Controller struct { + config BackupConfig + core *core + logger *zap.Logger + quit chan struct{} + mutex sync.Mutex + wg *sync.WaitGroup +} + +func NewController(config BackupConfig, logger *zap.Logger) (*Controller, error) { + if len(config.PrivateKey) == 0 { + return nil, errors.New("private key must be provided") + } + if config.FileNameGetter == nil { + return nil, errors.New("filename getter must be provided") + } + + return &Controller{ + config: config, + core: newCore(), + logger: logger, + wg: &sync.WaitGroup{}, + quit: make(chan struct{}), + }, nil +} + +func (c *Controller) Register(componentName string, provider BackupProvider) { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.core.Register(componentName, provider) +} + +func (c *Controller) Start() { + if !c.config.BackupEnabled { + return + } + c.wg.Add(1) + + go func() { + defer common.LogOnPanic() + ticker := time.NewTicker(c.config.Interval) + defer ticker.Stop() + defer c.wg.Done() + for { + select { + case <-ticker.C: + _, err := c.PerformBackup() + if err != nil { + c.logger.Error("Error performing backup: %v\n", zap.Error(err)) + } + case <-c.quit: + return + } + } + }() +} + +func (c *Controller) Stop() { + close(c.quit) + c.wg.Wait() +} + +func (c *Controller) PerformBackup() (string, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + + backupData, err := c.core.Create(c.config.PrivateKey) + if err != nil { + return "", err + } + + fileName, err := c.config.FileNameGetter() + if err != nil { + return "", err + } + + if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil { + return "", err + } + + file, err := os.Create(fileName) + if err != nil { + return "", err + } + defer file.Close() + + _, err = file.Write(backupData) + if err != nil { + return "", err + } + + return fileName, nil +} + +func (c *Controller) LoadBackup(filePath string) error { + c.mutex.Lock() + defer c.mutex.Unlock() + + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return err + } + + backupData := make([]byte, fileInfo.Size()) + _, err = file.Read(backupData) + if err != nil { + return err + } + + return c.core.Restore(c.config.PrivateKey, backupData) +} diff --git a/node/backup/controller_test.go b/node/backup/controller_test.go new file mode 100644 index 00000000000..7fd0ae4bdea --- /dev/null +++ b/node/backup/controller_test.go @@ -0,0 +1,94 @@ +package backup + +import ( + "encoding/json" + "encoding/xml" + "reflect" + "testing" + + "github.com/brianvoe/gofakeit/v6" + + "go.uber.org/zap" + + "github.com/stretchr/testify/require" +) + +type Foo struct { + Value int + PreciseValue float64 +} + +type Bar struct { + Names []string + Surname string +} + +type FooProvider struct { + foo Foo +} + +func (f FooProvider) ExportBackup() ([]byte, error) { + return json.Marshal(f.foo) +} + +var fooFromBackup Foo + +func (b FooProvider) ImportBackup(data []byte) error { + return json.Unmarshal(data, &fooFromBackup) +} + +type BarProvider struct { + bar Bar +} + +func (b BarProvider) ExportBackup() ([]byte, error) { + return xml.Marshal(b.bar) +} + +var barFromBackup Bar + +func (b BarProvider) ImportBackup(data []byte) error { + return xml.Unmarshal(data, &barFromBackup) +} + +func TestController(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + filename := t.TempDir() + "/test_backup.bak" + controller, err := NewController(BackupConfig{ + FileNameGetter: func() (string, error) { return filename, nil }, + PrivateKey: []byte("0123456789abcdef0123456789abcdef"), + }, logger) + require.NoError(t, err) + + foo := Foo{} + bar := Bar{} + err = gofakeit.Struct(&foo) + require.NoError(t, err) + err = gofakeit.Struct(&bar) + require.NoError(t, err) + + fooProvider := FooProvider{ + foo: foo, + } + + barProvider := BarProvider{ + bar: bar, + } + + controller.Register("foo", fooProvider) + controller.Register("bar", barProvider) + + filename, err = controller.PerformBackup() + require.NoError(t, err) + require.Equal(t, filename, filename) + + require.False(t, reflect.DeepEqual(barProvider.bar, barFromBackup)) + require.False(t, reflect.DeepEqual(fooProvider.foo, fooFromBackup)) + + err = controller.LoadBackup(filename) + require.NoError(t, err) + + require.True(t, reflect.DeepEqual(barProvider.bar, barFromBackup)) + require.True(t, reflect.DeepEqual(fooProvider.foo, fooFromBackup)) +} diff --git a/node/backup/core.go b/node/backup/core.go new file mode 100644 index 00000000000..52187618136 --- /dev/null +++ b/node/backup/core.go @@ -0,0 +1,115 @@ +package backup + +import ( + "bytes" + "encoding/gob" + "errors" + "fmt" + + "github.com/status-im/status-go/eth-node/crypto" +) + +type core struct { + backupProviders map[string]BackupProvider +} + +func newCore() *core { + return &core{ + backupProviders: make(map[string]BackupProvider), + } +} + +func (c *core) Register( + componentName string, + provider BackupProvider, +) { + c.backupProviders[componentName] = provider +} + +func (b *core) Create(privateKey []byte) ([]byte, error) { + backup, err := b.exportBackup() + if err != nil { + return nil, fmt.Errorf("exportBackup failed: %w", err) + } + + data, err := marshal(backup) + if err != nil { + return nil, fmt.Errorf("marshal failed: %w", err) + } + + encryptedData, err := crypto.EncryptSymmetric(privateKey, data) + if err != nil { + return nil, fmt.Errorf("encrypt failed: %w", err) + } + + return encryptedData, nil +} + +func (b *core) Restore(privateKey []byte, encrypted []byte) error { + decrypted, err := crypto.DecryptSymmetric(privateKey, encrypted) + if err != nil { + return fmt.Errorf("decrypt failed: %w", err) + } + + data, err := unmarshal(decrypted) + if err != nil { + return fmt.Errorf("unmarshal failed: %w", err) + } + + err = b.importBackup(data) + if err != nil { + return fmt.Errorf("importBackup failed: %w", err) + } + + return nil +} + +func (b *core) exportBackup() (map[string][]byte, error) { + result := make(map[string][]byte, len(b.backupProviders)) + + for name, provider := range b.backupProviders { + raw, err := provider.ExportBackup() + if err != nil { + return nil, err + } + result[name] = raw + } + + return result, nil +} + +func (b *core) importBackup(data map[string][]byte) error { + var errs []error + for name, provider := range b.backupProviders { + raw, ok := data[name] + if !ok { + continue + } + if err := provider.ImportBackup(raw); err != nil { + errs = append(errs, fmt.Errorf("importBackup %q failed: %w", name, err)) + } + } + + return errors.Join(errs...) +} + +func marshal(data map[string][]byte) ([]byte, error) { + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + err := enc.Encode(data) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func unmarshal(data []byte) (map[string][]byte, error) { + buf := bytes.NewReader(data) + dec := gob.NewDecoder(buf) + var result map[string][]byte + err := dec.Decode(&result) + if err != nil { + return nil, err + } + return result, nil +} diff --git a/node/get_status_node.go b/node/get_status_node.go index f29da238200..4fb1461cd53 100644 --- a/node/get_status_node.go +++ b/node/get_status_node.go @@ -2,11 +2,14 @@ package node import ( "context" + "crypto/ecdsa" "database/sql" "errors" + "fmt" "os" "path/filepath" "sync" + "time" "go.uber.org/zap" @@ -15,9 +18,12 @@ import ( "github.com/ethereum/go-ethereum/node" "github.com/status-im/status-go/account" + devicescommon "github.com/status-im/status-go/common" "github.com/status-im/status-go/connection" + "github.com/status-im/status-go/eth-node/crypto" "github.com/status-im/status-go/ipfs" "github.com/status-im/status-go/multiaccounts" + "github.com/status-im/status-go/node/backup" "github.com/status-im/status-go/params" "github.com/status-im/status-go/rpc" "github.com/status-im/status-go/server" @@ -41,6 +47,7 @@ import ( "github.com/status-im/status-go/services/stickers" "github.com/status-im/status-go/services/subscriptions" "github.com/status-im/status-go/services/updates" + "github.com/status-im/status-go/services/utils" "github.com/status-im/status-go/services/wakuv2ext" "github.com/status-im/status-go/services/wallet" "github.com/status-im/status-go/services/web3provider" @@ -117,6 +124,8 @@ type StatusNode struct { walletFeed event.Feed networksFeed event.Feed settingsFeed event.Feed + + localBackup *backup.Controller } // New makes new instance of StatusNode. @@ -216,6 +225,94 @@ func (n *StatusNode) StartWithOptions(config *params.NodeConfig, options StartOp return n.startWithDB(config, options.AccountsManager) } +func (n *StatusNode) StartLocalBackup(privateKey *ecdsa.PrivateKey) error { + if n.localBackup != nil { + return errors.New("local backup already started") + } + + backupPath, err := n.accountsSrvc.GetBackupPath() + if err != nil { + return err + } + if backupPath == "" { + // No path set yet, set it to the user's config directory + dir, err := os.UserConfigDir() + // We do not return the error as it's not a major issue + if err != nil { + n.logger.Error("failed to get user config dir", zap.Error(err)) + } else { + err = n.accountsSrvc.SetBackupPath(filepath.Join(dir, "Status", "backups")) + if err != nil { + n.logger.Error("failed to set backup path", zap.Error(err)) + } + } + } + + filenameGetter := func() (string, error) { + backupPath, err := n.accountsSrvc.GetBackupPath() + if err != nil { + return "", err + } + + compressedPubKey, err := utils.SerializePublicKey(crypto.CompressPubkey(&privateKey.PublicKey)) + if err != nil { + return "", err + } + + var backupDir string + if backupPath != "" { + backupDir = backupPath + } else { + // If we still have an issue, hardcode to known paths + if devicescommon.OperatingSystemIs(devicescommon.AndroidPlatform) { + backupDir = "/storage/emulated/0/Documents/Status/Backups" + } else if devicescommon.OperatingSystemIs(devicescommon.IOSPlatform) { + backupDir = filepath.Join("/Documents", "Status", "Backups") + } else { + return "", err + } + } + + fullPath := filepath.Join(backupDir, fmt.Sprintf("%s_user_data.bkp", compressedPubKey[len(compressedPubKey)-6:])) + + return fullPath, nil + } + + n.localBackup, err = backup.NewController(backup.BackupConfig{ + PrivateKey: crypto.Keccak256(crypto.FromECDSA(privateKey)), + FileNameGetter: filenameGetter, + BackupEnabled: true, + Interval: time.Minute * 30, + }, n.logger.Named("LocalBackup")) + if err != nil { + return err + } + + if n.accountsSrvc != nil { + n.localBackup.Register("settings", n.accountsSrvc) + } + + if n.walletSrvc != nil { + n.localBackup.Register("wallet", n.walletSrvc) + } + + if n.statusPublicSrvc != nil { + n.localBackup.Register("messenger", n.statusPublicSrvc.Messenger()) + } + + n.localBackup.Start() + + return nil +} + +func (n *StatusNode) PerformLocalBackup() (string, error) { + return n.localBackup.PerformBackup() +} + +func (n *StatusNode) LoadLocalBackup(filePath string) error { + return n.localBackup.LoadBackup(filePath) +} + func (n *StatusNode) SetMediaServerEnableTLS(enableTLS *bool) { n.mediaServerEnableTLS = enableTLS } @@ -304,6 +401,11 @@ func (n *StatusNode) Stop() error { return ErrNoRunningNode } + if n.localBackup != nil { + n.localBackup.Stop() + n.localBackup = nil + } + return n.stop() } diff --git a/params/defaults.go b/params/defaults.go index 047c205443f..51b4dead82c 100644 --- a/params/defaults.go +++ b/params/defaults.go @@ -8,6 +8,7 @@ const ( ArchivesRelativePath = "data/archivedata" TorrentTorrentsRelativePath = "data/torrents" + BackupsRelativePath = "backups" // SendTransactionMethodName https://docs.walletconnect.com/advanced/rpc-reference/ethereum-rpc#eth_sendtransaction SendTransactionMethodName = "eth_sendTransaction" diff --git a/protocol/common/message_test.go b/protocol/common/message_test.go index e8b779e1552..40d301d2a4a 100644 --- a/protocol/common/message_test.go +++ b/protocol/common/message_test.go @@ -3,7 +3,7 @@ package common import ( "encoding/base64" "encoding/json" - "io/ioutil" + "io" "os" "testing" @@ -22,7 +22,7 @@ func TestPrepareContentImage(t *testing.T) { require.NoError(t, err) defer file.Close() - payload, err := ioutil.ReadAll(file) + payload, err := io.ReadAll(file) require.NoError(t, err) message := NewMessage() @@ -42,7 +42,7 @@ func TestPrepareContentAudio(t *testing.T) { require.NoError(t, err) defer file.Close() - payload, err := ioutil.ReadAll(file) + payload, err := io.ReadAll(file) require.NoError(t, err) message := NewMessage() diff --git a/protocol/communities/manager.go b/protocol/communities/manager.go index 9c3dbd41f2c..1c679893e9d 100644 --- a/protocol/communities/manager.go +++ b/protocol/communities/manager.go @@ -85,6 +85,7 @@ var ( ErrTorrentTimedout = errors.New("torrent has timed out") ErrCommunityRequestAlreadyRejected = errors.New("that user was already rejected from the community") ErrInvalidClock = errors.New("invalid clock to cancel request to join") + ErrNotPartOfCommunity = errors.New("not part of the community") ) type Manager struct { @@ -3734,6 +3735,11 @@ func (m *Manager) LeaveCommunity(id types.HexBytes) (*Community, error) { return nil, err } + if !community.Joined() && !community.Spectated() { + // If we are not joined or spectating, there is nothing to leave + return nil, ErrNotPartOfCommunity + } + community.RemoveOurselvesFromOrg(&m.identity.PublicKey) community.Leave() diff --git a/protocol/communities_messenger_test.go b/protocol/communities_messenger_test.go index 0157ccbd544..f65cb51aa7e 100644 --- a/protocol/communities_messenger_test.go +++ b/protocol/communities_messenger_test.go @@ -2162,6 +2162,24 @@ func (s *MessengerCommunitiesSuite) TestLeaveAndRejoinCommunity() { s.Require().Equal(1, numberInactiveChats) } +func (s *MessengerCommunitiesSuite) TestLeaveCommunityTwice() { + community, _ := s.createCommunity() + advertiseCommunityToUserOldWay(&s.Suite, community, s.owner, s.alice) + + _, err := s.alice.SpectateCommunity(community.ID()) + s.Require().NoError(err) + + response, err := s.alice.LeaveCommunity(community.ID()) + s.Require().NoError(err) + s.Require().NotNil(response) + + // Try to leave again + response, err = s.alice.LeaveCommunity(community.ID()) + s.Require().Error(err) + s.Require().Equal(communities.ErrNotPartOfCommunity, err) + s.Require().Nil(response) +} + func (s *MessengerCommunitiesSuite) TestShareCommunity() { description := &requests.CreateCommunity{ Membership: protobuf.CommunityPermissions_MANUAL_ACCEPT, @@ -3145,6 +3163,46 @@ func (s *MessengerCommunitiesSuite) TestSyncCommunity_OutdatedDescription() { s.Require().False(community.Spectated()) } +func (s *MessengerCommunitiesSuite) TestSyncCommunity_LeftCommunity() { + community, _ := s.createCommunity() + s.advertiseCommunityTo(community, s.owner, s.alice) + + // Join community + s.joinCommunity(community, s.owner, s.alice) + + // Update owner's community reference + community, err := s.owner.GetCommunityByID(community.ID()) + s.Require().NoError(err) + s.Require().True(community.HasMember(s.alice.IdentityPublicKey())) + + // Leave community + response, err := s.alice.LeaveCommunity(community.ID()) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().False(response.Communities()[0].Joined()) + + // Create another device + aliceOtherDevice := s.createOtherDevice(s.alice) + defer TearDownMessenger(&s.Suite, aliceOtherDevice) + + // Create sync message + syncCommunityMsg, err := s.alice.buildSyncInstallationCommunity(response.Communities()[0], 1) + s.Require().NoError(err) + s.Require().False(syncCommunityMsg.Joined) + s.Require().False(syncCommunityMsg.Spectated) + + // Then make other device handle sync message with outdated community description + messageState := aliceOtherDevice.buildMessageState() + err = aliceOtherDevice.handleSyncInstallationCommunity(messageState, syncCommunityMsg) + s.Require().NoError(err) + + // Then community should not be joined + community, err = aliceOtherDevice.communitiesManager.GetByID(community.ID()) + s.Require().NoError(err) + s.Require().False(community.Joined()) + s.Require().False(community.Spectated()) +} + func (s *MessengerCommunitiesSuite) TestSetMutePropertyOnChatsByCategory() { // Create a community createCommunityReq := &requests.CreateCommunity{ diff --git a/protocol/message_persistence.go b/protocol/message_persistence.go index 017ded46834..b9d32ecaf45 100644 --- a/protocol/message_persistence.go +++ b/protocol/message_persistence.go @@ -12,6 +12,8 @@ import ( "github.com/golang/protobuf/proto" "github.com/lib/pq" + "github.com/status-im/markdown" + "github.com/status-im/status-go/protocol/common" "github.com/status-im/status-go/protocol/protobuf" ) @@ -122,6 +124,38 @@ func (db sqlitePersistence) tableUserMessagesAllFields() string { payment_requests` } +func (db sqlitePersistence) tableUserMessagesProtobufFields() string { + return ` + m1.id, + m1.whisper_timestamp, + m1.clock_value, + m1.text, + m1.source, + m1.response_to, + m1.local_chat_id, + m1.message_type, + m1.content_type, + + m1.sticker_pack, + m1.sticker_hash, + + m1.image_payload, + m1.image_type, + + COALESCE(m1.album_id, ""), + COALESCE(m1.album_images_count, 0), + COALESCE(m1.image_width, 0), + COALESCE(m1.image_height, 0), + + m1.links, + m1.unfurled_links, + m1.unfurled_status_links, + + m1.payment_requests, + + pm.pinned_by` +} + // keep the same order as in tableUserMessagesScanAllFields func (db sqlitePersistence) tableUserMessagesAllFieldsJoin() string { return `m1.id, @@ -526,6 +560,92 @@ func (db sqlitePersistence) tableUserMessagesScanAllFields(row scanner, message return nil } +// keep the same order as in tableUserMessagesProtobufFields +func (db sqlitePersistence) tableUserMessagesScanProtobufFields(row scanner, message *protobuf.BackedUpMessage, others ...interface{}) error { + + sticker := &protobuf.StickerMessage{} + image := &protobuf.ImageMessage{} + var serializedLinks []byte + var serializedUnfurledLinks []byte + var serializedPaymentRequests []byte + var serializedUnfurledStatusLinks []byte + var pinnedBy sql.NullString + + args := []interface{}{ + &message.Id, + &message.Timestamp, + &message.Clock, + &message.Text, + &message.From, // source in table + &message.ResponseTo, + &message.ChatId, + &message.MessageType, + &message.ContentType, + + &sticker.Pack, + &sticker.Hash, + + &image.Payload, + &image.Format, + + &image.AlbumId, + &image.AlbumImagesCount, + &image.Width, + &image.Height, + + &serializedLinks, + &serializedUnfurledLinks, + &serializedUnfurledStatusLinks, + + &serializedPaymentRequests, + + &pinnedBy, + } + err := row.Scan(append(args, others...)...) + if err != nil { + return err + } + + if serializedUnfurledLinks != nil { + err = json.Unmarshal(serializedUnfurledLinks, &message.UnfurledLinks) + if err != nil { + return err + } + } + + if serializedUnfurledStatusLinks != nil { + // use proto.Marshal, because json.Marshal doesn't support `oneof` fields + var links protobuf.UnfurledStatusLinks + err = proto.Unmarshal(serializedUnfurledStatusLinks, &links) + if err != nil { + return err + } + message.UnfurledStatusLinks = &links + } + + if serializedPaymentRequests != nil { + err := json.Unmarshal(serializedPaymentRequests, &message.PaymentRequests) + if err != nil { + return err + } + } + + if pinnedBy.Valid { + message.PinnedBy = pinnedBy.String + } + + switch message.ContentType { + case int64(protobuf.ChatMessage_STICKER): + message.Payload = &protobuf.BackedUpMessage_Sticker{Sticker: sticker} + + case int64(protobuf.ChatMessage_IMAGE): + message.Payload = &protobuf.BackedUpMessage_Image{Image: image} + + } + + return nil +} + func (db sqlitePersistence) tableUserMessagesAllValues(message *common.Message) ([]interface{}, error) { var gapFrom, gapTo uint32 @@ -1335,6 +1455,229 @@ func (db sqlitePersistence) MessageByChatIDs(chatIDs []string, currCursor string return result, newCursor, nil } +func (db sqlitePersistence) AllMessagesForBackup() ([]*protobuf.BackedUpMessage, error) { + where := "WHERE NOT(m1.hide)" + fields := db.tableUserMessagesProtobufFields() + selectQuery := `SELECT %s + FROM user_messages m1 + LEFT JOIN pin_messages pm + ON m1.id = pm.message_id AND pm.pinned = 1` + query := fmt.Sprintf(selectQuery, fields) + " " + where + rows, err := db.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []*protobuf.BackedUpMessage + for rows.Next() { + message := &protobuf.BackedUpMessage{} + if err := db.tableUserMessagesScanProtobufFields(rows, message); err != nil { + return nil, err + } + + messages = append(messages, message) + } + + return messages, nil +} + +func (db sqlitePersistence) saveBackedUpMessages(messages []*protobuf.BackedUpMessage) error { + if len(messages) == 0 { + return nil + } + + tx, err := db.db.BeginTx(context.Background(), &sql.TxOptions{}) + if err != nil { + return err + } + defer func() { + if err == nil { + err = tx.Commit() + return + } + // don't shadow original error + _ = tx.Rollback() + }() + + allFields := db.tableUserMessagesAllFields() + valuesVector := strings.Repeat("?, ", db.tableUserMessagesAllFieldsCount()-1) + "?" + query := "INSERT OR REPLACE INTO user_messages(" + allFields + ") VALUES (" + valuesVector + ")" //nolint: gosec + stmt, err := tx.Prepare(query) + if err != nil { + return err + } + defer stmt.Close() + + for _, msg := range messages { + allValues, err := db.backedUpMessageToUserMessageValues(msg) + if err != nil { + return err + } + + _, err = stmt.Exec(allValues...) + if err != nil { + return err + } + } + + return nil +} + +func (db sqlitePersistence) saveBackedUpPinMessages(messages []*protobuf.BackedUpMessage) error { + pinMessages := make([]*common.PinMessage, 0) + + for _, msg := range messages { + if msg.PinnedBy == "" { + continue + } + + pinMessage := protobuf.PinMessage{ + Clock: msg.Timestamp, + MessageId: msg.Id, + ChatId: msg.ChatId, + MessageType: msg.MessageType, + Pinned: true, + } + pinMessages = append(pinMessages, &common.PinMessage{ + ID: msg.Id, // TODO do we need a special ID? + From: msg.PinnedBy, + PinMessage: &pinMessage, + WhisperTimestamp: msg.Timestamp, + }) + } + + if len(pinMessages) == 0 { + return nil + } + + return db.SavePinMessages(pinMessages) +} + +func (db sqlitePersistence) SaveBackedUpMessages(messages []*protobuf.BackedUpMessage) error { + if len(messages) == 0 { + return nil + } + + err := db.saveBackedUpMessages(messages) + if err != nil { + return err + } + + err = db.saveBackedUpPinMessages(messages) + if err != nil { + return err + } + + return nil +} + +func (db sqlitePersistence) backedUpMessageToUserMessageValues(message *protobuf.BackedUpMessage) ([]interface{}, error) { + parsedText := markdown.Parse([]byte(message.Text), nil) + jsonParsedText, err := json.Marshal(parsedText) + if err != nil { + return nil, err + } + + sticker := message.GetSticker() + if sticker == nil { + sticker = &protobuf.StickerMessage{} + } + + image := message.GetImage() + if image == nil { + image = &protobuf.ImageMessage{} + } + + var serializedUnfurledLinks []byte + if links := message.GetUnfurledLinks(); len(links) > 0 { + serializedUnfurledLinks, err = json.Marshal(links) + if err != nil { + return nil, err + } + } + + var serializedUnfurledStatusLinks []byte + if links := message.GetUnfurledStatusLinks(); links != nil { + // use proto.Marshal, because json.Marshal doesn't support `oneof` fields + serializedUnfurledStatusLinks, err = proto.Marshal(links) + if err != nil { + return nil, err + } + } + + var serializedPaymentRequests []byte + if paymentRequests := message.GetPaymentRequests(); len(paymentRequests) > 0 { + serializedPaymentRequests, err = json.Marshal(paymentRequests) + if err != nil { + return nil, err + } + } + + // Convert protobuf MessageType to the expected format + messageType := message.MessageType + + return []interface{}{ + message.GetId(), // id + message.GetTimestamp(), // whisper_timestamp + message.GetFrom(), // source + message.GetText(), // text + message.GetContentType(), // content_type + "", // username (alias) - not available in BackedUpMessage + message.GetTimestamp(), // timestamp + message.GetChatId(), // chat_id + message.GetChatId(), // local_chat_id (same as chat_id) // TODO this should be adapated if 1-1 + messageType, // message_type + message.GetClock(), // clock_value + false, // seen + "", // outgoing_status + jsonParsedText, // parsed_text + sticker.GetPack(), // sticker_pack + sticker.GetHash(), // sticker_hash + image.GetPayload(), // image_payload + int64(image.GetFormat()), // image_type + image.GetAlbumId(), // album_id + nil, // album_images + image.GetAlbumImagesCount(), // album_images_count + image.GetWidth(), // image_width + image.GetHeight(), // image_height + "", // image_base64 + nil, // audio_payload + 0, // audio_type + 0, // audio_duration_ms + "", // audio_base64 + "", // community_id + nil, // mentions + nil, // links + serializedUnfurledLinks, // unfurled_links + serializedUnfurledStatusLinks, // unfurled_status_links + "", // command_id + "", // command_value + "", // command_from + "", // command_address + "", // command_contract + "", // command_transaction_hash + 0, // command_state + nil, // command_signature + "", // replace + int64(0), // edited_at + false, // deleted + "", // deleted_by + false, // deleted_for_me + false, // rtl + 0, // line_count + message.GetResponseTo(), // response_to + uint32(0), // gap_from + uint32(0), // gap_to + 0, // contact_request_state + 0, // contact_verification_state + false, // mentioned + false, // replied + "", // discord_message_id + serializedPaymentRequests, // payment_requests + }, nil +} + func (db sqlitePersistence) OldestMessageWhisperTimestampByChatIDs(chatIDs []string) (map[string]uint64, error) { if len(chatIDs) == 0 { return nil, nil diff --git a/protocol/messenger.go b/protocol/messenger.go index 7d640b1bf92..8cf4c83e43d 100644 --- a/protocol/messenger.go +++ b/protocol/messenger.go @@ -3524,6 +3524,13 @@ func (m *Messenger) saveDataAndPrepareResponse(messageState *ReceivedMessageStat if ok { contactsToSave = append(contactsToSave, contact) messageState.Response.AddContact(contact) + + _, ok := m.allContacts.Load(id) + if !ok { + // If the contact is not in the allContacts map, it means it's a new contact + // and we need to add it to the allContacts map. + m.allContacts.Store(id, contact) + } } return true }) diff --git a/protocol/messenger_backup.go b/protocol/messenger_backup.go index 1be48224d10..b4043844948 100644 --- a/protocol/messenger_backup.go +++ b/protocol/messenger_backup.go @@ -25,7 +25,7 @@ var backupTickerInterval = 120 * time.Second // backupIntervalSeconds is the amount of seconds we should allow between // backups -var backupIntervalSeconds uint64 = 28800 +var backupIntervalSeconds uint64 = 57600 type CommunitySet struct { Joined []*communities.Community @@ -96,17 +96,15 @@ func (m *Messenger) BackupData(ctx context.Context) (uint64, error) { return 0, err } chatsToBackup := m.backupChats(ctx, clock) - if err != nil { - return 0, err - } + profileToBackup, err := m.backupProfile(ctx, clock) if err != nil { return 0, err } - _, settings, errors := m.prepareSyncSettingsMessages(clock, true) - if len(errors) != 0 { + _, settings, err := m.prepareSyncSettingsMessages(clock, true) + if err != nil { // return just the first error, the others have been logged - return 0, errors[0] + return 0, err } keypairsToBackup, err := m.backupKeypairs() @@ -136,7 +134,7 @@ func (m *Messenger) BackupData(ctx context.Context) (uint64, error) { }, ProfileDetails: &protobuf.FetchingBackedUpDataDetails{ DataNumber: uint32(0), - TotalNumber: uint32(len(profileToBackup)), + TotalNumber: uint32(1), // Profile is always one }, SettingsDetails: &protobuf.FetchingBackedUpDataDetails{ DataNumber: uint32(0), @@ -176,14 +174,12 @@ func (m *Messenger) BackupData(ctx context.Context) (uint64, error) { } // Update profile messages encode and dispatch - for i, d := range profileToBackup { - pb := backupDetailsOnly() - pb.ProfileDetails.DataNumber = uint32(i + 1) - pb.Profile = d.Profile - err = m.encodeAndDispatchBackupMessage(ctx, pb, chat.ID) - if err != nil { - return 0, err - } + pb := backupDetailsOnly() + pb.ProfileDetails.DataNumber = uint32(1) + pb.Profile = profileToBackup.Profile + err = m.encodeAndDispatchBackupMessage(ctx, pb, chat.ID) + if err != nil { + return 0, err } // Update chats encode and dispatch @@ -208,6 +204,7 @@ func (m *Messenger) BackupData(ctx context.Context) (uint64, error) { } } + // TODO get rid of keypairs // Update keypairs messages encode and dispatch for i, d := range keypairsToBackup { pb := backupDetailsOnly() @@ -393,6 +390,7 @@ func (m *Messenger) backupChats(ctx context.Context, clock uint64) []*protobuf.B From: membershipUpdate.From, RawPayload: membershipUpdate.RawPayload, Color: membershipUpdate.Color, + Image: membershipUpdate.Image, } } } @@ -447,7 +445,7 @@ func (m *Messenger) buildSyncContactMessage(contact *Contact) *protobuf.SyncInst } } -func (m *Messenger) backupProfile(ctx context.Context, clock uint64) ([]*protobuf.Backup, error) { +func (m *Messenger) backupProfile(ctx context.Context, clock uint64) (*protobuf.Backup, error) { displayName, err := m.settings.DisplayName() if err != nil { return nil, err @@ -515,9 +513,7 @@ func (m *Messenger) backupProfile(ctx context.Context, clock uint64) ([]*protobu }, } - backupMessages := []*protobuf.Backup{backupMessage} - - return backupMessages, nil + return backupMessage, nil } func (m *Messenger) backupKeypairs() ([]*protobuf.Backup, error) { diff --git a/protocol/messenger_backup_handler.go b/protocol/messenger_backup_handler.go index b217fa9b240..f0d4e792628 100644 --- a/protocol/messenger_backup_handler.go +++ b/protocol/messenger_backup_handler.go @@ -60,6 +60,7 @@ func (m *Messenger) HandleBackup(state *ReceivedMessageState, message *protobuf. return nil } +// TODO remove this function once we do the Waku backup removal func (m *Messenger) handleBackup(state *ReceivedMessageState, message *protobuf.Backup) []error { var errors []error @@ -85,7 +86,7 @@ func (m *Messenger) handleBackup(state *ReceivedMessageState, message *protobuf. errors = append(errors, communityErrors...) } - err = m.handleBackedUpSettings(message.Setting) + err = m.HandleBackedUpSettings(message.Setting) if err != nil { errors = append(errors, err) } @@ -95,7 +96,7 @@ func (m *Messenger) handleBackup(state *ReceivedMessageState, message *protobuf. errors = append(errors, err) } - err = m.handleWatchOnlyAccount(message.WatchOnlyAccount) + err = m.HandleWatchOnlyAccount(message.WatchOnlyAccount) if err != nil { errors = append(errors, err) } @@ -126,6 +127,41 @@ func (m *Messenger) handleBackup(state *ReceivedMessageState, message *protobuf. return errors } +func (m *Messenger) handleLocalBackup(state *ReceivedMessageState, backup *protobuf.MessengerLocalBackup) []error { + var errors []error + + err := m.handleBackedUpProfile(backup.Profile, backup.Clock) + if err != nil { + errors = append(errors, err) + } + + for _, contact := range backup.Contacts { + err = m.HandleSyncInstallationContactV2(state, contact, nil) + if err != nil { + errors = append(errors, err) + } + } + + err = m.handleSyncChats(state, backup.Chats) + if err != nil { + errors = append(errors, err) + } + + communityErrors := m.handleLocalBackupCommunities(state, backup.Communities) + if len(communityErrors) > 0 { + errors = append(errors, communityErrors...) + } + + if len(backup.Messages) > 0 { + err := m.persistence.SaveBackedUpMessages(backup.Messages) + if err != nil { + errors = append(errors, err) + } + } + + return errors +} + func (m *Messenger) updateBackupFetchProgress(message *protobuf.Backup, response *wakusync.WakuBackedUpDataResponse, state *ReceivedMessageState) error { if m.backedUpFetchingStatus == nil { return nil @@ -376,7 +412,7 @@ func (m *Messenger) handleBackedUpProfile(message *protobuf.BackedUpProfile, bac return err } -func (m *Messenger) handleBackedUpSettings(message *protobuf.SyncSetting) error { +func (m *Messenger) HandleBackedUpSettings(message *protobuf.SyncSetting) error { if message == nil { return nil } @@ -405,7 +441,7 @@ func (m *Messenger) handleBackedUpSettings(message *protobuf.SyncSetting) error m.account.Name = message.GetValueString() err = m.multiAccounts.SaveAccount(*m.account) if err != nil { - m.logger.Warn("[handleBackedUpSettings] failed to save account", zap.Error(err)) + m.logger.Warn("[HandleBackedUpSettings] failed to save account", zap.Error(err)) return nil } } @@ -456,7 +492,7 @@ func (m *Messenger) handleKeypair(message *protobuf.SyncKeypair) error { return nil } -func (m *Messenger) handleWatchOnlyAccount(message *protobuf.SyncAccount) error { +func (m *Messenger) HandleWatchOnlyAccount(message *protobuf.SyncAccount) error { if message == nil { return nil } @@ -492,6 +528,7 @@ func syncInstallationCommunitiesSet(communities []*protobuf.SyncInstallationComm return ret } +// TODO remove this function once we do the Waku backup removal func (m *Messenger) handleSyncedCommunities(state *ReceivedMessageState, message *protobuf.Backup) []error { var errors []error for _, syncCommunity := range syncInstallationCommunitiesSet(message.Communities) { @@ -500,7 +537,24 @@ func (m *Messenger) handleSyncedCommunities(state *ReceivedMessageState, message errors = append(errors, err) } - err = m.requestCommunityKeysAndSharedAddresses(state, syncCommunity) + err = m.requestCommunityKeysAndSharedAddresses(syncCommunity) + if err != nil { + errors = append(errors, err) + } + } + + return errors +} + +func (m *Messenger) handleLocalBackupCommunities(state *ReceivedMessageState, communities []*protobuf.SyncInstallationCommunity) []error { + var errors []error + for _, syncCommunity := range syncInstallationCommunitiesSet(communities) { + err := m.handleSyncInstallationCommunity(state, syncCommunity) + if err != nil { + errors = append(errors, err) + } + + err = m.requestCommunityKeysAndSharedAddresses(syncCommunity) if err != nil { errors = append(errors, err) } @@ -509,7 +563,7 @@ func (m *Messenger) handleSyncedCommunities(state *ReceivedMessageState, message return errors } -func (m *Messenger) requestCommunityKeysAndSharedAddresses(state *ReceivedMessageState, syncCommunity *protobuf.SyncInstallationCommunity) error { +func (m *Messenger) requestCommunityKeysAndSharedAddresses(syncCommunity *protobuf.SyncInstallationCommunity) error { if !syncCommunity.Joined { return nil } @@ -572,3 +626,8 @@ func (m *Messenger) requestCommunityKeysAndSharedAddresses(state *ReceivedMessag return nil } + +func (m *Messenger) HandleBackedUpMessageBatch(state *ReceivedMessageState, messageBatch *protobuf.BackedUpMessageBatch, msg *v1protocol.StatusMessage) error { + // BackedUpMessages can only be sent in the context of a local backup + return nil +} diff --git a/protocol/messenger_backup_test.go b/protocol/messenger_backup_test.go index 6067a99a310..8bbef814228 100644 --- a/protocol/messenger_backup_test.go +++ b/protocol/messenger_backup_test.go @@ -44,7 +44,6 @@ func (s *MessengerBackupSuite) TestBackupContacts() { defer TearDownMessenger(&s.Suite, bob2) // Create 2 contacts - contact1Key, err := crypto.GenerateKey() s.Require().NoError(err) contactID1 := types.EncodeHex(crypto.FromECDSAPub(&contact1Key.PublicKey)) @@ -61,6 +60,7 @@ func (s *MessengerBackupSuite) TestBackupContacts() { s.Require().Len(bob1.Contacts(), 2) + // Validate contacts actualContacts := bob1.Contacts() if actualContacts[0].ID == contactID1 { s.Require().Equal(actualContacts[0].ID, contactID1) @@ -76,7 +76,6 @@ func (s *MessengerBackupSuite) TestBackupContacts() { s.Require().True(actualContacts[1].added()) // Backup - clock, err := bob1.BackupData(context.Background()) s.Require().NoError(err) @@ -825,8 +824,7 @@ func (s *MessengerBackupSuite) TestBackupCommunities() { s.Require().NoError(err) defer TearDownMessenger(&s.Suite, bob2) - // Create a communitie - + // Create a community description := &requests.CreateCommunity{ Membership: protobuf.CommunityPermissions_AUTO_ACCEPT, Name: "status", diff --git a/protocol/messenger_communities.go b/protocol/messenger_communities.go index 55fc35851eb..c7fb97ca5fd 100644 --- a/protocol/messenger_communities.go +++ b/protocol/messenger_communities.go @@ -76,15 +76,15 @@ const ( ) const ( - ErrOwnerTokenNeeded = "Owner token is needed" // #nosec G101 - ErrMissingCommunityID = "CommunityID has to be provided" - ErrForbiddenProfileOrWatchOnlyAccount = "Cannot join a community using profile chat or watch-only account" - ErrSigningJoinRequestForKeycardAccounts = "Signing a joining community request for accounts migrated to keycard must be done with a keycard" - ErrNotPartOfCommunity = "Not part of the community" - ErrNotAdminOrOwner = "Not admin or owner" - ErrSignerIsNil = "Signer can't be nil" - ErrSyncMessagesSentByNonControlNode = "Accepted/requested to join sync messages can be send only by the control node" - ErrReceiverIsNil = "Receiver can't be nil" + ErrOwnerTokenNeeded = "owner token is needed" // #nosec G101 + ErrMissingCommunityID = "communityID has to be provided" + ErrForbiddenProfileOrWatchOnlyAccount = "cannot join a community using profile chat or watch-only account" + ErrSigningJoinRequestForKeycardAccounts = "signing a joining community request for accounts migrated to keycard must be done with a keycard" + ErrNotPartOfCommunity = "not part of the community" + ErrNotAdminOrOwner = "not admin or owner" + ErrSignerIsNil = "signer can't be nil" + ErrSyncMessagesSentByNonControlNode = "accepted/requested to join sync messages can be send only by the control node" + ErrReceiverIsNil = "receiver can't be nil" ) type FetchCommunityRequest struct { @@ -2969,10 +2969,6 @@ func (m *Messenger) ImportCommunity(ctx context.Context, key *ecdsa.PrivateKey) return nil, err } - if err != nil { - return nil, err - } - _, err = m.FetchCommunity(&FetchCommunityRequest{ CommunityKey: community.IDString(), Shard: community.Shard(), @@ -3858,7 +3854,7 @@ func (m *Messenger) handleSyncInstallationCommunity(messageState *ReceivedMessag } } else { mr, err = m.leaveCommunity(syncCommunity.Id) - if err != nil { + if err != nil && err != communities.ErrNotPartOfCommunity { logger.Debug("m.leaveCommunity error", zap.Error(err)) return err } diff --git a/protocol/messenger_group_chat_test.go b/protocol/messenger_group_chat_test.go index 49dc12f0496..c151418b62e 100644 --- a/protocol/messenger_group_chat_test.go +++ b/protocol/messenger_group_chat_test.go @@ -60,7 +60,9 @@ func makeMutualContact(origin *Messenger, contactPubkey *ecdsa.PublicKey) error return err } contact.ContactRequestLocalState = ContactRequestStateSent + contact.ContactRequestLocalClock = 1 contact.ContactRequestRemoteState = ContactRequestStateReceived + contact.ContactRequestRemoteClock = 1 origin.allContacts.Store(contact.ID, contact) return nil diff --git a/protocol/messenger_handler.go b/protocol/messenger_handler.go index c6b228978ff..89fc917e835 100644 --- a/protocol/messenger_handler.go +++ b/protocol/messenger_handler.go @@ -579,6 +579,7 @@ func (m *Messenger) handleSyncChats(messageState *ReceivedMessageState, chats [] if err != nil { return err } + messageState.AllChats.Store(chat.ID, chat) messageState.Response.AddChat(chat) } diff --git a/protocol/messenger_installations_test.go b/protocol/messenger_installations_test.go index 8becbc9f195..84920b1418e 100644 --- a/protocol/messenger_installations_test.go +++ b/protocol/messenger_installations_test.go @@ -266,7 +266,7 @@ func (s *MessengerInstallationSuite) TestSyncInstallation() { s.Require().NoError(err) // sync - err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) var allChats []*Chat diff --git a/protocol/messenger_local_backup.go b/protocol/messenger_local_backup.go new file mode 100644 index 00000000000..2721ea201c8 --- /dev/null +++ b/protocol/messenger_local_backup.go @@ -0,0 +1,90 @@ +package protocol + +import ( + "errors" + + "github.com/golang/protobuf/proto" + + "github.com/status-im/status-go/protocol/protobuf" + "github.com/status-im/status-go/signal" +) + +func (m *Messenger) backupMessages() ([]*protobuf.BackedUpMessage, error) { + messagesBackupEnabled, err := m.settings.MessagesBackupEnabled() + if err != nil { + return nil, err + } + + if !messagesBackupEnabled { + return nil, nil + } + + return m.persistence.AllMessagesForBackup() +} + +func (m *Messenger) ExportBackup() ([]byte, error) { + backup := &protobuf.MessengerLocalBackup{} + + clock, _ := m.getLastClockWithRelatedChat() + contactsToBackup := m.backupContacts(m.ctx) + communitiesToBackup, err := m.backupCommunities(m.ctx, clock) + if err != nil { + return nil, err + } + chatsToBackup := m.backupChats(m.ctx, clock) + profileToBackup, err := m.backupProfile(m.ctx, clock) + if err != nil { + return nil, err + } + + for _, d := range contactsToBackup { + backup.Contacts = append(backup.Contacts, d.Contacts...) + } + for _, d := range communitiesToBackup { + backup.Communities = append(backup.Communities, d.Communities...) + } + backup.Profile = profileToBackup.Profile + for _, d := range chatsToBackup { + backup.Chats = append(backup.Chats, d.Chats...) + } + + backupMessages, err := m.backupMessages() + if err != nil { + return nil, err + } + backup.Messages = backupMessages + + return proto.Marshal(backup) +} + +func (m *Messenger) ImportBackup(data []byte) error { + var backup protobuf.MessengerLocalBackup + err := proto.Unmarshal(data, &backup) + if err != nil { + return err + } + + state := ReceivedMessageState{ + Response: &MessengerResponse{}, + AllChats: &chatMap{}, + AllContacts: &contactMap{ + me: m.selfContact, + }, + Timesource: m.getTimesource(), + ModifiedContacts: &stringBoolMap{}, + ModifiedInstallations: &stringBoolMap{}, + } + errs := m.handleLocalBackup( + &state, + &backup, + ) + if len(errs) > 0 { + return errors.Join(errs...) + } + + response, err := m.saveDataAndPrepareResponse(&state) + + signal.SendNewMessages(response) + + return err +} diff --git a/protocol/messenger_local_backup_test.go b/protocol/messenger_local_backup_test.go new file mode 100644 index 00000000000..0fee55da1a3 --- /dev/null +++ b/protocol/messenger_local_backup_test.go @@ -0,0 +1,369 @@ +package protocol + +import ( + "context" + "io" + "os" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/status-im/status-go/eth-node/crypto" + "github.com/status-im/status-go/eth-node/types" + "github.com/status-im/status-go/multiaccounts/settings" + "github.com/status-im/status-go/protocol/common" + "github.com/status-im/status-go/protocol/protobuf" + "github.com/status-im/status-go/protocol/requests" +) + +func TestMessengerLocalBackupSuite(t *testing.T) { + suite.Run(t, new(MessengerLocalBackupSuite)) +} + +type MessengerLocalBackupSuite struct { + MessengerBaseTestSuite +} + +func makeMutualContacts(lhs *Messenger, rhs *Messenger) error { + if err := makeMutualContact(lhs, &rhs.identity.PublicKey); err != nil { + return err + } + return makeMutualContact(rhs, &lhs.identity.PublicKey) +} + +func (s *MessengerLocalBackupSuite) TestLocalBackup() { + // Create bob1 + bob1 := s.m + + // Create bob2 + bob2, err := newMessengerWithKey(s.shh, bob1.identity, s.logger, nil) + s.Require().NoError(err) + defer TearDownMessenger(&s.Suite, bob2) + + // Enable message backup on both accounts + err = bob1.settings.SaveSetting(settings.MessagesBackupEnabled.GetReactName(), true) + s.Require().NoError(err) + + err = bob2.settings.SaveSetting(settings.MessagesBackupEnabled.GetReactName(), true) + s.Require().NoError(err) + + ctx := context.Background() + + // -------------------- CONTACTS -------------------- + // Create 2 contacts + contact1Key, err := crypto.GenerateKey() + s.Require().NoError(err) + contactID1 := types.EncodeHex(crypto.FromECDSAPub(&contact1Key.PublicKey)) + + _, err = bob1.AddContact(context.Background(), &requests.AddContact{ID: contactID1}) + s.Require().NoError(err) + + contact2Key, err := crypto.GenerateKey() + s.Require().NoError(err) + contactID2 := types.EncodeHex(crypto.FromECDSAPub(&contact2Key.PublicKey)) + + _, err = bob1.AddContact(context.Background(), &requests.AddContact{ID: contactID2}) + s.Require().NoError(err) + + s.Require().Len(bob1.Contacts(), 2) + + //-------------------- COMMUNITIES -------------------- + // Create a community + description := &requests.CreateCommunity{ + Membership: protobuf.CommunityPermissions_AUTO_ACCEPT, + Name: "status", + Color: "#ffffff", + Description: "status community description", + } + response, err := bob1.CreateCommunity(description, true) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Len(response.Communities(), 1) + + // Send message on community + chatID := response.Chats()[0].ID + inputMessage := common.NewMessage() + inputMessage.ChatId = chatID + inputMessage.ContentType = protobuf.ChatMessage_TEXT_PLAIN + inputMessage.Text = "some text" + + _, err = bob1.SendChatMessage(ctx, inputMessage) + s.Require().NoError(err) + + // Pin message + pinMessage := common.NewPinMessage() + pinMessage.ChatId = chatID + pinMessage.MessageId = inputMessage.ID + pinMessage.Pinned = true + sendResponse, err := bob1.SendPinMessage(ctx, pinMessage) + s.Require().NoError(err) + s.Require().Len(sendResponse.PinMessages(), 1) + + // Send markdown message + mdMessage := common.NewMessage() + mdMessage.ChatId = chatID + mdMessage.ContentType = protobuf.ChatMessage_TEXT_PLAIN + mdMessage.Text = "some *markdown* text" + + _, err = bob1.SendChatMessage(ctx, mdMessage) + s.Require().NoError(err) + + // Send image on community + file, err := os.Open("../_assets/tests/test.jpg") + s.Require().NoError(err) + defer file.Close() + + payload, err := io.ReadAll(file) + s.Require().NoError(err) + + imageMessage := common.NewMessage() + imageMessage.ChatId = chatID + imageMessage.ContentType = protobuf.ChatMessage_IMAGE + + image := protobuf.ImageMessage{ + Payload: payload, + Format: protobuf.ImageFormat_JPEG, + Width: 1200, + Height: 1000, + AlbumId: "", + } + imageMessage.Payload = &protobuf.ChatMessage_Image{Image: &image} + imageMessage.Text = "some image" + + _, err = bob1.SendChatMessage(ctx, imageMessage) + s.Require().NoError(err) + + // Send sticker on community + stickerMessage := common.NewMessage() + stickerMessage.ChatId = chatID + stickerMessage.ContentType = protobuf.ChatMessage_STICKER + stickerMessage.Text = "some sticker" + stickerMessage.Payload = &protobuf.ChatMessage_Sticker{ + Sticker: &protobuf.StickerMessage{ + Pack: 1, + Hash: "some-hash", + }, + } + _, err = bob1.SendChatMessage(ctx, stickerMessage) + s.Require().NoError(err) + + // Send emoji on community + emojiMessage := common.NewMessage() + emojiMessage.ChatId = chatID + emojiMessage.ContentType = protobuf.ChatMessage_EMOJI + emojiMessage.Text = ":+1:" + _, err = bob1.SendChatMessage(ctx, emojiMessage) + s.Require().NoError(err) + + // Check bob2 + communities, err := bob2.Communities() + s.Require().NoError(err) + s.Require().Len(communities, 0) + + // --------------------- LEFT COMMUNITY -------------------- + // Create another community + description = &requests.CreateCommunity{ + Membership: protobuf.CommunityPermissions_MANUAL_ACCEPT, + Name: "other-status", + Color: "#fffff4", + Description: "other status community description", + } + + response, err = bob1.CreateCommunity(description, true) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Len(response.Communities(), 1) + + newCommunity := response.Communities()[0] + + // Leave community + response, err = bob1.LeaveCommunity(newCommunity.ID()) + s.Require().NoError(err) + s.Require().NotNil(response) + + // Check bob2 + communities, err = bob2.Communities() + s.Require().NoError(err) + s.Require().Len(communities, 0) + + // --------------------- CHATS -------------------- + // Create a group chat + response, err = bob1.CreateGroupChatWithMembers(context.Background(), "group", []string{}) + s.NoError(err) + s.Require().Len(response.Chats(), 1) + + ourGroupChat := response.Chats()[0] + + err = bob1.SaveChat(ourGroupChat) + s.NoError(err) + + // Create a one-to-one chat + alice := s.newMessenger() + defer TearDownMessenger(&s.Suite, alice) + + err = makeMutualContacts(bob1, alice) + s.Require().NoError(err) + + aliceContact := bob1.GetContactByID(alice.selfContact.ID) + err = bob1.persistence.SaveContact(aliceContact, nil) + s.Require().NoError(err) + + ourOneOneChat := CreateOneToOneChat("Our 1TO1", &alice.identity.PublicKey, alice.getTimesource()) + err = bob1.SaveChat(ourOneOneChat) + s.Require().NoError(err) + + theirChat := CreateOneToOneChat("Their 1TO1", &bob1.identity.PublicKey, bob1.getTimesource()) + err = alice.SaveChat(theirChat) + s.Require().NoError(err) + + // Send transaction command to Alice + transactionMessage := common.NewMessage() + transactionMessage.ChatId = ourOneOneChat.ID + transactionMessage.ContentType = protobuf.ChatMessage_TRANSACTION_COMMAND + transactionMessage.Text = "some transaction" + _, err = bob1.SendChatMessage(ctx, transactionMessage) + s.Require().NoError(err) + + // Alice sends a message to bob1 + inputMessage = buildTestMessage(*theirChat) + inputMessage.Text = "some text from alice" + sendResponse, err = alice.SendChatMessage(context.Background(), inputMessage) + s.NoError(err) + s.Require().Len(sendResponse.Messages(), 1) + + response, err = WaitOnMessengerResponse( + bob1, + func(r *MessengerResponse) bool { + return len(r.messages) > 0 && r.Messages()[0].Text == "some text from alice" + }, + "no messages", + ) + s.Require().NoError(err) + s.Require().Len(response.Chats(), 1) + s.Require().Len(response.Messages(), 1) + + // Validate contacts on bob1 + contact1 := bob1.GetContactByID(contactID1) + s.Require().NotNil(contact1) + s.Require().Equal(ContactRequestStateSent, contact1.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateNone, contact1.ContactRequestRemoteState) + s.Require().True(contact1.added()) + + contact2 := bob1.GetContactByID(contactID2) + s.Require().NotNil(contact2) + s.Require().Equal(ContactRequestStateSent, contact2.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateNone, contact2.ContactRequestRemoteState) + s.Require().True(contact2.added()) + + aliceContact = bob1.GetContactByID(alice.selfContact.ID) + s.Require().NotNil(aliceContact) + s.Require().Equal(ContactRequestStateSent, aliceContact.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateReceived, aliceContact.ContactRequestRemoteState) + s.Require().True(aliceContact.added()) + + // Check that bob2 has no contacts + s.Require().Len(bob2.Contacts(), 0) + + // -------------------- BACKUP -------------------- + // Backup + marshalledBackup, err := bob1.ExportBackup() + s.Require().NoError(err) + + // Import the backup file and process it + err = bob2.ImportBackup(marshalledBackup) + s.Require().NoError(err) + + // -------------------- VALIDATE BACKUP -------------------- + // Validate contacts on bob2 + contact1 = bob2.GetContactByID(contactID1) + s.Require().NotNil(contact1) + s.Require().Equal(ContactRequestStateSent, contact1.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateNone, contact1.ContactRequestRemoteState) + s.Require().True(contact1.added()) + + contact2 = bob2.GetContactByID(contactID2) + s.Require().NotNil(contact2) + s.Require().Equal(ContactRequestStateSent, contact2.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateNone, contact2.ContactRequestRemoteState) + s.Require().True(contact2.added()) + + aliceContact = bob2.GetContactByID(alice.selfContact.ID) + s.Require().NotNil(aliceContact) + s.Require().Equal(ContactRequestStateSent, aliceContact.ContactRequestLocalState) + s.Require().Equal(ContactRequestStateReceived, aliceContact.ContactRequestRemoteState) + s.Require().True(aliceContact.added()) + + // Validate communities on bob2 + communities, err = bob2.JoinedCommunities() + s.Require().NoError(err) + s.Require().Len(communities, 1) + + // Validate chats on bob2 + // Group chat + chat, ok := bob2.allChats.Load(ourGroupChat.ID) + s.Require().True(ok) + s.Require().Equal(ourGroupChat.Name, chat.Name) + + // One on one chat + chat, ok = bob2.allChats.Load(ourOneOneChat.ID) + s.Require().True(ok) + s.Require().True(chat.Active) + s.Require().Equal("", chat.Name) + + // Validate messages + messages, err := bob2.persistence.AllMessagesForBackup() + s.Require().NoError(err) + s.Require().Len(messages, 15) + + // Build a map for easier assertions + messageMap := make(map[string]*protobuf.BackedUpMessage) + for _, msg := range messages { + if msg.ContentType == int64(protobuf.ChatMessage_SYSTEM_MESSAGE_PINNED_MESSAGE) && msg.Text == "" { + // For system pinned message, Text is empty so we use a custom key + messageMap["systemPin"] = msg + continue + } + messageMap[msg.Text] = msg + } + + // Assert each message type exists and has expected properties + textMsg, ok := messageMap["some text"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_TEXT_PLAIN), textMsg.ContentType) + s.Require().Equal(bob2.selfContact.ID, textMsg.PinnedBy) + + mdMsg, ok := messageMap["some *markdown* text"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_TEXT_PLAIN), mdMsg.ContentType) + + imageMsg, ok := messageMap["some image"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_IMAGE), imageMsg.ContentType) + + stickerMsg, ok := messageMap["some sticker"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_STICKER), stickerMsg.ContentType) + + emojiMsg, ok := messageMap[":+1:"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_EMOJI), emojiMsg.ContentType) + + txMsg, ok := messageMap["some transaction"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_TRANSACTION_COMMAND), txMsg.ContentType) + + aliceMsg, ok := messageMap["some text from alice"] + s.Require().True(ok) + s.Require().Equal(int64(protobuf.ChatMessage_TEXT_PLAIN), aliceMsg.ContentType) + + systemPinMsg, ok := messageMap["systemPin"] + s.Require().True(ok) + s.Require().Equal("", systemPinMsg.Text) + + // Validate pinned messages + pinnedMessages, _, err := bob2.PinnedMessageByChatID(chatID, "", 10) + s.Require().NoError(err) + s.Require().Len(pinnedMessages, 1) + s.Require().Equal(bob2.selfContact.ID, pinnedMessages[0].PinnedBy) + +} diff --git a/protocol/messenger_pairing_and_syncing.go b/protocol/messenger_pairing_and_syncing.go index cf84650d73a..104c98134db 100644 --- a/protocol/messenger_pairing_and_syncing.go +++ b/protocol/messenger_pairing_and_syncing.go @@ -40,7 +40,7 @@ func (m *Messenger) EnableInstallationAndSync(request *requests.EnableInstallati return nil, err } - if err = m.SyncDevices(context.Background(), "", "", nil); err != nil { + if err = m.SyncDevices(context.Background(), "", "", false, nil); err != nil { return nil, err } @@ -157,9 +157,12 @@ func (m *Messenger) SendPairInstallation(ctx context.Context, targetInstallation } // SyncDevices sends all public chats and contacts to paired devices +// Also sends all messages if enabled // TODO remove use of photoPath in contacts -func (m *Messenger) SyncDevices(ctx context.Context, ensName, photoPath string, rawMessageHandler RawMessageHandler) (err error) { +func (m *Messenger) SyncDevices(ctx context.Context, ensName, photoPath string, messageSyncEnabled bool, rawMessageHandler RawMessageHandler) (err error) { + isLocalPairing := true if rawMessageHandler == nil { + isLocalPairing = false rawMessageHandler = m.dispatchMessage } @@ -310,6 +313,15 @@ func (m *Messenger) SyncDevices(ctx context.Context, ensName, photoPath string, return err } + // Only sync messages when enabled by the user and on local pairing + // This is to avoid as much as possible the possibility of breaching user privacy + if messageSyncEnabled && isLocalPairing { + err = m.syncMessages(ctx, rawMessageHandler) + if err != nil { + return err + } + } + return nil } @@ -450,6 +462,44 @@ func (m *Messenger) syncProfilePicturesFromDatabase(rawMessageHandler RawMessage return m.syncProfilePictures(rawMessageHandler, identityImages) } +func (m *Messenger) syncMessages(ctx context.Context, rawMessageHandler RawMessageHandler) error { + backupMessages, err := m.persistence.AllMessagesForBackup() + if err != nil { + return err + } + + // Sync messages in batches of 100 + batchSize := 100 + for i := 0; i < len(backupMessages); i += batchSize { + end := i + batchSize + if end > len(backupMessages) { + end = len(backupMessages) + } + batch := backupMessages[i:end] + batchMsg := &protobuf.BackedUpMessageBatch{Messages: batch} + + encodedMessage, err := proto.Marshal(batchMsg) + if err != nil { + return err + } + + _, chat := m.getLastClockWithRelatedChat() + rawMessage := common.RawMessage{ + LocalChatID: chat.ID, + Payload: encodedMessage, + MessageType: protobuf.ApplicationMetadataMessage_BACKED_UP_MESSAGE_BATCH, + ResendType: common.ResendTypeDataSync, + } + + _, err = rawMessageHandler(ctx, rawMessage) + if err != nil { + return err + } + } + + return nil +} + func (m *Messenger) InitInstallations() error { installations, err := m.encryptor.GetOurInstallations(&m.identity.PublicKey) if err != nil { diff --git a/protocol/messenger_sync_keycards_state_test.go b/protocol/messenger_sync_keycards_state_test.go index 2396c258a14..bb7617ea282 100644 --- a/protocol/messenger_sync_keycards_state_test.go +++ b/protocol/messenger_sync_keycards_state_test.go @@ -143,7 +143,7 @@ func (s *MessengerSyncKeycardsStateSuite) TestSyncKeycardsIfReceiverHasNoKeycard s.Require().NoError(err) // Trigger's a sync between devices - err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) // Wait for the response @@ -207,7 +207,7 @@ func (s *MessengerSyncKeycardsStateSuite) TestSyncKeycardsIfKeycardsWereDeletedO s.Require().NoError(err) // Trigger's a sync between devices - err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) // Wait for the response @@ -263,7 +263,7 @@ func (s *MessengerSyncKeycardsStateSuite) TestSyncKeycardsIfReceiverAndSenderHas s.Require().NoError(err) // Trigger's a sync between devices - err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.main.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) // Wait for the response diff --git a/protocol/messenger_sync_raw_messages.go b/protocol/messenger_sync_raw_messages.go index 3788085d81b..370c5afd153 100644 --- a/protocol/messenger_sync_raw_messages.go +++ b/protocol/messenger_sync_raw_messages.go @@ -326,6 +326,16 @@ func (m *Messenger) HandleSyncRawMessages(rawMessages []*protobuf.RawMessage) er if err != nil { return err } + case protobuf.ApplicationMetadataMessage_BACKED_UP_MESSAGE_BATCH: + var messageBatch protobuf.BackedUpMessageBatch + err := proto.Unmarshal(rawMessage.GetPayload(), &messageBatch) + if err != nil { + return err + } + err = m.persistence.SaveBackedUpMessages(messageBatch.Messages) + if err != nil { + return err + } } } response, err := m.saveDataAndPrepareResponse(state) diff --git a/protocol/messenger_sync_settings.go b/protocol/messenger_sync_settings.go index 727d1199e09..ab4413e35f7 100644 --- a/protocol/messenger_sync_settings.go +++ b/protocol/messenger_sync_settings.go @@ -3,6 +3,7 @@ package protocol import ( "context" "encoding/json" + errorsLib "errors" "go.uber.org/zap" @@ -14,10 +15,11 @@ import ( ) // syncSettings syncs all settings that are syncable -func (m *Messenger) prepareSyncSettingsMessages(currentClock uint64, prepareForBackup bool) (resultRaw []*common.RawMessage, resultSync []*protobuf.SyncSetting, errors []error) { +func (m *Messenger) prepareSyncSettingsMessages(currentClock uint64, prepareForBackup bool) (resultRaw []*common.RawMessage, resultSync []*protobuf.SyncSetting, errorResult error) { + var errors []error s, err := m.settings.GetSettings() if err != nil { - errors = append(errors, err) + errorResult = err return } @@ -36,7 +38,7 @@ func (m *Messenger) prepareSyncSettingsMessages(currentClock uint64, prepareForB clock, err := m.settings.GetSettingLastSynced(sf) if err != nil { logger.Error("m.settings.GetSettingLastSynced", zap.Error(err), zap.String("SettingField", sf.GetDBName())) - errors = append(errors, err) + errorResult = err return } if clock == 0 { @@ -55,6 +57,7 @@ func (m *Messenger) prepareSyncSettingsMessages(currentClock uint64, prepareForB resultSync = append(resultSync, sm) } } + errorResult = errorsLib.Join(errors...) return } @@ -62,11 +65,10 @@ func (m *Messenger) syncSettings(rawMessageHandler RawMessageHandler) error { logger := m.logger.Named("syncSettings") clock, _ := m.getLastClockWithRelatedChat() - rawMessages, _, errors := m.prepareSyncSettingsMessages(clock, false) + rawMessages, _, err := m.prepareSyncSettingsMessages(clock, false) - if len(errors) != 0 { - // return just the first error, the others have been logged - return errors[0] + if err != nil { + return err } for _, rm := range rawMessages { diff --git a/protocol/messenger_sync_wallets_test.go b/protocol/messenger_sync_wallets_test.go index a7a75bd049a..243f1fc6328 100644 --- a/protocol/messenger_sync_wallets_test.go +++ b/protocol/messenger_sync_wallets_test.go @@ -156,7 +156,7 @@ func (s *MessengerSyncWalletSuite) TestSyncWallets() { s.Require().Equal(len(profileKp.Accounts)+len(seedPhraseKp.Accounts)+len(privKeyKp.Accounts)+len(woAccounts), len(dbAccounts1)) // Trigger's a sync between devices - err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) err = tt.RetryWithBackOff(func() error { @@ -535,7 +535,7 @@ func (s *MessengerSyncWalletSuite) TestSyncWalletAccountOrderAfterDeletion() { s.Require().NoError(err) // Trigger's a sync between devices - err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", nil) + err = s.m.SyncDevices(context.Background(), "ens-name", "profile-image", false, nil) s.Require().NoError(err) err = tt.RetryWithBackOff(func() error { diff --git a/protocol/messenger_test.go b/protocol/messenger_test.go index de806b99843..11e438ba348 100644 --- a/protocol/messenger_test.go +++ b/protocol/messenger_test.go @@ -2416,13 +2416,13 @@ func (s *MessengerSuite) TestResendExpiredEmojis() { func buildImageWithAlbumIDMessage(chat Chat, albumID string) (*common.Message, error) { file, err := os.Open("../_assets/tests/test.jpg") - if err != err { + if err != nil { return nil, err } defer file.Close() payload, err := io.ReadAll(file) - if err != err { + if err != nil { return nil, err } diff --git a/protocol/protobuf/accounts_local_backup.proto b/protocol/protobuf/accounts_local_backup.proto new file mode 100644 index 00000000000..b09126662ef --- /dev/null +++ b/protocol/protobuf/accounts_local_backup.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +import "sync_settings.proto"; + +option go_package = "./;protobuf"; +package protobuf; + +message AccountsLocalBackup { + repeated SyncSetting settings = 1; +} diff --git a/protocol/protobuf/application_metadata_message.proto b/protocol/protobuf/application_metadata_message.proto index 4ff8a157673..e5a423e648a 100644 --- a/protocol/protobuf/application_metadata_message.proto +++ b/protocol/protobuf/application_metadata_message.proto @@ -109,5 +109,6 @@ message ApplicationMetadataMessage { COMMUNITY_TOKEN_ACTION = 88; COMMUNITY_SHARED_ADDRESSES_REQUEST = 89; COMMUNITY_SHARED_ADDRESSES_RESPONSE = 90; + BACKED_UP_MESSAGE_BATCH = 91; } } diff --git a/protocol/protobuf/messenger_local_backup.proto b/protocol/protobuf/messenger_local_backup.proto new file mode 100644 index 00000000000..0fbc9022523 --- /dev/null +++ b/protocol/protobuf/messenger_local_backup.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +import "pairing.proto"; + +option go_package = "./;protobuf"; +package protobuf; + + +message MessengerLocalBackup { + uint64 clock = 1; + + repeated SyncInstallationContactV2 contacts = 2; + repeated SyncInstallationCommunity communities = 3; + repeated SyncChat chats = 4; + BackedUpProfile profile = 5; + repeated BackedUpMessage messages = 6; +} diff --git a/protocol/protobuf/pairing.proto b/protocol/protobuf/pairing.proto index b56c927aeda..ef9685ec9f4 100644 --- a/protocol/protobuf/pairing.proto +++ b/protocol/protobuf/pairing.proto @@ -5,6 +5,8 @@ import "sync_settings.proto"; import 'application_metadata_message.proto'; import 'communities.proto'; import 'profile_showcase.proto'; +import "chat_message.proto"; +import "enums.proto"; option go_package = "./;protobuf"; package protobuf; @@ -457,3 +459,49 @@ message SyncCollectiblePreferences { bool testnet = 2; repeated CollectiblePreferences preferences = 3; } + +message BackedUpMessage { + // Lamport timestamp of the chat message + uint64 clock = 1; + + // ID generated from the sender's public key and the message payload + string id = 2; + // The public key of the sender of the message + string from = 3; + + // Unix timestamps in milliseconds, currently not used as we use whisper as + // more reliable, but here so that we don't rely on it + uint64 timestamp = 4; + // Text of the message + string text = 5; + // Id of the message that we are replying to + string response_to = 6; + // Chat id, this field is symmetric for public-chats and private group chats, + // but asymmetric in case of one-to-ones, as the sender will use the chat-id + // of the received, while the receiver will use the chat-id of the sender. + // Probably should be the concatenation of sender-pk & receiver-pk in + // alphabetical order + string chat_id = 7; + + // The type of message (public/one-to-one/private-group-chat) + MessageType message_type = 8; + // The type of the content of the message + int64 content_type = 9; + + oneof payload { + StickerMessage sticker = 10; + ImageMessage image = 11; + } + + repeated UnfurledLink unfurled_links = 12; + + UnfurledStatusLinks unfurled_status_links = 13; + + repeated PaymentRequest payment_requests = 14; + + string pinned_by = 15; +} + +message BackedUpMessageBatch { + repeated BackedUpMessage messages = 1; +} diff --git a/protocol/protobuf/service.go b/protocol/protobuf/service.go index e3052c2ea80..b1adc62c3cd 100644 --- a/protocol/protobuf/service.go +++ b/protocol/protobuf/service.go @@ -4,7 +4,7 @@ import ( "github.com/golang/protobuf/proto" ) -//go:generate protoc --go_out=. ./chat_message.proto ./application_metadata_message.proto ./membership_update_message.proto ./command.proto ./contact.proto ./pairing.proto ./push_notifications.proto ./emoji_reaction.proto ./enums.proto ./shard.proto ./group_chat_invitation.proto ./chat_identity.proto ./communities.proto ./pin_message.proto ./anon_metrics.proto ./status_update.proto ./sync_settings.proto ./contact_verification.proto ./community_update.proto ./community_shard_key.proto ./url_data.proto ./community_privileged_user_sync_message.proto ./profile_showcase.proto ./segment_message.proto +//go:generate protoc --go_out=. ./chat_message.proto ./application_metadata_message.proto ./membership_update_message.proto ./command.proto ./contact.proto ./pairing.proto ./push_notifications.proto ./emoji_reaction.proto ./enums.proto ./shard.proto ./group_chat_invitation.proto ./chat_identity.proto ./communities.proto ./pin_message.proto ./anon_metrics.proto ./status_update.proto ./sync_settings.proto ./contact_verification.proto ./community_update.proto ./community_shard_key.proto ./url_data.proto ./community_privileged_user_sync_message.proto ./profile_showcase.proto ./segment_message.proto ./messenger_local_backup.proto ./wallet_local_backup.proto ./accounts_local_backup.proto func Unmarshal(payload []byte) (*ApplicationMetadataMessage, error) { var message ApplicationMetadataMessage diff --git a/protocol/protobuf/wallet_local_backup.proto b/protocol/protobuf/wallet_local_backup.proto new file mode 100644 index 00000000000..7d3cd753bec --- /dev/null +++ b/protocol/protobuf/wallet_local_backup.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +import "pairing.proto"; + +option go_package = "./;protobuf"; +package protobuf; + +message WalletLocalBackup { + repeated SyncAccount watchOnlyAccounts = 1; +} diff --git a/protocol/requests/load_local_backup.go b/protocol/requests/load_local_backup.go new file mode 100644 index 00000000000..2384f6cffa2 --- /dev/null +++ b/protocol/requests/load_local_backup.go @@ -0,0 +1,14 @@ +package requests + +import "errors" + +type LoadLocalBackup struct { + FilePath string `json:"filePath"` +} + +func (c *LoadLocalBackup) Validate() error { + if c.FilePath == "" { + return errors.New("filePath must be provided") + } + return nil +} diff --git a/server/pairing/config.go b/server/pairing/config.go index 03d78de79e0..60a41d8714f 100644 --- a/server/pairing/config.go +++ b/server/pairing/config.go @@ -20,6 +20,8 @@ type SenderConfig struct { Password string `json:"password" validate:"required"` ChatKey string `json:"chatKey"` // set only in case of a Keycard user, otherwise empty + MessageSyncingEnabled bool `json:"messageSyncingEnabled"` + DB *multiaccounts.Database `json:"-"` } diff --git a/server/pairing/raw_message_handler.go b/server/pairing/raw_message_handler.go index e32e56d4b26..0f970e9c44d 100644 --- a/server/pairing/raw_message_handler.go +++ b/server/pairing/raw_message_handler.go @@ -55,12 +55,14 @@ func (s *SyncRawMessageHandler) PrepareRawMessage(keyUID, deviceType string) (rm return nil, nil, nil, fmt.Errorf("keyUID not equal") } + messageSyncEnabled := s.backend.LocalPairingStateManager.IsMessageSyncEnabled() + messenger.SetLocalPairing(true) defer func() { messenger.SetLocalPairing(false) }() rawMessageCollector := new(RawMessageCollector) - err = messenger.SyncDevices(context.TODO(), currentAccount.Name, currentAccount.Identicon, rawMessageCollector.dispatchMessage) + err = messenger.SyncDevices(context.TODO(), currentAccount.Name, currentAccount.Identicon, messageSyncEnabled, rawMessageCollector.dispatchMessage) if err != nil { return } diff --git a/server/pairing/server.go b/server/pairing/server.go index def85819a66..c10e8667bb7 100644 --- a/server/pairing/server.go +++ b/server/pairing/server.go @@ -188,6 +188,8 @@ func StartUpSenderServer(backend *api.GethStatusBackend, configJSON string) (str } } + backend.LocalPairingStateManager.SetMessageSyncEnabled(conf.SenderConfig.MessageSyncingEnabled) + ps, err := MakeFullSenderServer(backend, conf) if err != nil { return "", err diff --git a/server/pairing/statecontrol/state_management.go b/server/pairing/statecontrol/state_management.go index 9e0de40d040..9c04bc73cca 100644 --- a/server/pairing/statecontrol/state_management.go +++ b/server/pairing/statecontrol/state_management.go @@ -19,6 +19,9 @@ type ProcessStateManager struct { pairing bool pairingLock sync.Mutex + messageSyncEnabled bool + messageSyncLock sync.Mutex + // sessions represents a map[string]bool: // where string is a ConnectionParams string and bool is the transfer success state of that connection string sessions sync.Map @@ -38,6 +41,20 @@ func (psm *ProcessStateManager) SetPairing(state bool) { psm.pairing = state } +// IsMessageSyncEnabled returns if syncing messages is enabled by the User +func (psm *ProcessStateManager) IsMessageSyncEnabled() bool { + psm.messageSyncLock.Lock() + defer psm.messageSyncLock.Unlock() + return psm.messageSyncEnabled +} + +// SetMessageSyncEnabled sets the ProcessStateManager message sync state +func (psm *ProcessStateManager) SetMessageSyncEnabled(state bool) { + psm.messageSyncLock.Lock() + defer psm.messageSyncLock.Unlock() + psm.messageSyncEnabled = state +} + // RegisterSession stores a sessionName with the default false value. // In practice a sessionName will be a ConnectionParams string provided by the server mode device. // The boolean value represents whether the ConnectionParams string session resulted in a successful transfer. diff --git a/services/accounts/service.go b/services/accounts/service.go index 57e2b9700b2..6711c3fb707 100644 --- a/services/accounts/service.go +++ b/services/accounts/service.go @@ -1,11 +1,19 @@ package accounts import ( + "errors" + "time" + "github.com/ethereum/go-ethereum/event" "github.com/ethereum/go-ethereum/rpc" + "github.com/status-im/status-go/eth-node/crypto" + "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/multiaccounts/settings" + "github.com/status-im/status-go/protocol/protobuf" "github.com/status-im/status-go/server" + "github.com/golang/protobuf/proto" + "github.com/status-im/status-go/account" "github.com/status-im/status-go/multiaccounts" "github.com/status-im/status-go/multiaccounts/accounts" @@ -18,7 +26,7 @@ func NewService(db *accounts.Database, mdb *multiaccounts.Database, manager *acc return &Service{db, mdb, manager, config, feed, nil, mediaServer} } -// Service is a browsers service. +// Service is an accounts service. type Service struct { db *accounts.Database mdb *multiaccounts.Database @@ -77,6 +85,14 @@ func (s *Service) GetSettings() (settings.Settings, error) { return s.db.GetSettings() } +func (s *Service) GetBackupPath() (string, error) { + return s.db.BackupPath() +} + +func (s *Service) SetBackupPath(path string) error { + return s.db.SaveSettingField(settings.BackupPath, path) +} + func (s *Service) GetMessenger() *protocol.Messenger { return s.messenger } @@ -89,3 +105,74 @@ func (s *Service) VerifyPassword(password string) bool { _, err = s.manager.VerifyAccountPassword(s.config.KeyStoreDir, address.Hex(), password) return err == nil } + +func (s *Service) prepareSyncSettingsMessages(currentClock uint64, prepareForBackup bool) (resultSync []*protobuf.SyncSetting, errorResult error) { + var errs []error + dbSettings, err := s.db.GetSettings() + if err != nil { + errorResult = err + return + } + + for _, sf := range settings.SettingFieldRegister { + if !sf.CanSync(settings.FromStruct) { + continue + } + + // DisplayName is backed up via `protobuf.BackedUpProfile` message. + if prepareForBackup && sf.SyncProtobufFactory().SyncSettingProtobufType() == protobuf.SyncSetting_DISPLAY_NAME { + continue + } + + // Pull clock from the db + clock, err := s.db.GetSettingLastSynced(sf) + if err != nil { + errorResult = err + return + } + if clock == 0 { + clock = currentClock + } + + // Build protobuf + _, sm, err := sf.SyncProtobufFactory().FromStruct()(dbSettings, clock, types.EncodeHex(crypto.FromECDSAPub(s.messenger.IdentityPublicKey()))) + if err != nil { + // Collect errors to give other sync messages a chance to send + errs = append(errs, err) + } + + resultSync = append(resultSync, sm) + } + errorResult = errors.Join(errs...) + return +} + +func (s *Service) ExportBackup() ([]byte, error) { + backup := &protobuf.AccountsLocalBackup{} + + settings, err := s.prepareSyncSettingsMessages(uint64(time.Now().UnixMilli()), true) + if err != nil { + return nil, err + } + backup.Settings = append(backup.Settings, settings...) + + return proto.Marshal(backup) +} + +func (s *Service) ImportBackup(data []byte) error { + var backup protobuf.AccountsLocalBackup + err := proto.Unmarshal(data, &backup) + if err != nil { + return err + } + var errs []error + + for _, setting := range backup.Settings { + // TODO is it ok to use the messenger here? Otherwise, I have to duplicate a lot of code + err = s.messenger.HandleBackedUpSettings(setting) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/services/accounts/settings.go b/services/accounts/settings.go index cbe3f67e849..c6246568bae 100644 --- a/services/accounts/settings.go +++ b/services/accounts/settings.go @@ -63,6 +63,15 @@ func (api *SettingsAPI) NewsRSSEnabled() (bool, error) { return api.db.NewsRSSEnabled() } +// Backup Settings +func (api *SettingsAPI) BackupPath() (string, error) { + return api.db.BackupPath() +} + +func (api *SettingsAPI) MessagesBackupEnabled() (bool, error) { + return api.db.MessagesBackupEnabled() +} + // Notifications Settings func (api *SettingsAPI) NotificationsGetAllowNotifications() (bool, error) { return api.db.GetAllowNotifications() diff --git a/services/ext/api.go b/services/ext/api.go index dcfd8ff665a..992854126ac 100644 --- a/services/ext/api.go +++ b/services/ext/api.go @@ -1015,7 +1015,7 @@ func (api *PublicAPI) SendPairInstallation(ctx context.Context) (*protocol.Messe } func (api *PublicAPI) SyncDevices(ctx context.Context, name, picture string) error { - return api.service.messenger.SyncDevices(ctx, name, picture, nil) + return api.service.messenger.SyncDevices(ctx, name, picture, false, nil) } // Deprecated: Use EnableInstallationAndSync instead diff --git a/services/gif/gif_test.go b/services/gif/gif_test.go index 61c72482c4d..2c824218ad8 100644 --- a/services/gif/gif_test.go +++ b/services/gif/gif_test.go @@ -9,6 +9,8 @@ import ( "github.com/status-im/status-go/appdatabase" "github.com/status-im/status-go/multiaccounts/accounts" + "github.com/status-im/status-go/multiaccounts/settings" + "github.com/status-im/status-go/params" "github.com/status-im/status-go/t/helpers" ) @@ -21,6 +23,18 @@ func setupSQLTestDb(t *testing.T) (*sql.DB, func()) { func setupTestDB(t *testing.T, db *sql.DB) (*accounts.Database, func()) { acc, err := accounts.NewDB(db) require.NoError(t, err) + config := params.NodeConfig{ + NetworkID: 10, + DataDir: "test", + } + networks := json.RawMessage("{}") + settingsObj := settings.Settings{ + Networks: &networks, + } + + err = acc.CreateSettings(settingsObj, config) + require.NoError(t, err) + return acc, func() { require.NoError(t, db.Close()) } diff --git a/services/status/service.go b/services/status/service.go index 4fe44ef4941..141a63134c3 100644 --- a/services/status/service.go +++ b/services/status/service.go @@ -42,6 +42,10 @@ func (s *Service) APIs() []rpc.API { } } +func (s *Service) Messenger() *protocol.Messenger { + return s.messenger +} + // NewPublicAPI returns a reference to the PublicAPI object func NewPublicAPI(s *Service) *PublicAPI { api := &PublicAPI{ diff --git a/services/wallet/market/market_test.go b/services/wallet/market/market_test.go index 43a3a96b56a..5a338d7286d 100644 --- a/services/wallet/market/market_test.go +++ b/services/wallet/market/market_test.go @@ -2,6 +2,7 @@ package market import ( "context" + "encoding/json" "errors" "testing" @@ -12,6 +13,9 @@ import ( "github.com/stretchr/testify/require" "github.com/status-im/status-go/appdatabase" + "github.com/status-im/status-go/multiaccounts/accounts" + "github.com/status-im/status-go/multiaccounts/settings" + "github.com/status-im/status-go/params" "github.com/status-im/status-go/rpc/network" mock_market "github.com/status-im/status-go/services/wallet/market/mock" "github.com/status-im/status-go/services/wallet/thirdparty" @@ -30,10 +34,25 @@ func setupTokenManager(t *testing.T) (*token.Manager, func()) { nm := network.NewManager(appDb, nil, nil, nil) - return token.NewTokenManager(walletDb, nil, nil, nm, appDb, nil, nil, nil, nil, token.NewPersistence(walletDb)), + accDb, err := accounts.NewDB(appDb) + require.NoError(t, err) + config := params.NodeConfig{ + NetworkID: 10, + DataDir: "test", + } + networks := json.RawMessage("{}") + settingsObj := settings.Settings{ + Networks: &networks, + } + + err = accDb.CreateSettings(settingsObj, config) + require.NoError(t, err) + + return token.NewTokenManager(walletDb, nil, nil, nm, appDb, nil, nil, nil, accDb, token.NewPersistence(walletDb)), func() { require.NoError(t, appDb.Close()) require.NoError(t, walletDb.Close()) + require.NoError(t, accDb.Close()) } } diff --git a/services/wallet/service.go b/services/wallet/service.go index 1c00cff7f0f..9ccfec7617e 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -4,10 +4,14 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "sync" "time" + "github.com/golang/protobuf/proto" + + "github.com/status-im/status-go/eth-node/types" "github.com/status-im/status-go/services/wallet/thirdparty/market/cryptocompare" "github.com/ethereum/go-ethereum/common" @@ -17,8 +21,11 @@ import ( "github.com/status-im/status-go/account" "github.com/status-im/status-go/logutils" "github.com/status-im/status-go/multiaccounts/accounts" + multiaccountscommon "github.com/status-im/status-go/multiaccounts/common" "github.com/status-im/status-go/params" protocolCommon "github.com/status-im/status-go/protocol/common" + "github.com/status-im/status-go/protocol/protobuf" + "github.com/status-im/status-go/protocol/wakusync" "github.com/status-im/status-go/rpc" "github.com/status-im/status-go/server" "github.com/status-im/status-go/services/ens/ensresolver" @@ -53,6 +60,16 @@ const ( defaultAutoRefreshCheckInterval = 3 * time.Minute // interval after which we should check if we should trigger the auto-refresh ) +// TODO this is duplicated +var ( + ErrNotWatchOnlyAccount = errors.New("an account is not a watch only account") + ErrTryingToStoreOldWalletAccount = errors.New("trying to store an old wallet account") +) + +const ( + EventWatchOnlyAccountRetrieved walletevent.EventType = "wallet-watch-only-account-retrieved" +) + func createCoingeckoProxyClient(config params.MarketDataProxyConfig) *coingecko.Client { baseURL := leaderboard.GetMarketProxyUrl(config.UrlOverride.Reveal(), config.StageName) @@ -464,3 +481,162 @@ func (s *Service) GetCollectiblesService() *collectibles.Service { func (s *Service) GetCollectiblesManager() *collectibles.Manager { return s.collectiblesManager } + +// LocalBackup Code +func (s *Service) prepareSyncAccountMessage(acc *accounts.Account) *protobuf.SyncAccount { + return &protobuf.SyncAccount{ + Clock: acc.Clock, + Address: acc.Address.Bytes(), + KeyUid: acc.KeyUID, + PublicKey: acc.PublicKey, + Path: acc.Path, + Name: acc.Name, + ColorId: string(acc.ColorID), + Emoji: acc.Emoji, + Wallet: acc.Wallet, + Chat: acc.Chat, + Hidden: acc.Hidden, + Removed: acc.Removed, + Operable: acc.Operable.String(), + Position: acc.Position, + ProdPreferredChainIDs: acc.ProdPreferredChainIDs, + TestPreferredChainIDs: acc.TestPreferredChainIDs, + } +} + +func (s *Service) backupWatchOnlyAccounts() ([]*protobuf.Backup, error) { + accounts, err := s.accountsDB.GetAllWatchOnlyAccounts() + if err != nil { + return nil, err + } + + var backupMessages []*protobuf.Backup + for _, acc := range accounts { + + backupMessage := &protobuf.Backup{} + backupMessage.WatchOnlyAccount = s.prepareSyncAccountMessage(acc) + + backupMessages = append(backupMessages, backupMessage) + } + + return backupMessages, nil +} + +func (s *Service) ExportBackup() ([]byte, error) { + backup := &protobuf.WalletLocalBackup{} + + woAccountsToBackup, err := s.backupWatchOnlyAccounts() + if err != nil { + return nil, err + } + for _, d := range woAccountsToBackup { + backup.WatchOnlyAccounts = append(backup.WatchOnlyAccounts, d.WatchOnlyAccount) + } + + return proto.Marshal(backup) +} + +func mapSyncAccountToAccount(message *protobuf.SyncAccount, accountOperability accounts.AccountOperable, accType accounts.AccountType) *accounts.Account { + return &accounts.Account{ + Address: types.BytesToAddress(message.Address), + KeyUID: message.KeyUid, + PublicKey: types.HexBytes(message.PublicKey), + Type: accType, + Path: message.Path, + Name: message.Name, + ColorID: multiaccountscommon.CustomizationColor(message.ColorId), + Emoji: message.Emoji, + Wallet: message.Wallet, + Chat: message.Chat, + Hidden: message.Hidden, + Clock: message.Clock, + Operable: accountOperability, + Removed: message.Removed, + Position: message.Position, + ProdPreferredChainIDs: message.ProdPreferredChainIDs, + TestPreferredChainIDs: message.TestPreferredChainIDs, + } +} + +// TODO this is a duplicate of the code in messenger_handler. Should it be moved to a common place? +func (s *Service) handleSyncWatchOnlyAccount(message *protobuf.SyncAccount) (*accounts.Account, error) { + if message.KeyUid != "" { + return nil, ErrNotWatchOnlyAccount + } + + accountOperability := accounts.AccountFullyOperable + + accAddress := types.BytesToAddress(message.Address) + dbAccount, err := s.accountsDB.GetAccountByAddress(accAddress) + if err != nil && err != accounts.ErrDbAccountNotFound { + return nil, err + } + + if dbAccount != nil { + if message.Clock <= dbAccount.Clock { + // ignore this old message + return nil, nil + } + + if message.Removed { + err = s.accountsDB.RemoveAccount(accAddress, message.Clock) + if err != nil { + return nil, err + } + dbAccount.Removed = true + return dbAccount, nil + } + } + + acc := mapSyncAccountToAccount(message, accountOperability, accounts.AccountTypeWatch) + + err = s.accountsDB.SaveOrUpdateAccounts([]*accounts.Account{acc}, false) + if err != nil { + return nil, err + } + + return acc, nil +} + +func (s *Service) handleWatchOnlyAccount(message *protobuf.SyncAccount) error { + if message == nil { + return nil + } + + acc, err := s.handleSyncWatchOnlyAccount(message) + if err != nil { + return err + } + response := wakusync.WakuBackedUpDataResponse{ + WatchOnlyAccount: acc, + } + encodedmessage, err := json.Marshal(response) + if err != nil { + return err + } + event := walletevent.Event{ + Type: EventWatchOnlyAccountRetrieved, + Message: string(encodedmessage), + } + s.feed.Send(event) + + return nil +} + +func (s *Service) ImportBackup(data []byte) error { + var backup protobuf.WalletLocalBackup + err := proto.Unmarshal(data, &backup) + if err != nil { + return err + } + var errs []error + + for _, watchOnlyAccount := range backup.WatchOnlyAccounts { + err = s.handleWatchOnlyAccount(watchOnlyAccount) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +}