From f41d54b336733ff713df5c44e029e49c81acc044 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Fri, 13 Sep 2024 10:54:51 +0200 Subject: [PATCH] Allows invites to be sent to offline remotes (#28176) * Allows invites to be sent to offline remotes Invites sent to remotes marked as offline will be stored as pending, and when the remote comes back online, it will process the invites as part of the synchronization process. * Update condition name for excluding confirmed invites --- .../store/sqlstore/shared_channel_store.go | 4 +- .../store/storetest/shared_channel_store.go | 23 ++++++-- .../services/sharedchannel/channelinvite.go | 32 +++++++++- .../services/sharedchannel/service.go | 1 + .../services/sharedchannel/sync_send.go | 58 ++++++++++++++++++- server/public/model/remote_cluster.go | 2 + server/public/model/shared_channel.go | 13 +++-- 7 files changed, 117 insertions(+), 16 deletions(-) diff --git a/server/channels/store/sqlstore/shared_channel_store.go b/server/channels/store/sqlstore/shared_channel_store.go index c6ccf916da..73d8d2c92d 100644 --- a/server/channels/store/sqlstore/shared_channel_store.go +++ b/server/channels/store/sqlstore/shared_channel_store.go @@ -495,7 +495,9 @@ func (s SqlSharedChannelStore) GetRemotes(offset, limit int, opts model.SharedCh query = query.Where(sq.Eq{"scr.RemoteId": opts.RemoteId}) } - if !opts.InclUnconfirmed { + if opts.ExcludeConfirmed { + query = query.Where(sq.Eq{"scr.IsInviteConfirmed": false}) + } else if !opts.IncludeUnconfirmed { query = query.Where(sq.Eq{"scr.IsInviteConfirmed": true}) } diff --git a/server/channels/store/storetest/shared_channel_store.go b/server/channels/store/storetest/shared_channel_store.go index 60dcc26e8a..a4f8dfc2ad 100644 --- a/server/channels/store/storetest/shared_channel_store.go +++ b/server/channels/store/storetest/shared_channel_store.go @@ -393,7 +393,7 @@ func testDeleteSharedChannel(t *testing.T, rctx request.CTX, ss store.Store) { ChannelId: channel.Id, CreatorId: model.NewId(), RemoteId: model.NewId(), - IsInviteConfirmed: true, // to avoid adding the InclUnconfirmed filter + IsInviteConfirmed: true, // to avoid adding the IncludeUnconfirmed filter } _, err := ss.SharedChannel().SaveRemote(remote) require.NoError(t, err, "couldn't add remote", err) @@ -688,8 +688,8 @@ func testGetSharedChannelRemotes(t *testing.T, rctx request.CTX, ss store.Store) t.Run("Get shared channel remotes by remote_id including unconfirmed", func(t *testing.T) { opts := model.SharedChannelRemoteFilterOpts{ - RemoteId: remoteId, - InclUnconfirmed: true, + RemoteId: remoteId, + IncludeUnconfirmed: true, } remotes, err := ss.SharedChannel().GetRemotes(0, 999999, opts) require.NoError(t, err, "should not error", err) @@ -699,6 +699,19 @@ func testGetSharedChannelRemotes(t *testing.T, rctx request.CTX, ss store.Store) } }) + t.Run("Get only unconfirmed shared channel remotes for remote", func(t *testing.T) { + opts := model.SharedChannelRemoteFilterOpts{ + RemoteId: remoteId, + ExcludeConfirmed: true, + } + remotes, err := ss.SharedChannel().GetRemotes(0, 999999, opts) + require.NoError(t, err, "should not error", err) + require.Len(t, remotes, 1) + for _, r := range remotes { + require.False(t, r.IsInviteConfirmed) + } + }) + t.Run("Get shared channel remotes with bad options", func(t *testing.T) { opts := model.SharedChannelRemoteFilterOpts{ ExcludeHome: true, @@ -739,8 +752,8 @@ func testGetSharedChannelRemotes(t *testing.T, rctx request.CTX, ss store.Store) t.Run("Get shared channel remotes excluding shared from home including unconfirmed", func(t *testing.T) { opts := model.SharedChannelRemoteFilterOpts{ - ExcludeHome: true, - InclUnconfirmed: true, + ExcludeHome: true, + IncludeUnconfirmed: true, } remotes, err := ss.SharedChannel().GetRemotes(0, 999999, opts) require.NoError(t, err, "should not error", err) diff --git a/server/platform/services/sharedchannel/channelinvite.go b/server/platform/services/sharedchannel/channelinvite.go index 2a45467e1b..ff0e016ec4 100644 --- a/server/platform/services/sharedchannel/channelinvite.go +++ b/server/platform/services/sharedchannel/channelinvite.go @@ -52,6 +52,30 @@ func (scs *Service) SendChannelInvite(channel *model.Channel, userId string, rc return err } + // if the remote is not currently online, we store the invite to + // send it when the connection is restored + if !rc.IsOnline() { + if len(options) > 0 { + // pending invites with options are currently not supported + scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error sending channel invite for %s: %s", rc.DisplayName, model.ErrOfflineRemote)) + return model.ErrOfflineRemote + } + + scr := &model.SharedChannelRemote{ + ChannelId: sc.ChannelId, + CreatorId: userId, + RemoteId: rc.RemoteId, + IsInviteAccepted: true, + IsInviteConfirmed: false, + } + if _, err = scs.server.GetStore().SharedChannel().SaveRemote(scr); err != nil { + scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error saving channel invite for %s: %v", rc.DisplayName, err)) + return err + } + + return nil + } + invite := channelInviteMsg{ ChannelId: channel.Id, ReadOnly: sc.ReadOnly, @@ -89,18 +113,20 @@ func (scs *Service) SendChannelInvite(channel *model.Channel, userId string, rc curTime := model.GetMillis() if existingScr != nil { - if existingScr.DeleteAt == 0 { + if existingScr.DeleteAt == 0 && existingScr.IsInviteConfirmed { // the shared channel remote exists and is not // deleted, nothing to do here return } - // the shared channel remote was deleted in the past, so - // with the new invite we restore it + // the shared channel remote was deleted in the past or + // pending confirmation, so with the new invite we restore + // it existingScr.DeleteAt = 0 existingScr.UpdateAt = curTime existingScr.LastPostCreateAt = curTime existingScr.LastPostUpdateAt = curTime + existingScr.IsInviteConfirmed = true if _, sErr := scs.server.GetStore().SharedChannel().UpdateRemote(existingScr); sErr != nil { scs.sendEphemeralPost(channel.Id, userId, fmt.Sprintf("Error confirming channel invite for %s: %v", rc.DisplayName, sErr)) return diff --git a/server/platform/services/sharedchannel/service.go b/server/platform/services/sharedchannel/service.go index 192d8a8a2b..771538fe97 100644 --- a/server/platform/services/sharedchannel/service.go +++ b/server/platform/services/sharedchannel/service.go @@ -244,6 +244,7 @@ func (scs *Service) makeChannelReadOnly(channel *model.Channel) *model.AppError func (scs *Service) onConnectionStateChange(rc *model.RemoteCluster, online bool) { if online { // when a previously offline remote comes back online force a sync. + scs.SendPendingInvitesForRemote(rc) scs.ForceSyncForRemote(rc) } diff --git a/server/platform/services/sharedchannel/sync_send.go b/server/platform/services/sharedchannel/sync_send.go index 220e01e216..b42382e1bb 100644 --- a/server/platform/services/sharedchannel/sync_send.go +++ b/server/platform/services/sharedchannel/sync_send.go @@ -150,6 +150,61 @@ func (scs *Service) NotifyUserStatusChanged(status *model.Status) { } } +func (scs *Service) SendPendingInvitesForRemote(rc *model.RemoteCluster) { + if rcs := scs.server.GetRemoteClusterService(); rcs == nil { + return + } + + scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Processing pending invites for remote after reconnection", + mlog.String("remote", rc.DisplayName), + mlog.String("remoteId", rc.RemoteId), + ) + + opts := model.SharedChannelRemoteFilterOpts{ + RemoteId: rc.RemoteId, + ExcludeConfirmed: true, + } + scrs, err := scs.server.GetStore().SharedChannel().GetRemotes(0, 999999, opts) + if err != nil { + scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Failed to fetch shared channel remotes for pending invites", + mlog.String("remote", rc.DisplayName), + mlog.String("remoteId", rc.RemoteId), + mlog.Err(err), + ) + return + } + + for _, scr := range scrs { + channel, err := scs.server.GetStore().Channel().Get(scr.ChannelId, true) + if err != nil { + scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Failed to fetch channel for pending invite", + mlog.String("remote_id", scr.RemoteId), + mlog.String("channel_id", scr.ChannelId), + mlog.String("sharedchannelremote_id", scr.Id), + mlog.Err(err), + ) + continue + } + + if err := scs.SendChannelInvite(channel, scr.CreatorId, rc); err != nil { + scs.server.Log().Log(mlog.LvlSharedChannelServiceError, "Failed to send pending invite", + mlog.String("remote_id", scr.RemoteId), + mlog.String("channel_id", scr.ChannelId), + mlog.String("sharedchannelremote_id", scr.Id), + mlog.Err(err), + ) + continue + } + + scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Pending invite sent", + mlog.String("remote", rc.DisplayName), + mlog.String("remoteId", rc.RemoteId), + mlog.String("channel_id", scr.ChannelId), + mlog.String("sharedchannelremote_id", scr.Id), + ) + } +} + // ForceSyncForRemote causes all channels shared with the remote to be synchronized. func (scs *Service) ForceSyncForRemote(rc *model.RemoteCluster) { if rcs := scs.server.GetRemoteClusterService(); rcs == nil { @@ -327,9 +382,10 @@ func (scs *Service) processTask(task syncTask) error { remotesMap[r.RemoteId] = r } - // add all remotes that have the autoinvited option. + // add all confirmed remotes that have the autoinvited option. filter = model.RemoteClusterQueryFilter{ RequireOptions: model.BitflagOptionAutoInvited, + OnlyConfirmed: true, } remotesAutoInvited, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter) if err != nil { diff --git a/server/public/model/remote_cluster.go b/server/public/model/remote_cluster.go index 83304cc8de..fd6aeba617 100644 --- a/server/public/model/remote_cluster.go +++ b/server/public/model/remote_cluster.go @@ -33,6 +33,8 @@ const ( var ( validRemoteNameChars = regexp.MustCompile(`^[a-zA-Z0-9\.\-\_]+$`) + + ErrOfflineRemote = errors.New("remote is offline") ) type Bitmask uint32 diff --git a/server/public/model/shared_channel.go b/server/public/model/shared_channel.go index 6ddd975a5e..9aec719720 100644 --- a/server/public/model/shared_channel.go +++ b/server/public/model/shared_channel.go @@ -261,12 +261,13 @@ type SharedChannelFilterOpts struct { } type SharedChannelRemoteFilterOpts struct { - ChannelId string - RemoteId string - InclUnconfirmed bool - ExcludeHome bool - ExcludeRemote bool - IncludeDeleted bool + ChannelId string + RemoteId string + IncludeUnconfirmed bool + ExcludeConfirmed bool + ExcludeHome bool + ExcludeRemote bool + IncludeDeleted bool } // SyncMsg represents a change in content (post add/edit/delete, reaction add/remove, users).