Shared channels auto-share DM & group messages (#26097)

* option for auto inviting plugin to all shared channels.
* auto-invite remotes to shared channels when flag set
This commit is contained in:
Doug Lauder 2024-02-09 10:47:12 -05:00 committed by GitHub
parent 7e419e98ee
commit 7b7bcff821
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 525 additions and 323 deletions

View File

@ -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()

View File

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

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

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

View File

@ -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{}
}

View File

@ -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) {

View File

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

View File

@ -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 err
}
return fmt.Errorf("cannot find channel: %w", 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) {

View File

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

View File

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

View File

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

View File

@ -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{

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

@ -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.

View File

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

View File

@ -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."))

View File

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