diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 950415a6d6..8092541755 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -157,6 +157,8 @@ func setupTestHelper(dbStore store.Store, searchEngine *searchengine.Broker, ent th.App.SetSearchEngine(searchEngine) } + th.App.Srv().SetLicense(getLicense(enterprise, memoryConfig)) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 *cfg.RateLimitSettings.Enable = false @@ -186,12 +188,6 @@ func setupTestHelper(dbStore store.Store, searchEngine *searchengine.Broker, ent web.New(th.App.Srv()) wsapi.Init(th.App.Srv()) - if enterprise { - th.App.Srv().SetLicense(model.NewTestLicense()) - } else { - th.App.Srv().SetLicense(nil) - } - th.Client = th.CreateClient() th.SystemAdminClient = th.CreateClient() th.SystemManagerClient = th.CreateClient() @@ -215,6 +211,16 @@ func setupTestHelper(dbStore store.Store, searchEngine *searchengine.Broker, ent return th } +func getLicense(enterprise bool, cfg *model.Config) *model.License { + if *cfg.ExperimentalSettings.EnableRemoteClusterService || *cfg.ExperimentalSettings.EnableSharedChannels { + return model.NewTestLicenseSKU(model.LicenseShortSkuProfessional) + } + if enterprise { + return model.NewTestLicense() + } + return nil +} + func SetupEnterprise(tb testing.TB, options ...app.Option) *TestHelper { if testing.Short() { tb.SkipNow() @@ -285,6 +291,7 @@ func SetupConfig(tb testing.TB, updateConfig func(cfg *model.Config)) *TestHelpe dbStore := mainHelper.GetStore() dbStore.DropAllTables() dbStore.MarkSystemRanUnitTests() + mainHelper.PreloadMigrations() searchEngine := mainHelper.GetSearchEngine() th := setupTestHelper(dbStore, searchEngine, false, true, updateConfig, nil) th.InitLogin() diff --git a/server/channels/api4/shared_channel_test.go b/server/channels/api4/shared_channel_test.go index c955b29a79..96f50733cc 100644 --- a/server/channels/api4/shared_channel_test.go +++ b/server/channels/api4/shared_channel_test.go @@ -15,23 +15,26 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/v8/channels/app" ) var ( rnd = rand.New(rand.NewSource(time.Now().UnixNano())) ) +func setupForSharedChannels(tb testing.TB) *TestHelper { + return SetupConfig(tb, func(cfg *model.Config) { + *cfg.ExperimentalSettings.EnableRemoteClusterService = true + *cfg.ExperimentalSettings.EnableSharedChannels = true + }) +} + func TestGetAllSharedChannels(t *testing.T) { - th := Setup(t).InitBasic() + th := setupForSharedChannels(t).InitBasic() defer th.TearDown() const pages = 3 const pageSize = 7 - mockService := app.NewMockRemoteClusterService(nil, app.MockOptionRemoteClusterServiceWithActive(true)) - th.App.Srv().SetRemoteClusterService(mockService) - savedIds := make([]string, 0, pages*pageSize) // make some shared channels @@ -45,6 +48,7 @@ func TestGetAllSharedChannels(t *testing.T) { CreatorId: th.BasicChannel.CreatorId, RemoteId: model.NewId(), } + _, err := th.App.ShareChannel(th.Context, sc) require.NoError(t, err) savedIds = append(savedIds, channel.Id) @@ -96,12 +100,9 @@ func randomBool() bool { } func TestGetRemoteClusterById(t *testing.T) { - th := Setup(t).InitBasic() + th := setupForSharedChannels(t).InitBasic() defer th.TearDown() - mockService := app.NewMockRemoteClusterService(nil, app.MockOptionRemoteClusterServiceWithActive(true)) - th.App.Srv().SetRemoteClusterService(mockService) - // for this test we need a user that belongs to a channel that // is shared with the requested remote id. @@ -155,7 +156,7 @@ func TestGetRemoteClusterById(t *testing.T) { func TestCreateDirectChannelWithRemoteUser(t *testing.T) { t.Run("creates a local DM channel that is shared", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupForSharedChannels(t).InitBasic() defer th.TearDown() client := th.Client defer client.Logout(context.Background()) @@ -175,16 +176,14 @@ func TestCreateDirectChannelWithRemoteUser(t *testing.T) { }) t.Run("sends a shared channel invitation to the remote", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupForSharedChannels(t).InitBasic() defer th.TearDown() client := th.Client defer client.Logout(context.Background()) - mockService := app.NewMockSharedChannelService(nil, app.MockOptionSharedChannelServiceWithActive(true)) - th.App.Srv().SetSharedChannelSyncService(mockService) - localUser := th.BasicUser remoteUser := th.CreateUser() + rc := &model.RemoteCluster{ Name: "test", Token: model.NewId(), @@ -203,21 +202,17 @@ func TestCreateDirectChannelWithRemoteUser(t *testing.T) { channelName := model.GetDMNameFromIds(localUser.Id, remoteUser.Id) require.Equal(t, channelName, dm.Name, "dm name didn't match") require.True(t, dm.IsShared()) - - assert.Equal(t, 1, mockService.NumInvitations()) }) t.Run("does not send a shared channel invitation to the remote when creator is remote", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupForSharedChannels(t).InitBasic() defer th.TearDown() client := th.Client defer client.Logout(context.Background()) - mockService := app.NewMockSharedChannelService(nil, app.MockOptionSharedChannelServiceWithActive(true)) - th.App.Srv().SetSharedChannelSyncService(mockService) - localUser := th.BasicUser remoteUser := th.CreateUser() + rc := &model.RemoteCluster{ Name: "test", Token: model.NewId(), @@ -236,7 +231,5 @@ func TestCreateDirectChannelWithRemoteUser(t *testing.T) { channelName := model.GetDMNameFromIds(localUser.Id, remoteUser.Id) require.Equal(t, channelName, dm.Name, "dm name didn't match") require.True(t, dm.IsShared()) - - assert.Zero(t, mockService.NumInvitations()) }) } diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 49fbb90660..9c22f83eff 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -913,7 +913,7 @@ type AppIface interface { InviteGuestsToChannelsGracefully(teamID string, guestsInvite *model.GuestsInvite, senderId string) ([]*model.EmailInviteWithError, *model.AppError) InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) - InviteRemoteToChannel(channelID, remoteID, userID string) error + InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error IsCRTEnabledForUser(c request.CTX, userID string) bool IsConfigReadOnly() bool IsFirstUserAccount() bool diff --git a/server/channels/app/helper_test.go b/server/channels/app/helper_test.go index 005c7740e1..c10e20e396 100644 --- a/server/channels/app/helper_test.go +++ b/server/channels/app/helper_test.go @@ -50,7 +50,8 @@ type TestHelper struct { tempWorkspace string } -func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, options []Option, tb testing.TB) *TestHelper { +func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, + updateConfig func(*model.Config), options []Option, tb testing.TB) *TestHelper { tempWorkspace, err := os.MkdirTemp("", "apptest") if err != nil { panic(err) @@ -66,6 +67,9 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo *memoryConfig.LogSettings.ConsoleLevel = mlog.LvlStdLog.Name *memoryConfig.AnnouncementSettings.AdminNoticesEnabled = false *memoryConfig.AnnouncementSettings.UserNoticesEnabled = false + if updateConfig != nil { + updateConfig(memoryConfig) + } configStore.Set(memoryConfig) buffer := &mlog.Buffer{} @@ -105,6 +109,8 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo ConfigStore: configStore, } + th.App.Srv().SetLicense(getLicense(enterprise, memoryConfig)) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.RateLimitSettings.Enable = false }) prevListenAddress := *th.App.Config().ServiceSettings.ListenAddress @@ -131,12 +137,6 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo *cfg.PasswordSettings.Number = false }) - if enterprise { - th.App.Srv().SetLicense(model.NewTestLicense()) - } else { - th.App.Srv().SetLicense(nil) - } - if th.tempWorkspace == "" { th.tempWorkspace = tempWorkspace } @@ -144,6 +144,16 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo return th } +func getLicense(enterprise bool, cfg *model.Config) *model.License { + if *cfg.ExperimentalSettings.EnableRemoteClusterService || *cfg.ExperimentalSettings.EnableSharedChannels { + return model.NewTestLicenseSKU(model.LicenseShortSkuProfessional) + } + if enterprise { + return model.NewTestLicense() + } + return nil +} + func Setup(tb testing.TB, options ...Option) *TestHelper { if testing.Short() { tb.SkipNow() @@ -153,7 +163,19 @@ func Setup(tb testing.TB, options ...Option) *TestHelper { dbStore.MarkSystemRanUnitTests() mainHelper.PreloadMigrations() - return setupTestHelper(dbStore, false, true, options, tb) + return setupTestHelper(dbStore, false, true, nil, options, tb) +} + +func SetupConfig(tb testing.TB, updateConfig func(cfg *model.Config)) *TestHelper { + if testing.Short() { + tb.SkipNow() + } + dbStore := mainHelper.GetStore() + dbStore.DropAllTables() + dbStore.MarkSystemRanUnitTests() + mainHelper.PreloadMigrations() + + return setupTestHelper(dbStore, false, true, updateConfig, nil, tb) } func SetupWithoutPreloadMigrations(tb testing.TB) *TestHelper { @@ -164,13 +186,13 @@ func SetupWithoutPreloadMigrations(tb testing.TB) *TestHelper { dbStore.DropAllTables() dbStore.MarkSystemRanUnitTests() - return setupTestHelper(dbStore, false, true, nil, tb) + return setupTestHelper(dbStore, false, true, nil, nil, tb) } func SetupWithStoreMock(tb testing.TB) *TestHelper { mockStore := testlib.GetMockStoreForSetupFunctions() setupOptions := []Option{SkipProductsInitialization()} - th := setupTestHelper(mockStore, false, false, setupOptions, tb) + th := setupTestHelper(mockStore, false, false, nil, setupOptions, tb) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) @@ -192,7 +214,7 @@ func SetupWithStoreMock(tb testing.TB) *TestHelper { func SetupEnterpriseWithStoreMock(tb testing.TB) *TestHelper { mockStore := testlib.GetMockStoreForSetupFunctions() setupOptions := []Option{SkipProductsInitialization()} - th := setupTestHelper(mockStore, true, false, setupOptions, tb) + th := setupTestHelper(mockStore, true, false, nil, setupOptions, tb) statusMock := mocks.StatusStore{} statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil) @@ -214,7 +236,7 @@ func SetupWithClusterMock(tb testing.TB, cluster einterfaces.ClusterInterface) * dbStore.MarkSystemRanUnitTests() mainHelper.PreloadMigrations() - return setupTestHelper(dbStore, true, true, []Option{SetCluster(cluster)}, tb) + return setupTestHelper(dbStore, true, true, nil, []Option{SetCluster(cluster)}, tb) } var initBasicOnce sync.Once diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 90328779cb..4defcd1e7d 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -11964,7 +11964,7 @@ func (a *OpenTracingAppLayer) InviteNewUsersToTeamGracefully(memberInvite *model return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) InviteRemoteToChannel(channelID string, remoteID string, userID string) error { +func (a *OpenTracingAppLayer) InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.InviteRemoteToChannel") @@ -11976,7 +11976,7 @@ func (a *OpenTracingAppLayer) InviteRemoteToChannel(channelID string, remoteID s }() defer span.Finish() - resultVar0 := a.app.InviteRemoteToChannel(channelID, remoteID, userID) + resultVar0 := a.app.InviteRemoteToChannel(channelID, remoteID, userID, shareIfNotShared) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) diff --git a/server/channels/app/platform/shared_channel_notifier.go b/server/channels/app/platform/shared_channel_notifier.go index 22d39712a1..fcf24e417a 100644 --- a/server/channels/app/platform/shared_channel_notifier.go +++ b/server/channels/app/platform/shared_channel_notifier.go @@ -78,7 +78,31 @@ func handleContentSync(ps *PlatformService, syncService SharedChannelServiceIFac return err } - if channel != nil && channel.IsShared() { + shouldNotify := channel.IsShared() + + // check if any remotes need to be auto-subscribed to this channel. Remotes are auto-subscribed to DM/GM's if they registered + // with the AutoShareDMs flag set. + if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { + filter := model.RemoteClusterQueryFilter{ + NotInChannel: channel.Id, + OnlyConfirmed: true, + RequireOptions: model.BitflagOptionAutoShareDMs, + } + remotes, err := ps.Store.RemoteCluster().GetAll(filter) // empty list returned if none found, no error + if err != nil { + return fmt.Errorf("cannot fetch remote clusters: %w", err) + } + for _, remote := range remotes { + // invite remote to channel (will share the channel if not already shared) + if err := syncService.InviteRemoteToChannel(channel.Id, remote.RemoteId, remote.CreatorId, true); err != nil { + return fmt.Errorf("cannot invite remote to channel %s: %w", channel.Id, err) + } + shouldNotify = true + } + } + + // notify + if shouldNotify { syncService.NotifyChannelChanged(channel.Id) } diff --git a/server/channels/app/platform/shared_channel_service_iface.go b/server/channels/app/platform/shared_channel_service_iface.go index dc9665d4e2..2d915478d8 100644 --- a/server/channels/app/platform/shared_channel_service_iface.go +++ b/server/channels/app/platform/shared_channel_service_iface.go @@ -16,6 +16,12 @@ type SharedChannelServiceIFace interface { NotifyUserProfileChanged(userID string) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error Active() bool + InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error + UninviteRemoteFromChannel(channelID, remoteID string) error + ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) + CheckChannelNotShared(channelID string) error + CheckChannelIsShared(channelID string) error + CheckCanInviteToSharedChannel(channelId string) error } type MockOptionSharedChannelService func(service *mockSharedChannelService) diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 2a333dd8df..6ee6dc52b2 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -1305,7 +1305,7 @@ func (api *PluginAPI) UnregisterPluginForSharedChannels(pluginID string) error { func (api *PluginAPI) ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) { scShared, err := api.app.ShareChannel(api.ctx, sc) - if errors.Is(err, ErrChannelAlreadyShared) { + if errors.Is(err, model.ErrChannelAlreadyShared) { // sharing an already shared channel is not an error; treat as idempotent and return the existing shared channel return api.app.GetSharedChannel(sc.ChannelId) } @@ -1328,8 +1328,8 @@ func (api *PluginAPI) SyncSharedChannel(channelID string) error { return api.app.SyncSharedChannel(channelID) } -func (api *PluginAPI) InviteRemoteToChannel(channelID string, remoteID, userID string) error { - return api.app.InviteRemoteToChannel(channelID, remoteID, userID) +func (api *PluginAPI) InviteRemoteToChannel(channelID string, remoteID, userID string, shareIfNotShared bool) error { + return api.app.InviteRemoteToChannel(channelID, remoteID, userID, shareIfNotShared) } func (api *PluginAPI) UninviteRemoteFromChannel(channelID string, remoteID string) error { diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 66ea158313..049e746825 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -2985,11 +2985,11 @@ func TestReplyToPostWithLag(t *testing.T) { func TestSharedChannelSyncForPostActions(t *testing.T) { t.Run("creating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() defer th.TearDown() - remoteClusterService := NewMockSharedChannelService(nil) - th.Server.SetSharedChannelSyncService(remoteClusterService) + sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) + th.Server.SetSharedChannelSyncService(sharedChannelService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) @@ -3004,16 +3004,16 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { }, channel, false, true) require.Nil(t, err, "Creating a post should not error") - require.Len(t, remoteClusterService.channelNotifications, 1) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[0]) + require.Len(t, sharedChannelService.channelNotifications, 1) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0]) }) t.Run("updating a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() defer th.TearDown() - remoteClusterService := NewMockSharedChannelService(nil) - th.Server.SetSharedChannelSyncService(remoteClusterService) + sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) + th.Server.SetSharedChannelSyncService(sharedChannelService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) @@ -3031,17 +3031,17 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { _, err = th.App.UpdatePost(th.Context, post, true) require.Nil(t, err, "Updating a post should not error") - require.Len(t, remoteClusterService.channelNotifications, 2) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[0]) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[1]) + require.Len(t, sharedChannelService.channelNotifications, 2) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0]) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1]) }) t.Run("deleting a post in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() defer th.TearDown() - remoteClusterService := NewMockSharedChannelService(nil) - th.Server.SetSharedChannelSyncService(remoteClusterService) + sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) + th.Server.SetSharedChannelSyncService(sharedChannelService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) @@ -3060,10 +3060,10 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { require.Nil(t, err, "Deleting a post should not error") // one creation and two deletes - require.Len(t, remoteClusterService.channelNotifications, 3) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[0]) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[1]) - assert.Equal(t, channel.Id, remoteClusterService.channelNotifications[2]) + require.Len(t, sharedChannelService.channelNotifications, 3) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[0]) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1]) + assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[2]) }) } diff --git a/server/channels/app/reaction_test.go b/server/channels/app/reaction_test.go index 03ee890a5f..5bb0624671 100644 --- a/server/channels/app/reaction_test.go +++ b/server/channels/app/reaction_test.go @@ -98,9 +98,9 @@ func TestSaveReactionForPost(t *testing.T) { func TestSharedChannelSyncForReactionActions(t *testing.T) { t.Run("adding a reaction in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() - sharedChannelService := NewMockSharedChannelService(nil) + sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) th.Server.SetSharedChannelSyncService(sharedChannelService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) @@ -133,9 +133,9 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { }) t.Run("removing a reaction in a shared channel performs a content sync when sync service is running on that node", func(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() - sharedChannelService := NewMockSharedChannelService(nil) + sharedChannelService := NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) th.Server.SetSharedChannelSyncService(sharedChannelService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) diff --git a/server/channels/app/remote_cluster_service_mock.go b/server/channels/app/remote_cluster_service_mock.go deleted file mode 100644 index d17d11a463..0000000000 --- a/server/channels/app/remote_cluster_service_mock.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package app - -import ( - "context" - - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster" -) - -// MockOptionRemoteClusterService a mock of the remote cluster service -type MockOptionRemoteClusterService func(service *mockRemoteClusterService) - -func MockOptionRemoteClusterServiceWithActive(active bool) MockOptionRemoteClusterService { - return func(mrcs *mockRemoteClusterService) { - mrcs.active = active - } -} - -func NewMockRemoteClusterService(service remotecluster.RemoteClusterServiceIFace, options ...MockOptionRemoteClusterService) *mockRemoteClusterService { - mrcs := &mockRemoteClusterService{service, true} - for _, option := range options { - option(mrcs) - } - return mrcs -} - -type mockRemoteClusterService struct { - remotecluster.RemoteClusterServiceIFace - active bool -} - -func (mrcs *mockRemoteClusterService) Shutdown() error { - return nil -} - -func (mrcs *mockRemoteClusterService) Start() error { - return nil -} - -func (mrcs *mockRemoteClusterService) Active() bool { - return mrcs.active -} - -func (mrcs *mockRemoteClusterService) AddTopicListener(topic string, listener remotecluster.TopicListener) string { - return model.NewId() -} - -func (mrcs *mockRemoteClusterService) RemoveTopicListener(listenerId string) { -} - -func (mrcs *mockRemoteClusterService) AddConnectionStateListener(listener remotecluster.ConnectionStateListener) string { - return model.NewId() -} - -func (mrcs *mockRemoteClusterService) RemoveConnectionStateListener(listenerId string) { -} - -func (mrcs *mockRemoteClusterService) SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f remotecluster.SendMsgResultFunc) error { - return nil -} - -func (mrcs *mockRemoteClusterService) SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp remotecluster.ReaderProvider, f remotecluster.SendFileResultFunc) error { - return nil -} - -func (mrcs *mockRemoteClusterService) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) { - return nil, nil -} - -func (mrcs *mockRemoteClusterService) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) remotecluster.Response { - return remotecluster.Response{} -} diff --git a/server/channels/app/remote_cluster_test.go b/server/channels/app/remote_cluster_test.go index 41dd89f477..d49ad488bc 100644 --- a/server/channels/app/remote_cluster_test.go +++ b/server/channels/app/remote_cluster_test.go @@ -13,8 +13,14 @@ import ( "github.com/mattermost/mattermost/server/public/shared/i18n" ) +func setupRemoteCluster(tb testing.TB) *TestHelper { + return SetupConfig(tb, func(cfg *model.Config) { + *cfg.ExperimentalSettings.EnableRemoteClusterService = true + }) +} + func TestAddRemoteCluster(t *testing.T) { - th := Setup(t).InitBasic() + th := setupRemoteCluster(t).InitBasic() defer th.TearDown() t.Run("adding remote cluster with duplicate site url and remote team id", func(t *testing.T) { @@ -68,7 +74,7 @@ func TestAddRemoteCluster(t *testing.T) { } func TestUpdateRemoteCluster(t *testing.T) { - th := Setup(t).InitBasic() + th := setupRemoteCluster(t).InitBasic() defer th.TearDown() t.Run("update remote cluster with an already existing site url and team id", func(t *testing.T) { diff --git a/server/channels/app/server.go b/server/channels/app/server.go index bae76a4f24..a120378dfe 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -651,7 +651,7 @@ func (s *Server) startInterClusterServices(license *model.License) error { return nil } - scs, err := sharedchannel.NewSharedChannelService(s, appInstance) + scs, err := sharedchannel.NewSharedChannelService(s, s.Platform(), appInstance) if err != nil { return err } diff --git a/server/channels/app/shared_channel.go b/server/channels/app/shared_channel.go index 18167d9234..d6223fea3b 100644 --- a/server/channels/app/shared_channel.go +++ b/server/channels/app/shared_channel.go @@ -15,10 +15,14 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/store" ) -var ( - errNotFound = errors.New("not found") - ErrChannelAlreadyShared = errors.New("channel is already shared") -) +func (a *App) getSharedChannelsService() (SharedChannelServiceIFace, error) { + scService := a.Srv().GetSharedChannelSyncService() + if scService == nil || !scService.Active() { + return nil, model.NewAppError("InviteRemoteToChannel", "api.command_share.service_disabled", + nil, "", http.StatusBadRequest) + } + return scService, nil +} func (a *App) checkChannelNotShared(c request.CTX, channelId string) error { // check that channel exists. @@ -28,7 +32,7 @@ func (a *App) checkChannelNotShared(c request.CTX, channelId string) error { // Check channel is not already shared. if _, err := a.GetSharedChannel(channelId); err == nil { - return ErrChannelAlreadyShared + return model.ErrChannelAlreadyShared } return nil @@ -46,42 +50,21 @@ func (a *App) checkChannelIsShared(channelId string) error { } func (a *App) CheckCanInviteToSharedChannel(channelId string) error { - sc, err := a.GetSharedChannel(channelId) + scService, err := a.getSharedChannelsService() if err != nil { - var errNotFound *store.ErrNotFound - if errors.As(err, &errNotFound) { - return fmt.Errorf("channel is not shared: %w", err) - } - return fmt.Errorf("cannot find channel: %w", err) + return err } - - if !sc.Home { - return errors.New("channel is homed on a remote cluster") - } - return nil -} - -func (a *App) notifyClientsForSharedChannelUpdate(teamID, channelID string) { - messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelConverted, teamID, "", "", nil, "") - messageWs.Add("channel_id", channelID) - a.Publish(messageWs) + return scService.CheckCanInviteToSharedChannel(channelId) } // SharedChannels func (a *App) ShareChannel(c request.CTX, sc *model.SharedChannel) (*model.SharedChannel, error) { - if err := a.checkChannelNotShared(c, sc.ChannelId); err != nil { - return nil, err - } - - // stores a SharedChannel and set the share flag on the channel. - scNew, err := a.Srv().Store().SharedChannel().Save(sc) + scService, err := a.getSharedChannelsService() if err != nil { return nil, err } - - a.notifyClientsForSharedChannelUpdate(scNew.TeamId, scNew.ChannelId) - return scNew, nil + return scService.ShareChannel(sc) } func (a *App) GetSharedChannel(channelID string) (*model.SharedChannel, error) { @@ -105,103 +88,37 @@ func (a *App) GetSharedChannelsCount(opts model.SharedChannelFilterOpts) (int64, } func (a *App) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) { - scUpdated, err := a.Srv().Store().SharedChannel().Update(sc) + scService, err := a.getSharedChannelsService() if err != nil { return nil, err } - a.notifyClientsForSharedChannelUpdate(scUpdated.TeamId, scUpdated.ChannelId) - return scUpdated, nil + return scService.UpdateSharedChannel(sc) } func (a *App) UnshareChannel(channelID string) (bool, error) { - // fetch the SharedChannel first - sc, err := a.GetSharedChannel(channelID) + scService, err := a.getSharedChannelsService() if err != nil { return false, err } - - // deletes the ShareChannel, unsets the share flag on the channel, deletes all remotes for the channel - deleted, err := a.Srv().Store().SharedChannel().Delete(channelID) - if err != nil { - return false, err - } - a.notifyClientsForSharedChannelUpdate(sc.TeamId, sc.ChannelId) - return deleted, nil + return scService.UnshareChannel(channelID) } // SharedChannelRemotes -func (a *App) InviteRemoteToChannel(channelID, remoteID, userID string) error { - syncService := a.Srv().GetSharedChannelSyncService() - if syncService == nil || !syncService.Active() { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.service_disabled", - nil, "", http.StatusBadRequest) - } - - hasRemote, err := a.HasRemote(channelID, remoteID) +func (a *App) InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error { + ssService, err := a.getSharedChannelsService() if err != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.fetch_remote.error", - map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError) + return err } - if hasRemote { - // already invited - return nil - } - - // Check if channel is shared or not. - hasChan, err := a.HasSharedChannel(channelID) - if err != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.check_channel_exist.error", - map[string]any{"ChannelID": channelID, "Error": err.Error()}, "", http.StatusInternalServerError) - } - if !hasChan { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_not_shared.error", - map[string]any{"ChannelID": channelID}, "", http.StatusBadRequest) - } - - // don't allow invitation to shared channel originating from remote. - // (also blocks cyclic invitations) - if err := a.CheckCanInviteToSharedChannel(channelID); err != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite_not_home.error", nil, "", http.StatusInternalServerError) - } - - rc, appErr := a.GetRemoteCluster(remoteID) - if appErr != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.remote_id_invalid.error", - map[string]any{"Error": appErr.Error()}, "", http.StatusInternalServerError).Wrap(appErr) - } - - channel, errApp := a.GetChannel(request.EmptyContext(a.Log()), channelID) - if errApp != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error", - map[string]any{"Name": rc.DisplayName, "Error": errApp.Error()}, "", http.StatusInternalServerError).Wrap(appErr) - } - // send channel invite to remote cluster. Will notify clients of channel change. - if err := syncService.SendChannelInvite(channel, userID, rc); err != nil { - return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error", - map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err) - } - return nil + return ssService.InviteRemoteToChannel(channelID, remoteID, userID, shareIfNotShared) } func (a *App) UninviteRemoteFromChannel(channelID, remoteID string) error { - scr, err := a.GetSharedChannelRemoteByIds(channelID, remoteID) - if err != nil || scr.ChannelId != channelID { - return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.channel_remote_id_not_exists", - map[string]any{"RemoteId": remoteID}, "", http.StatusInternalServerError) + ssService, err := a.getSharedChannelsService() + if err != nil { + return err } - - deleted, err := a.Srv().Store().SharedChannel().DeleteRemote(scr.Id) - if err != nil || !deleted { - code := http.StatusInternalServerError - if err == nil { - err = errNotFound - code = http.StatusBadRequest - } - return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.could_not_uninvite.error", - map[string]any{"RemoteId": remoteID, "Error": err.Error()}, "", code) - } - return nil + return ssService.UninviteRemoteFromChannel(channelID, remoteID) } func (a *App) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) { diff --git a/server/channels/app/shared_channel_service_iface.go b/server/channels/app/shared_channel_service_iface.go index 485ae692a5..509e5464cb 100644 --- a/server/channels/app/shared_channel_service_iface.go +++ b/server/channels/app/shared_channel_service_iface.go @@ -17,27 +17,28 @@ type SharedChannelServiceIFace interface { NotifyUserProfileChanged(userID string) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error Active() bool + InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error + UninviteRemoteFromChannel(channelID, remoteID string) error + ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) + UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) + UnshareChannel(channelID string) (bool, error) + CheckChannelNotShared(channelID string) error + CheckChannelIsShared(channelID string) error + CheckCanInviteToSharedChannel(channelId string) error } -type MockOptionSharedChannelService func(service *mockSharedChannelService) - -func MockOptionSharedChannelServiceWithActive(active bool) MockOptionSharedChannelService { - return func(mrcs *mockSharedChannelService) { - mrcs.active = active - } -} - -func NewMockSharedChannelService(service SharedChannelServiceIFace, options ...MockOptionSharedChannelService) *mockSharedChannelService { - mrcs := &mockSharedChannelService{service, true, []string{}, []string{}, 0} - for _, option := range options { - option(mrcs) +func NewMockSharedChannelService(service SharedChannelServiceIFace) *mockSharedChannelService { + mrcs := &mockSharedChannelService{ + SharedChannelServiceIFace: service, + channelNotifications: []string{}, + userProfileNotifications: []string{}, + numInvitations: 0, } return mrcs } type mockSharedChannelService struct { SharedChannelServiceIFace - active bool channelNotifications []string userProfileNotifications []string numInvitations int @@ -45,26 +46,44 @@ type mockSharedChannelService struct { func (mrcs *mockSharedChannelService) NotifyChannelChanged(channelId string) { mrcs.channelNotifications = append(mrcs.channelNotifications, channelId) + if mrcs.SharedChannelServiceIFace != nil { + mrcs.SharedChannelServiceIFace.NotifyChannelChanged(channelId) + } } func (mrcs *mockSharedChannelService) NotifyUserProfileChanged(userId string) { mrcs.userProfileNotifications = append(mrcs.userProfileNotifications, userId) + if mrcs.SharedChannelServiceIFace != nil { + mrcs.SharedChannelServiceIFace.NotifyUserProfileChanged(userId) + } } func (mrcs *mockSharedChannelService) Shutdown() error { + if mrcs.SharedChannelServiceIFace != nil { + return mrcs.SharedChannelServiceIFace.Shutdown() + } return nil } func (mrcs *mockSharedChannelService) Start() error { + if mrcs.SharedChannelServiceIFace != nil { + return mrcs.SharedChannelServiceIFace.Start() + } return nil } func (mrcs *mockSharedChannelService) Active() bool { - return mrcs.active + if mrcs.SharedChannelServiceIFace != nil { + return mrcs.SharedChannelServiceIFace.Active() + } + return false } func (mrcs *mockSharedChannelService) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error { mrcs.numInvitations += 1 + if mrcs.SharedChannelServiceIFace != nil { + return mrcs.SharedChannelServiceIFace.SendChannelInvite(channel, userId, rc, options...) + } return nil } diff --git a/server/channels/app/shared_channel_test.go b/server/channels/app/shared_channel_test.go index 9ddcd181ae..4d6e3448cb 100644 --- a/server/channels/app/shared_channel_test.go +++ b/server/channels/app/shared_channel_test.go @@ -12,8 +12,15 @@ import ( "github.com/mattermost/mattermost/server/public/model" ) +func setupSharedChannels(tb testing.TB) *TestHelper { + return SetupConfig(tb, func(cfg *model.Config) { + *cfg.ExperimentalSettings.EnableRemoteClusterService = true + *cfg.ExperimentalSettings.EnableSharedChannels = true + }) +} + func TestApp_CheckCanInviteToSharedChannel(t *testing.T) { - th := Setup(t).InitBasic() + th := setupSharedChannels(t).InitBasic() channel1 := th.CreateChannel(th.Context, th.BasicTeam) channel2 := th.CreateChannel(th.Context, th.BasicTeam) diff --git a/server/channels/app/slashcommands/command_share.go b/server/channels/app/slashcommands/command_share.go index e1b3cc182b..91affe307a 100644 --- a/server/channels/app/slashcommands/command_share.go +++ b/server/channels/app/slashcommands/command_share.go @@ -232,6 +232,8 @@ func (sp *ShareProvider) doInviteRemote(a *app.App, c request.CTX, args *model.C } // Check if channel is shared or not. + // TODO: have the share channels service generate the "channel has been shared post" and this section can be removed since + // since `a.InviteRemoteToChannel` will share the channel automatically. hasChan, err := a.HasSharedChannel(args.ChannelId) if err != nil { return responsef(args.T("api.command_share.check_channel_exist.error", map[string]any{"ChannelID": args.ChannelId, "Error": err.Error()})) @@ -251,7 +253,7 @@ func (sp *ShareProvider) doInviteRemote(a *app.App, c request.CTX, args *model.C return responsef(args.T("api.command_share.remote_id_invalid.error", map[string]any{"Error": appErr.Error()})) } - if err = a.InviteRemoteToChannel(args.ChannelId, remoteID, args.UserId); err != nil { + if err = a.InviteRemoteToChannel(args.ChannelId, remoteID, args.UserId, true); err != nil { return responsef(appErr.Error()) } diff --git a/server/channels/app/slashcommands/command_share_test.go b/server/channels/app/slashcommands/command_share_test.go index 59bb153713..c42f7338f9 100644 --- a/server/channels/app/slashcommands/command_share_test.go +++ b/server/channels/app/slashcommands/command_share_test.go @@ -12,33 +12,32 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/testlib" "github.com/mattermost/mattermost/server/v8/channels/app" - "github.com/mattermost/mattermost/server/v8/platform/services/remotecluster" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" ) +func setupForSharedChannels(tb testing.TB) *TestHelper { + return setupConfig(tb, func(cfg *model.Config) { + *cfg.ExperimentalSettings.EnableRemoteClusterService = true + *cfg.ExperimentalSettings.EnableSharedChannels = true + }) +} + func TestShareProviderDoCommand(t *testing.T) { t.Run("share command sends a websocket channel converted event", func(t *testing.T) { - th := setup(t).initBasic() + th := setupForSharedChannels(t).initBasic() defer th.tearDown() th.addPermissionToRole(model.PermissionManageSharedChannels.Id, th.BasicUser.Roles) - mockSyncService := app.NewMockSharedChannelService(nil, app.MockOptionSharedChannelServiceWithActive(true)) + mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) th.Server.SetSharedChannelSyncService(mockSyncService) - remoteClusterService, err := remotecluster.NewRemoteClusterService(th.Server, th.App) - require.NoError(t, err) - th.Server.SetRemoteClusterService(remoteClusterService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) - err = remoteClusterService.Start() - require.NoError(t, err) - defer remoteClusterService.Shutdown() - commandProvider := ShareProvider{} channel := th.CreateChannel(th.BasicTeam, WithShared(false)) @@ -61,24 +60,17 @@ func TestShareProviderDoCommand(t *testing.T) { }) t.Run("unshare command sends a websocket channel converted event", func(t *testing.T) { - th := setup(t).initBasic() + th := setupForSharedChannels(t).initBasic() defer th.tearDown() th.addPermissionToRole(model.PermissionManageSharedChannels.Id, th.BasicUser.Roles) - mockSyncService := app.NewMockSharedChannelService(nil) + mockSyncService := app.NewMockSharedChannelService(th.Server.GetSharedChannelSyncService()) th.Server.SetSharedChannelSyncService(mockSyncService) - remoteClusterService, err := remotecluster.NewRemoteClusterService(th.Server, th.App) - require.NoError(t, err) - th.Server.SetRemoteClusterService(remoteClusterService) testCluster := &testlib.FakeClusterInterface{} th.Server.Platform().SetCluster(testCluster) - err = remoteClusterService.Start() - require.NoError(t, err) - defer remoteClusterService.Shutdown() - commandProvider := ShareProvider{} channel := th.CreateChannel(th.BasicTeam, WithShared(true)) args := &model.CommandArgs{ diff --git a/server/channels/app/slashcommands/helper_test.go b/server/channels/app/slashcommands/helper_test.go index ea0c4ec0f3..88161fa214 100644 --- a/server/channels/app/slashcommands/helper_test.go +++ b/server/channels/app/slashcommands/helper_test.go @@ -92,6 +92,18 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo IncludeCacheLayer: includeCacheLayer, } + if enterprise { + th.App.Srv().Jobs.StopWorkers() + th.App.Srv().Jobs.StopSchedulers() + + th.App.Srv().SetLicense(model.NewTestLicense()) + + th.App.Srv().Jobs.StartWorkers() + th.App.Srv().Jobs.StartSchedulers() + } else { + th.App.Srv().SetLicense(getLicense(false, memoryConfig)) + } + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.MaxUsersPerTeam = 50 }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.RateLimitSettings.Enable = false }) prevListenAddress := *th.App.Config().ServiceSettings.ListenAddress @@ -118,18 +130,6 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo *cfg.PasswordSettings.Number = false }) - if enterprise { - th.App.Srv().Jobs.StopWorkers() - th.App.Srv().Jobs.StopSchedulers() - - th.App.Srv().SetLicense(model.NewTestLicense()) - - th.App.Srv().Jobs.StartWorkers() - th.App.Srv().Jobs.StartSchedulers() - } else { - th.App.Srv().SetLicense(nil) - } - if th.tempWorkspace == "" { th.tempWorkspace = tempWorkspace } @@ -137,6 +137,16 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo return th } +func getLicense(enterprise bool, cfg *model.Config) *model.License { + if *cfg.ExperimentalSettings.EnableRemoteClusterService || *cfg.ExperimentalSettings.EnableSharedChannels { + return model.NewTestLicenseSKU(model.LicenseShortSkuProfessional) + } + if enterprise { + return model.NewTestLicense() + } + return nil +} + func setup(tb testing.TB) *TestHelper { if testing.Short() { tb.SkipNow() @@ -148,6 +158,17 @@ func setup(tb testing.TB) *TestHelper { return setupTestHelper(dbStore, false, true, tb, nil) } +func setupConfig(tb testing.TB, updateConfig func(cfg *model.Config)) *TestHelper { + if testing.Short() { + tb.SkipNow() + } + dbStore := mainHelper.GetStore() + dbStore.DropAllTables() + dbStore.MarkSystemRanUnitTests() + + return setupTestHelper(dbStore, false, true, tb, updateConfig) +} + var initBasicOnce sync.Once var userCache struct { SystemAdminUser *model.User diff --git a/server/platform/services/sharedchannel/mock_AppIface_test.go b/server/platform/services/sharedchannel/mock_AppIface_test.go index ac1872a125..0af65db307 100644 --- a/server/platform/services/sharedchannel/mock_AppIface_test.go +++ b/server/platform/services/sharedchannel/mock_AppIface_test.go @@ -288,11 +288,6 @@ func (_m *MockAppIface) GetProfileImage(user *model.User) ([]byte, bool, *model. return r0, r1, r2 } -// InvalidateCacheForUser provides a mock function with given fields: userID -func (_m *MockAppIface) InvalidateCacheForUser(userID string) { - _m.Called(userID) -} - // MentionsToTeamMembers provides a mock function with given fields: c, message, teamID func (_m *MockAppIface) MentionsToTeamMembers(c request.CTX, message string, teamID string) model.UserMentionMap { ret := _m.Called(c, message, teamID) @@ -410,6 +405,11 @@ func (_m *MockAppIface) PermanentDeleteChannel(c request.CTX, channel *model.Cha return r0 } +// Publish provides a mock function with given fields: message +func (_m *MockAppIface) Publish(message *model.WebSocketEvent) { + _m.Called(message) +} + // SaveReactionForPost provides a mock function with given fields: c, reaction func (_m *MockAppIface) SaveReactionForPost(c request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) { ret := _m.Called(c, reaction) diff --git a/server/platform/services/sharedchannel/service.go b/server/platform/services/sharedchannel/service.go index 0351d82650..0c5761a822 100644 --- a/server/platform/services/sharedchannel/service.go +++ b/server/platform/services/sharedchannel/service.go @@ -44,6 +44,11 @@ type ServerIface interface { GetRemoteClusterService() remotecluster.RemoteClusterServiceIFace } +type PlatformIface interface { + InvalidateCacheForUser(userID string) + InvalidateCacheForChannel(channel *model.Channel) +} + type AppIface interface { SendEphemeralPost(c request.CTX, userId string, post *model.Post) *model.Post CreateChannelWithUser(c request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError) @@ -61,11 +66,11 @@ type AppIface interface { FileReader(path string) (filestore.ReadCloseSeeker, *model.AppError) MentionsToTeamMembers(c request.CTX, message, teamID string) model.UserMentionMap GetProfileImage(user *model.User) ([]byte, bool, *model.AppError) - InvalidateCacheForUser(userID string) NotifySharedChannelUserUpdate(user *model.User) OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error + Publish(message *model.WebSocketEvent) } // errNotFound allows checking against Store.ErrNotFound errors without making Store a dependency. @@ -76,6 +81,7 @@ type errNotFound interface { // Service provides shared channel synchronization. type Service struct { server ServerIface + platform PlatformIface app AppIface changeSignal chan struct{} @@ -93,9 +99,10 @@ type Service struct { } // NewSharedChannelService creates a RemoteClusterService instance. -func NewSharedChannelService(server ServerIface, app AppIface) (*Service, error) { +func NewSharedChannelService(server ServerIface, platform PlatformIface, app AppIface) (*Service, error) { service := &Service{ server: server, + platform: platform, app: app, changeSignal: make(chan struct{}, 1), tasks: make(map[string]syncTask), @@ -245,3 +252,17 @@ func (scs *Service) onConnectionStateChange(rc *model.RemoteCluster, online bool mlog.Bool("online", online), ) } + +func (scs *Service) notifyClientsForSharedChannelConverted(channel *model.Channel) { + scs.platform.InvalidateCacheForChannel(channel) + messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelConverted, channel.TeamId, "", "", nil, "") + messageWs.Add("channel_id", channel.Id) + scs.app.Publish(messageWs) +} + +func (scs *Service) notifyClientsForSharedChannelUpdate(channel *model.Channel) { + scs.platform.InvalidateCacheForChannel(channel) + messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelUpdated, channel.TeamId, "", "", nil, "") + messageWs.Add("channel_id", channel.Id) + scs.app.Publish(messageWs) +} diff --git a/server/platform/services/sharedchannel/service_api.go b/server/platform/services/sharedchannel/service_api.go new file mode 100644 index 0000000000..6b425808a9 --- /dev/null +++ b/server/platform/services/sharedchannel/service_api.go @@ -0,0 +1,231 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sharedchannel + +import ( + "errors" + "fmt" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +// ShareChannel marks a local channel as shared. If the channel is already shared this method has +// no effect and returns without error. +// TeamId, type, displayname, purpose, and header are fetched from the channel if not provided. +func (scs *Service) ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error) { + channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true) + if err != nil { + return nil, fmt.Errorf("cannot fetch channel while sharing channel %s: %w", sc.ChannelId, err) + } + + // check if channel is already shared + scExisting, err := scs.server.GetStore().SharedChannel().Get(sc.ChannelId) + if err == nil { + // already shared, nothing to do + return scExisting, nil + } + if !isNotFoundError(err) { + return nil, fmt.Errorf("cannot check if channel %s is shared: %w", sc.ChannelId, err) + } + + if sc.TeamId == "" { + sc.TeamId = channel.TeamId + } + if sc.Type == "" { + sc.Type = channel.Type + } + if sc.ShareName == "" { + sc.ShareName = channel.Name + } + if sc.ShareDisplayName == "" { + sc.ShareDisplayName = channel.DisplayName + } + if sc.SharePurpose == "" { + sc.SharePurpose = channel.Purpose + } + if sc.ShareHeader == "" { + sc.ShareHeader = channel.Header + } + if sc.CreatorId == "" { + sc.CreatorId = channel.CreatorId + } + + // stores the SharedChannel and sets the share flag on the channel. + scNew, err := scs.server.GetStore().SharedChannel().Save(sc) + if err != nil { + return nil, err + } + + scs.notifyClientsForSharedChannelConverted(channel) + return scNew, nil +} + +// UpdateSharedChannel updates the shared channel details such as displayname, purpose, or header +func (scs *Service) UpdateSharedChannel(sc *model.SharedChannel) (*model.SharedChannel, error) { + channel, err := scs.server.GetStore().Channel().Get(sc.ChannelId, true) + if err != nil { + return nil, err + } + + scUpdated, err := scs.server.GetStore().SharedChannel().Update(sc) + if err != nil { + return nil, err + } + + scs.notifyClientsForSharedChannelUpdate(channel) + return scUpdated, nil +} + +// UnshareChannel unshared the channel by deleting the SharedChannels record and unsets the Channel `shared` flag. +// Returns true if a shared channel existed and was deleted. +func (scs *Service) UnshareChannel(channelID string) (bool, error) { + channel, err := scs.server.GetStore().Channel().Get(channelID, true) + if err != nil { + return false, err + } + + // deletes the ShareChannel, unsets the share flag on the channel, deletes all records in SharedChannelRemotes for the channel. + deleted, err := scs.server.GetStore().SharedChannel().Delete(channelID) + if err != nil { + return false, err + } + + scs.notifyClientsForSharedChannelConverted(channel) + return deleted, nil +} + +// InviteRemoteToChannel sends an invite to the remote to a shared channel. If `shareIfNotShared` is true +// then the channel is marked as `shared` first if needed. +func (scs *Service) InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error { + scStore := scs.server.GetStore().SharedChannel() + rcStore := scs.server.GetStore().RemoteCluster() + + // check if remote already invited to channel + hasRemote, err := scStore.HasRemote(channelID, remoteID) + if err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.fetch_remote.error", + map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError) + } + if hasRemote { + // already invited, nothing to do + return nil + } + + // set the channel `shared` flag if needed + if shareIfNotShared { + if _, err = scs.ShareChannel(&model.SharedChannel{ChannelId: channelID, CreatorId: userID}); err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.share_channel.error", + map[string]any{"Error": err.Error()}, "", http.StatusBadRequest) + } + } else { + if err = scs.CheckChannelIsShared(channelID); err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_not_shared.error", + map[string]any{"ChannelID": channelID}, "", http.StatusBadRequest) + } + } + + rc, err := rcStore.Get(remoteID) + if err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.remote_id_invalid.error", + map[string]any{"Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err) + } + + // don't allow invitation to shared channel originating from remote. + // (also blocks cyclic invitations) + if err = scs.CheckCanInviteToSharedChannel(channelID); err != nil { + if errors.Is(err, model.ErrChannelHomedOnRemote) { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite_not_home.error", nil, "", http.StatusInternalServerError) + } + scs.server.Log().Debug("InviteRemoteToChannel failed to check if can-invite", + mlog.String("name", rc.Name), + mlog.String("channel_id", channelID), + mlog.Err(err), + ) + return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error", + map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "CheckCanInviteToSharedChannel", http.StatusInternalServerError).Wrap(err) + } + + channel, err := scs.server.GetStore().Channel().Get(channelID, true) + if err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error", + map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err) + } + // send channel invite to remote cluster. Will notify clients of channel change. + if err := scs.SendChannelInvite(channel, userID, rc); err != nil { + return model.NewAppError("InviteRemoteToChannel", "api.command_share.channel_invite.error", + map[string]any{"Name": rc.DisplayName, "Error": err.Error()}, "", http.StatusInternalServerError).Wrap(err) + } + return nil +} + +func (scs *Service) UninviteRemoteFromChannel(channelID, remoteID string) error { + scr, err := scs.server.GetStore().SharedChannel().GetRemoteByIds(channelID, remoteID) + if err != nil || scr.ChannelId != channelID { + return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.channel_remote_id_not_exists", + map[string]any{"RemoteId": remoteID}, "", http.StatusInternalServerError) + } + + deleted, err := scs.server.GetStore().SharedChannel().DeleteRemote(scr.Id) + if err != nil || !deleted { + code := http.StatusInternalServerError + if err == nil { + err = errors.New("not found") + code = http.StatusBadRequest + } + return model.NewAppError("UninviteRemoteFromChannel", "api.command_share.could_not_uninvite.error", + map[string]any{"RemoteId": remoteID, "Error": err.Error()}, "", code) + } + return nil +} + +// CheckChannelNotShared returns nil only if the channel is not already shared. Otherwise ErrChannelAlreadyShared is +// returned if the channel is shared, or database error. +func (scs *Service) CheckChannelNotShared(channelID string) error { + // check that channel exists. + if _, err := scs.server.GetStore().Channel().Get(channelID, true); err != nil { + return fmt.Errorf("cannot find channel %s: %w", channelID, err) + } + + // Check channel is not already shared. + if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err == nil { + return model.ErrChannelAlreadyShared + } + + return nil +} + +// CheckChannelIsShared returns nil only if the channel is shared. Otherwise a store.ErrNotFound is returned +// or database error. +func (scs *Service) CheckChannelIsShared(channelID string) error { + if _, err := scs.server.GetStore().SharedChannel().Get(channelID); err != nil { + var errNotFound *store.ErrNotFound + if errors.As(err, &errNotFound) { + return fmt.Errorf("channel is not shared: %w", errNotFound) + } + return fmt.Errorf("cannot check if channel %s is shared: %w", channelID, err) + } + return nil +} + +// CheckCanInviteToSharedChannel checks if an invitation can be sent for the specified channel. +// - don't allow invitations to a shared channel originating from remote. +// - block cyclic invitations +// - the channel must exist +func (scs *Service) CheckCanInviteToSharedChannel(channelId string) error { + sc, err := scs.server.GetStore().SharedChannel().Get(channelId) + if err != nil { + if isNotFoundError(err) { + return fmt.Errorf("channel is not shared: %w", err) + } + return fmt.Errorf("cannot find channel: %w", err) + } + + if !sc.Home { + return model.ErrChannelHomedOnRemote + } + return nil +} diff --git a/server/platform/services/sharedchannel/sync_recv.go b/server/platform/services/sharedchannel/sync_recv.go index 5fd08e2fa4..faf6f78f81 100644 --- a/server/platform/services/sharedchannel/sync_recv.go +++ b/server/platform/services/sharedchannel/sync_recv.go @@ -317,7 +317,7 @@ func (scs *Service) updateSyncUser(rctx request.CTX, patch *model.UserPatch, use ) } } else { - scs.app.InvalidateCacheForUser(update.New.Id) + scs.platform.InvalidateCacheForUser(update.New.Id) scs.app.NotifySharedChannelUserUpdate(update.New) return update.New, nil } diff --git a/server/public/model/shared_channel.go b/server/public/model/shared_channel.go index 842f7acc01..336a5b08ed 100644 --- a/server/public/model/shared_channel.go +++ b/server/public/model/shared_channel.go @@ -7,6 +7,13 @@ import ( "encoding/json" "net/http" "unicode/utf8" + + "github.com/pkg/errors" +) + +var ( + ErrChannelAlreadyShared = errors.New("channel is already shared") + ErrChannelHomedOnRemote = errors.New("channel is homed on a remote cluster") ) // SharedChannel represents a channel that can be synchronized with a remote cluster. diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index d0f5b408af..edc9bd0078 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -1279,10 +1279,11 @@ type API interface { // InviteRemoteToChannel invites a remote, or this plugin, as a target for synchronizing. Once invited, the // remote will start to receive synchronization messages for any changed content in the specified channel. + // If `shareIfNotShared` is true, the channel's shared flag will be set, if not already. // // @tag SharedChannels // Minimum server version: 9.5 - InviteRemoteToChannel(channelID string, remoteID string, userID string) error + InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error // UninviteRemoteFromChannel uninvites a remote, or this plugin, such that it will stop receiving sychronization // messages for the channel. diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index 66754c0b70..a680249f0b 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -1351,9 +1351,9 @@ func (api *apiTimerLayer) SyncSharedChannel(channelID string) error { return _returnsA } -func (api *apiTimerLayer) InviteRemoteToChannel(channelID string, remoteID string, userID string) error { +func (api *apiTimerLayer) InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error { startTime := timePkg.Now() - _returnsA := api.apiImpl.InviteRemoteToChannel(channelID, remoteID, userID) + _returnsA := api.apiImpl.InviteRemoteToChannel(channelID, remoteID, userID, shareIfNotShared) api.recordTime(startTime, "InviteRemoteToChannel", _returnsA == nil) return _returnsA } diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index 68587c4e2e..084664d2fa 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -6478,14 +6478,15 @@ type Z_InviteRemoteToChannelArgs struct { A string B string C string + D bool } type Z_InviteRemoteToChannelReturns struct { A error } -func (g *apiRPCClient) InviteRemoteToChannel(channelID string, remoteID string, userID string) error { - _args := &Z_InviteRemoteToChannelArgs{channelID, remoteID, userID} +func (g *apiRPCClient) InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error { + _args := &Z_InviteRemoteToChannelArgs{channelID, remoteID, userID, shareIfNotShared} _returns := &Z_InviteRemoteToChannelReturns{} if err := g.client.Call("Plugin.InviteRemoteToChannel", _args, _returns); err != nil { log.Printf("RPC call to InviteRemoteToChannel API failed: %s", err.Error()) @@ -6495,9 +6496,9 @@ func (g *apiRPCClient) InviteRemoteToChannel(channelID string, remoteID string, func (s *apiRPCServer) InviteRemoteToChannel(args *Z_InviteRemoteToChannelArgs, returns *Z_InviteRemoteToChannelReturns) error { if hook, ok := s.impl.(interface { - InviteRemoteToChannel(channelID string, remoteID string, userID string) error + InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error }); ok { - returns.A = hook.InviteRemoteToChannel(args.A, args.B, args.C) + returns.A = hook.InviteRemoteToChannel(args.A, args.B, args.C, args.D) returns.A = encodableError(returns.A) } else { return encodableError(fmt.Errorf("API InviteRemoteToChannel called but not implemented.")) diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 9531936685..75fa5ec6d5 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -2806,13 +2806,13 @@ func (_m *API) InstallPlugin(file io.Reader, replace bool) (*model.Manifest, *mo return r0, r1 } -// InviteRemoteToChannel provides a mock function with given fields: channelID, remoteID, userID -func (_m *API) InviteRemoteToChannel(channelID string, remoteID string, userID string) error { - ret := _m.Called(channelID, remoteID, userID) +// InviteRemoteToChannel provides a mock function with given fields: channelID, remoteID, userID, shareIfNotShared +func (_m *API) InviteRemoteToChannel(channelID string, remoteID string, userID string, shareIfNotShared bool) error { + ret := _m.Called(channelID, remoteID, userID, shareIfNotShared) var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(channelID, remoteID, userID) + if rf, ok := ret.Get(0).(func(string, string, string, bool) error); ok { + r0 = rf(channelID, remoteID, userID, shareIfNotShared) } else { r0 = ret.Error(0) }