2022-11-30 09:41:35 +00:00
|
|
|
package protocol
|
|
|
|
|
|
|
|
import (
|
2022-12-12 10:20:32 +00:00
|
|
|
"database/sql"
|
2022-11-30 09:41:35 +00:00
|
|
|
|
|
|
|
"go.uber.org/zap"
|
|
|
|
|
2024-05-10 08:04:46 +00:00
|
|
|
"github.com/golang/protobuf/proto"
|
|
|
|
|
2022-11-30 09:41:35 +00:00
|
|
|
"github.com/status-im/status-go/images"
|
2023-08-25 14:28:26 +00:00
|
|
|
"github.com/status-im/status-go/multiaccounts/errors"
|
2024-03-05 13:03:10 +00:00
|
|
|
"github.com/status-im/status-go/multiaccounts/settings"
|
2024-05-10 08:04:46 +00:00
|
|
|
"github.com/status-im/status-go/protocol/common"
|
|
|
|
"github.com/status-im/status-go/protocol/communities"
|
2022-11-30 09:41:35 +00:00
|
|
|
"github.com/status-im/status-go/protocol/protobuf"
|
2023-08-25 14:28:26 +00:00
|
|
|
v1protocol "github.com/status-im/status-go/protocol/v1"
|
2022-11-30 09:41:35 +00:00
|
|
|
"github.com/status-im/status-go/protocol/wakusync"
|
2023-08-25 14:28:26 +00:00
|
|
|
ensservice "github.com/status-im/status-go/services/ens"
|
2022-11-30 09:41:35 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
2023-05-16 10:48:00 +00:00
|
|
|
SyncWakuSectionKeyProfile = "profile"
|
|
|
|
SyncWakuSectionKeyContacts = "contacts"
|
|
|
|
SyncWakuSectionKeyCommunities = "communities"
|
|
|
|
SyncWakuSectionKeySettings = "settings"
|
|
|
|
SyncWakuSectionKeyKeypairs = "keypairs"
|
|
|
|
SyncWakuSectionKeyWatchOnlyAccounts = "watchOnlyAccounts"
|
2022-11-30 09:41:35 +00:00
|
|
|
)
|
|
|
|
|
2023-08-18 11:39:59 +00:00
|
|
|
func (m *Messenger) HandleBackup(state *ReceivedMessageState, message *protobuf.Backup, statusMessage *v1protocol.StatusMessage) error {
|
|
|
|
if !m.processBackedupMessages {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
errors := m.handleBackup(state, message)
|
|
|
|
if len(errors) > 0 {
|
|
|
|
for _, err := range errors {
|
|
|
|
m.logger.Warn("failed to handle Backup", zap.Error(err))
|
|
|
|
}
|
|
|
|
return errors[0]
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Messenger) handleBackup(state *ReceivedMessageState, message *protobuf.Backup) []error {
|
2022-11-30 09:41:35 +00:00
|
|
|
var errors []error
|
|
|
|
|
|
|
|
err := m.handleBackedUpProfile(message.Profile, message.Clock)
|
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, contact := range message.Contacts {
|
2023-08-18 11:39:59 +00:00
|
|
|
err = m.HandleSyncInstallationContactV2(state, contact, nil)
|
2022-11-30 09:41:35 +00:00
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-12 21:15:05 +00:00
|
|
|
err = m.handleSyncChats(state, message.Chats)
|
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
2024-05-10 08:04:46 +00:00
|
|
|
communityErrors := m.handleSyncedCommunities(state, message)
|
|
|
|
if len(communityErrors) > 0 {
|
|
|
|
errors = append(errors, communityErrors...)
|
2022-11-30 09:41:35 +00:00
|
|
|
}
|
2024-05-10 08:04:46 +00:00
|
|
|
|
2022-11-30 09:41:35 +00:00
|
|
|
err = m.handleBackedUpSettings(message.Setting)
|
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
2023-06-28 19:45:36 +00:00
|
|
|
err = m.handleKeypair(message.Keypair)
|
2023-04-19 14:44:57 +00:00
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
2023-05-16 10:48:00 +00:00
|
|
|
err = m.handleWatchOnlyAccount(message.WatchOnlyAccount)
|
2023-02-27 10:19:18 +00:00
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send signal about applied backup progress
|
2022-11-30 09:41:35 +00:00
|
|
|
if m.config.messengerSignalsHandler != nil {
|
2023-05-16 10:48:00 +00:00
|
|
|
response := wakusync.WakuBackedUpDataResponse{
|
|
|
|
Clock: message.Clock,
|
|
|
|
}
|
2022-11-30 09:41:35 +00:00
|
|
|
|
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeyProfile, message.ProfileDetails)
|
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeyContacts, message.ContactsDetails)
|
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeyCommunities, message.CommunitiesDetails)
|
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeySettings, message.SettingsDetails)
|
2023-06-28 19:45:36 +00:00
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeyKeypairs, message.KeypairDetails)
|
2023-05-16 10:48:00 +00:00
|
|
|
response.AddFetchingBackedUpDataDetails(SyncWakuSectionKeyWatchOnlyAccounts, message.WatchOnlyAccountDetails)
|
2022-11-30 09:41:35 +00:00
|
|
|
|
|
|
|
m.config.messengerSignalsHandler.SendWakuFetchingBackupProgress(&response)
|
|
|
|
}
|
|
|
|
|
2023-02-27 10:19:18 +00:00
|
|
|
state.Response.BackupHandled = true
|
|
|
|
|
2022-11-30 09:41:35 +00:00
|
|
|
return errors
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Messenger) handleBackedUpProfile(message *protobuf.BackedUpProfile, backupTime uint64) error {
|
|
|
|
if message == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
response := wakusync.WakuBackedUpDataResponse{
|
|
|
|
Profile: &wakusync.BackedUpProfile{},
|
|
|
|
}
|
|
|
|
|
2023-03-30 10:18:13 +00:00
|
|
|
err := m.SaveSyncDisplayName(message.DisplayName, message.DisplayNameClock)
|
2023-08-25 14:28:26 +00:00
|
|
|
if err != nil && err != errors.ErrNewClockOlderThanCurrent {
|
2023-03-30 10:18:13 +00:00
|
|
|
return err
|
2022-11-30 09:41:35 +00:00
|
|
|
}
|
|
|
|
|
2023-04-19 22:59:09 +00:00
|
|
|
response.SetDisplayName(message.DisplayName)
|
2023-03-30 10:18:13 +00:00
|
|
|
|
2023-08-25 14:28:26 +00:00
|
|
|
// if we already have a newer clock, then we don't need to update the display name
|
|
|
|
if err == errors.ErrNewClockOlderThanCurrent {
|
|
|
|
response.SetDisplayName(m.account.Name)
|
|
|
|
}
|
|
|
|
|
2022-11-30 09:41:35 +00:00
|
|
|
syncWithBackedUpImages := false
|
|
|
|
dbImages, err := m.multiAccounts.GetIdentityImages(message.KeyUid)
|
|
|
|
if err != nil {
|
2022-12-12 10:20:32 +00:00
|
|
|
if err != sql.ErrNoRows {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
// if images are deleted and no images were backed up, then we need to delete them on other devices,
|
|
|
|
// that's why we don't return in case of `sql.ErrNoRows`
|
2022-11-30 09:41:35 +00:00
|
|
|
syncWithBackedUpImages = true
|
|
|
|
}
|
|
|
|
if len(dbImages) == 0 {
|
|
|
|
if len(message.Pictures) > 0 {
|
|
|
|
syncWithBackedUpImages = true
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// since both images (large and thumbnail) are always stored in the same time, we're free to use either of those two clocks for comparison
|
|
|
|
lastImageStoredClock := dbImages[0].Clock
|
|
|
|
syncWithBackedUpImages = lastImageStoredClock < backupTime
|
|
|
|
}
|
|
|
|
|
|
|
|
if syncWithBackedUpImages {
|
|
|
|
if len(message.Pictures) == 0 {
|
|
|
|
err = m.multiAccounts.DeleteIdentityImage(message.KeyUid)
|
2022-12-12 10:20:32 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-04-19 22:59:09 +00:00
|
|
|
response.SetImages(nil)
|
2022-11-30 09:41:35 +00:00
|
|
|
} else {
|
|
|
|
idImages := make([]images.IdentityImage, len(message.Pictures))
|
|
|
|
for i, pic := range message.Pictures {
|
|
|
|
img := images.IdentityImage{
|
|
|
|
Name: pic.Name,
|
|
|
|
Payload: pic.Payload,
|
|
|
|
Width: int(pic.Width),
|
|
|
|
Height: int(pic.Height),
|
|
|
|
FileSize: int(pic.FileSize),
|
|
|
|
ResizeTarget: int(pic.ResizeTarget),
|
|
|
|
Clock: pic.Clock,
|
|
|
|
}
|
|
|
|
idImages[i] = img
|
|
|
|
}
|
|
|
|
err = m.multiAccounts.StoreIdentityImages(message.KeyUid, idImages, false)
|
2022-12-12 10:20:32 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-04-19 22:59:09 +00:00
|
|
|
response.SetImages(idImages)
|
2022-11-30 09:41:35 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-26 13:53:40 +00:00
|
|
|
profileShowcasePreferences, err := m.saveProfileShowcasePreferencesProto(message.ProfileShowcasePreferences, false)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2024-02-28 11:36:13 +00:00
|
|
|
if profileShowcasePreferences != nil {
|
|
|
|
response.SetProfileShowcasePreferences(profileShowcasePreferences)
|
|
|
|
}
|
2024-02-26 13:53:40 +00:00
|
|
|
|
2023-04-26 15:37:18 +00:00
|
|
|
var ensUsernameDetails []*ensservice.UsernameDetail
|
|
|
|
for _, d := range message.EnsUsernameDetails {
|
2023-08-18 11:39:59 +00:00
|
|
|
dd, err := m.saveEnsUsernameDetailProto(d)
|
2023-04-26 15:37:18 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
ensUsernameDetails = append(ensUsernameDetails, dd)
|
|
|
|
}
|
|
|
|
response.SetEnsUsernameDetails(ensUsernameDetails)
|
|
|
|
|
2023-04-19 22:59:09 +00:00
|
|
|
if m.config.messengerSignalsHandler != nil {
|
2022-11-30 09:41:35 +00:00
|
|
|
m.config.messengerSignalsHandler.SendWakuBackedUpProfile(&response)
|
|
|
|
}
|
|
|
|
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *Messenger) handleBackedUpSettings(message *protobuf.SyncSetting) error {
|
|
|
|
if message == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-03-30 10:18:13 +00:00
|
|
|
// DisplayName is recovered via `protobuf.BackedUpProfile` message
|
|
|
|
if message.GetType() == protobuf.SyncSetting_DISPLAY_NAME {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-02-14 16:49:57 +00:00
|
|
|
settingField, err := m.extractAndSaveSyncSetting(message)
|
2022-11-30 09:41:35 +00:00
|
|
|
if err != nil {
|
|
|
|
m.logger.Warn("failed to handle SyncSetting from backed up message", zap.Error(err))
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-09-16 01:20:23 +00:00
|
|
|
if settingField != nil {
|
|
|
|
if message.GetType() == protobuf.SyncSetting_PREFERRED_NAME && message.GetValueString() != "" {
|
2024-03-05 13:03:10 +00:00
|
|
|
displayNameClock, err := m.settings.GetSettingLastSynced(settings.DisplayName)
|
2023-09-16 01:20:23 +00:00
|
|
|
if err != nil {
|
2024-03-05 13:03:10 +00:00
|
|
|
m.logger.Warn("failed to get last synced clock for display name", zap.Error(err))
|
2023-09-16 01:20:23 +00:00
|
|
|
return nil
|
|
|
|
}
|
2024-03-05 13:03:10 +00:00
|
|
|
// there is a race condition between display name and preferred name on updating m.account.Name, so we need to check the clock
|
|
|
|
// there is also a similar check within SaveSyncDisplayName
|
|
|
|
if displayNameClock < message.GetClock() {
|
|
|
|
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))
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
2022-12-12 10:20:32 +00:00
|
|
|
}
|
2022-11-30 09:41:35 +00:00
|
|
|
|
2023-09-16 01:20:23 +00:00
|
|
|
if m.config.messengerSignalsHandler != nil {
|
|
|
|
response := wakusync.WakuBackedUpDataResponse{
|
|
|
|
Setting: settingField,
|
|
|
|
}
|
|
|
|
m.config.messengerSignalsHandler.SendWakuBackedUpSettings(&response)
|
|
|
|
}
|
2023-02-27 10:19:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-06-28 19:45:36 +00:00
|
|
|
func (m *Messenger) handleKeypair(message *protobuf.SyncKeypair) error {
|
2023-02-27 10:19:18 +00:00
|
|
|
if message == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-07-27 13:20:40 +00:00
|
|
|
multiAcc, err := m.multiAccounts.GetAccount(message.KeyUid)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
// If user is recovering his account via seed phrase, but the backed up messages indicate that the profile keypair
|
|
|
|
// is a keycard related profile, then we need to remove related profile keycards (only profile, other keycards should remain).
|
2024-02-29 12:44:35 +00:00
|
|
|
if multiAcc != nil && multiAcc.KeyUID == message.KeyUid && !multiAcc.RefersToKeycard() && len(message.Keycards) > 0 {
|
2023-07-27 13:20:40 +00:00
|
|
|
message.Keycards = []*protobuf.SyncKeycard{}
|
|
|
|
}
|
|
|
|
|
2023-08-24 13:05:04 +00:00
|
|
|
keypair, err := m.handleSyncKeypair(message, false, nil)
|
2023-02-27 10:19:18 +00:00
|
|
|
if err != nil {
|
2023-06-28 19:45:36 +00:00
|
|
|
if err == ErrTryingToStoreOldKeypair {
|
|
|
|
return nil
|
|
|
|
}
|
2023-02-27 10:19:18 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.config.messengerSignalsHandler != nil {
|
2023-05-16 10:48:00 +00:00
|
|
|
kpResponse := wakusync.WakuBackedUpDataResponse{
|
|
|
|
Keypair: keypair.CopyKeypair(),
|
|
|
|
}
|
|
|
|
|
|
|
|
m.config.messengerSignalsHandler.SendWakuBackedUpKeypair(&kpResponse)
|
2023-04-19 14:44:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-05-16 10:48:00 +00:00
|
|
|
func (m *Messenger) handleWatchOnlyAccount(message *protobuf.SyncAccount) error {
|
2023-04-19 14:44:57 +00:00
|
|
|
if message == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-07-27 13:20:40 +00:00
|
|
|
acc, err := m.handleSyncWatchOnlyAccount(message, true)
|
2023-04-19 14:44:57 +00:00
|
|
|
if err != nil {
|
2023-06-28 19:45:36 +00:00
|
|
|
if err == ErrTryingToStoreOldWalletAccount {
|
|
|
|
return nil
|
|
|
|
}
|
2023-04-19 14:44:57 +00:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if m.config.messengerSignalsHandler != nil {
|
|
|
|
response := wakusync.WakuBackedUpDataResponse{
|
2023-05-16 10:48:00 +00:00
|
|
|
WatchOnlyAccount: acc,
|
2023-04-19 14:44:57 +00:00
|
|
|
}
|
|
|
|
|
2023-05-16 10:48:00 +00:00
|
|
|
m.config.messengerSignalsHandler.SendWakuBackedUpWatchOnlyAccount(&response)
|
2022-11-30 09:41:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2024-05-10 08:04:46 +00:00
|
|
|
|
2024-06-14 17:32:55 +00:00
|
|
|
func syncInstallationCommunitiesSet(communities []*protobuf.SyncInstallationCommunity) map[string]*protobuf.SyncInstallationCommunity {
|
|
|
|
ret := map[string]*protobuf.SyncInstallationCommunity{}
|
|
|
|
for _, c := range communities {
|
|
|
|
id := string(c.GetId())
|
|
|
|
prevC, ok := ret[id]
|
|
|
|
if !ok || prevC.Clock < c.Clock {
|
|
|
|
ret[id] = c
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return ret
|
|
|
|
}
|
|
|
|
|
2024-05-10 08:04:46 +00:00
|
|
|
func (m *Messenger) handleSyncedCommunities(state *ReceivedMessageState, message *protobuf.Backup) []error {
|
|
|
|
var errors []error
|
2024-06-14 17:32:55 +00:00
|
|
|
for _, syncCommunity := range syncInstallationCommunitiesSet(message.Communities) {
|
2024-06-18 16:18:21 +00:00
|
|
|
err := m.handleSyncInstallationCommunity(state, syncCommunity)
|
2024-05-10 08:04:46 +00:00
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
|
2024-06-14 17:32:55 +00:00
|
|
|
err = m.requestCommunityKeysAndSharedAddresses(state, syncCommunity)
|
2024-05-10 08:04:46 +00:00
|
|
|
if err != nil {
|
|
|
|
errors = append(errors, err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return errors
|
|
|
|
}
|
|
|
|
|
2024-06-14 17:32:55 +00:00
|
|
|
func (m *Messenger) requestCommunityKeysAndSharedAddresses(state *ReceivedMessageState, syncCommunity *protobuf.SyncInstallationCommunity) error {
|
2024-05-10 08:04:46 +00:00
|
|
|
if !syncCommunity.Joined {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
community, err := m.GetCommunityByID(syncCommunity.Id)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if community == nil {
|
|
|
|
return communities.ErrOrgNotFound
|
|
|
|
}
|
|
|
|
|
2024-06-14 17:32:55 +00:00
|
|
|
// Send a request to get back our previous shared addresses
|
|
|
|
request := &protobuf.CommunitySharedAddressesRequest{
|
|
|
|
CommunityId: syncCommunity.Id,
|
|
|
|
}
|
|
|
|
|
|
|
|
payload, err := proto.Marshal(request)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
rawMessage := &common.RawMessage{
|
|
|
|
Payload: payload,
|
|
|
|
Sender: m.identity,
|
|
|
|
CommunityID: community.ID(),
|
|
|
|
SkipEncryptionLayer: true,
|
|
|
|
MessageType: protobuf.ApplicationMetadataMessage_COMMUNITY_SHARED_ADDRESSES_REQUEST,
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = m.SendMessageToControlNode(community, rawMessage)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
m.logger.Error("failed to request shared addresses", zap.String("communityId", community.IDString()), zap.Error(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the community is encrypted or one channel is, ask for the encryption keys back
|
2024-05-10 08:04:46 +00:00
|
|
|
isEncrypted := syncCommunity.Encrypted || len(syncCommunity.EncryptionKeysV2) > 0
|
|
|
|
if !isEncrypted {
|
|
|
|
// check if we have encrypted channels
|
|
|
|
myPk := m.IdentityPublicKeyString()
|
|
|
|
for channelID, channel := range community.Chats() {
|
|
|
|
_, exists := channel.GetMembers()[myPk]
|
|
|
|
if exists && community.ChannelEncrypted(channelID) {
|
|
|
|
isEncrypted = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if isEncrypted {
|
|
|
|
request := &protobuf.CommunityEncryptionKeysRequest{
|
|
|
|
CommunityId: syncCommunity.Id,
|
|
|
|
}
|
|
|
|
|
|
|
|
payload, err := proto.Marshal(request)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
rawMessage := &common.RawMessage{
|
|
|
|
Payload: payload,
|
|
|
|
Sender: m.identity,
|
|
|
|
CommunityID: community.ID(),
|
|
|
|
SkipEncryptionLayer: true,
|
|
|
|
MessageType: protobuf.ApplicationMetadataMessage_COMMUNITY_ENCRYPTION_KEYS_REQUEST,
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err = m.SendMessageToControlNode(community, rawMessage)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
m.logger.Error("failed to request community encryption keys", zap.String("communityId", community.IDString()), zap.Error(err))
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|