Denial of service in mattermost mobile apps and server via emoji reactions
Description
Mattermost fails to check if a custom emoji reaction exists when sending it to a post and to limit the amount of custom emojis allowed to be added in a post, allowing an attacker sending a huge amount of non-existent custom emojis in a post to crash the mobile app of a user seeing the post and to crash the server due to overloading when clients attempt to retrive the aforementioned post.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/mattermost/mattermost/server/v8Go | < 8.1.8 | 8.1.8 |
github.com/mattermost/mattermost/server/v8Go | >= 9.2.0, < 9.2.4 | 9.2.4 |
github.com/mattermost/mattermost/server/v8Go | >= 9.1.0, < 9.1.5 | 9.1.5 |
Affected products
1- Range: 0
Patches
364cb0ca8af2dCherry-pick of #25331 (#25589)
24 files changed · +657 −30
e2e-tests/playwright/support/server/default_config.ts+1 −0 modified@@ -177,6 +177,7 @@ const defaultServerConfig: AdminConfig = { PersistentNotificationMaxRecipients: 5, PersistentNotificationIntervalMinutes: 5, AllowPersistentNotificationsForGuests: false, + UniqueEmojiReactionLimitPerPost: 25, }, TeamSettings: { SiteName: 'Mattermost',
server/channels/api4/reaction_test.go+3 −3 modified@@ -55,7 +55,7 @@ func TestSaveReaction(t *testing.T) { }) t.Run("save-second-reaction", func(t *testing.T) { - reaction.EmojiName = "sad" + reaction.EmojiName = "cry" rr, _, err := client.SaveReaction(context.Background(), reaction) require.NoError(t, err) @@ -290,7 +290,7 @@ func TestDeleteReaction(t *testing.T) { r2 := &model.Reaction{ UserId: userId, PostId: postId, - EmojiName: "smile-", + EmojiName: "cry", } r3 := &model.Reaction{ @@ -302,7 +302,7 @@ func TestDeleteReaction(t *testing.T) { r4 := &model.Reaction{ UserId: user2Id, PostId: postId, - EmojiName: "smile_", + EmojiName: "grin", } // Check the appropriate permissions are enforced.
server/channels/app/export_test.go+6 −4 modified@@ -29,18 +29,20 @@ func TestReactionsOfPost(t *testing.T) { reactionObject := model.Reaction{ UserId: th.BasicUser.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } reactionObjectDeleted := model.Reaction{ UserId: th.BasicUser2.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } - th.App.SaveReactionForPost(th.Context, &reactionObject) - th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + _, err := th.App.SaveReactionForPost(th.Context, &reactionObject) + require.Nil(t, err) + _, err = th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + require.Nil(t, err) reactionsOfPost, err := th.App.BuildPostReactions(th.Context, post.Id) require.Nil(t, err)
server/channels/app/reaction.go+24 −0 modified@@ -20,6 +20,30 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) return nil, err } + // Check whether this is a valid emoji + if _, ok := model.GetSystemEmojiId(reaction.EmojiName); !ok { + if _, emojiErr := a.GetEmojiByName(c, reaction.EmojiName); emojiErr != nil { + return nil, emojiErr + } + } + + existing, dErr := a.Srv().Store().Reaction().ExistsOnPost(reaction.PostId, reaction.EmojiName) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + // If it exists already, we don't need to check for the limit + if !existing { + count, dErr := a.Srv().Store().Reaction().GetUniqueCountForPost(reaction.PostId) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + if count >= *a.Config().ServiceSettings.UniqueEmojiReactionLimitPerPost { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.too_many_reactions", nil, "", http.StatusBadRequest) + } + } + channel, err := a.GetChannel(c, post.ChannelId) if err != nil { return nil, err
server/channels/app/reaction_test.go+95 −0 modified@@ -14,6 +14,89 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/testlib" ) +func TestSaveReactionForPost(t *testing.T) { + th := Setup(t).InitBasic() + + post := th.CreatePost(th.BasicChannel) + reaction1, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "cry", + }) + require.NotNil(t, reaction1) + require.Nil(t, err) + reaction2, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "smile", + }) + require.NotNil(t, reaction2) + require.Nil(t, err) + reaction3, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "rofl", + }) + require.NotNil(t, reaction3) + require.Nil(t, err) + + t.Run("should not add reaction if it does not exist on the system", func(t *testing.T) { + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: th.BasicPost.Id, + EmojiName: "definitely-not-a-real-emoji", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should not add reaction if we are over the limit", func(t *testing.T) { + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "joy", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should always add reaction if we are over the limit but the reaction is not unique", func(t *testing.T) { + user := th.CreateUser() + + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: user.Id, + PostId: post.Id, + EmojiName: "cry", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.Nil(t, err) + require.NotNil(t, result) + }) +} + 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() @@ -429,3 +512,15 @@ func TestGetTopReactionsForUserSince(t *testing.T) { assert.NotNil(t, err) }) } + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.ConfigStore.IsReadOnly() { + return + } + old := th.ConfigStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.ConfigStore.Set(updated); err != nil { + panic(err) + } +}
server/channels/store/opentracinglayer/opentracinglayer.go+36 −0 modified@@ -7317,6 +7317,24 @@ func (s *OpenTracingLayerReactionStore) DeleteOrphanedRowsByIds(r *model.Retenti return err } +func (s *OpenTracingLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.ExistsOnPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetForPost") @@ -7389,6 +7407,24 @@ func (s *OpenTracingLayerReactionStore) GetTopForUserSince(userID string, teamID return result, err } +func (s *OpenTracingLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetUniqueCountForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.PermanentDeleteBatch")
server/channels/store/retrylayer/retrylayer.go+42 −0 modified@@ -8306,6 +8306,27 @@ func (s *RetryLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF } +func (s *RetryLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + + tries := 0 + for { + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { tries := 0 @@ -8390,6 +8411,27 @@ func (s *RetryLayerReactionStore) GetTopForUserSince(userID string, teamID strin } +func (s *RetryLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + + tries := 0 + for { + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { tries := 0
server/channels/store/sqlstore/reaction_store.go+35 −0 modified@@ -4,6 +4,7 @@ package sqlstore import ( + "database/sql" "time" sq "github.com/mattermost/squirrel" @@ -107,6 +108,25 @@ func (s *SqlReactionStore) GetForPost(postId string, allowFromCache bool) ([]*mo return reactions, nil } +func (s *SqlReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + query := s.getQueryBuilder(). + Select("1"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"EmojiName": emojiName}). + Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0}) + + var hasRows bool + if err := s.GetReplicaX().GetBuilder(&hasRows, query); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to check for existing reaction") + } + + return hasRows, nil +} + // GetForPostSince returns all reactions associated with `postId` updated after `since`. func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) { query := s.getQueryBuilder(). @@ -138,6 +158,21 @@ func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRe return reactions, nil } +func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) { + query := s.getQueryBuilder(). + Select("COUNT(DISTINCT EmojiName)"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"DeleteAt": 0}) + + var count int64 + err := s.GetReplicaX().GetBuilder(&count, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count Reactions") + } + return int(count), nil +} + func (s *SqlReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { placeholder, values := constructArrayArgs(postIds) var reactions []*model.Reaction
server/channels/store/store.go+2 −0 modified@@ -724,6 +724,8 @@ type ReactionStore interface { Delete(reaction *model.Reaction) (*model.Reaction, error) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) + GetUniqueCountForPost(postId string) (int, error) + ExistsOnPost(postId string, emojiName string) (bool, error) DeleteAllWithEmojiName(emojiName string) error BulkGetForPosts(postIds []string) ([]*model.Reaction, error) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) error
server/channels/store/storetest/mocks/ReactionStore.go+48 −0 modified@@ -94,6 +94,30 @@ func (_m *ReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletio return r0 } +// ExistsOnPost provides a mock function with given fields: postId, emojiName +func (_m *ReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + ret := _m.Called(postId, emojiName) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (bool, error)); ok { + return rf(postId, emojiName) + } + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(postId, emojiName) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postId, emojiName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetForPost provides a mock function with given fields: postID, allowFromCache func (_m *ReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { ret := _m.Called(postID, allowFromCache) @@ -198,6 +222,30 @@ func (_m *ReactionStore) GetTopForUserSince(userID string, teamID string, since return r0, r1 } +// GetUniqueCountForPost provides a mock function with given fields: postId +func (_m *ReactionStore) GetUniqueCountForPost(postId string) (int, error) { + ret := _m.Called(postId) + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(string) (int, error)); ok { + return rf(postId) + } + if rf, ok := ret.Get(0).(func(string) int); ok { + r0 = rf(postId) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PermanentDeleteBatch provides a mock function with given fields: endTime, limit func (_m *ReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { ret := _m.Called(endTime, limit)
server/channels/store/storetest/reaction_store.go+65 −0 modified@@ -28,6 +28,8 @@ func TestReactionStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("PermanentDeleteBatch", func(t *testing.T) { testReactionStorePermanentDeleteBatch(t, ss) }) t.Run("ReactionBulkGetForPosts", func(t *testing.T) { testReactionBulkGetForPosts(t, ss) }) t.Run("ReactionDeadlock", func(t *testing.T) { testReactionDeadlock(t, ss) }) + t.Run("ExistsOnPost", func(t *testing.T) { testExistsOnPost(t, ss) }) + t.Run("GetUniqueCountForPost", func(t *testing.T) { testGetUniqueCountForPost(t, ss) }) } func testReactionSave(t *testing.T, ss store.Store) { @@ -876,3 +878,66 @@ func testReactionDeadlock(t *testing.T, ss store.Store) { }() wg.Wait() } + +func testExistsOnPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + emojiName := model.NewId() + reaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + exists, err := ss.Reaction().ExistsOnPost(post.Id, emojiName) + require.NoError(t, err) + require.True(t, exists) + exists, err = ss.Reaction().ExistsOnPost(post.Id, model.NewId()) + require.NoError(t, err) + require.False(t, exists) +} + +func testGetUniqueCountForPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + + userId := model.NewId() + emojiName := model.NewId() + + reaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + + sameReaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr = ss.Reaction().Save(sameReaction) + require.NoError(t, nErr) + + newReaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: model.NewId(), + } + _, nErr = ss.Reaction().Save(newReaction) + require.NoError(t, nErr) + + totalReactions, err := ss.Reaction().GetForPost(post.Id, false) + require.NoError(t, err) + require.Equal(t, 3, len(totalReactions)) + + count, err := ss.Reaction().GetUniqueCountForPost(post.Id) + require.NoError(t, err) + require.Equal(t, 2, count) +}
server/channels/store/timerlayer/timerlayer.go+32 −0 modified@@ -6612,6 +6612,22 @@ func (s *TimerLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF return err } +func (s *TimerLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + start := time.Now() + + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.ExistsOnPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { start := time.Now() @@ -6676,6 +6692,22 @@ func (s *TimerLayerReactionStore) GetTopForUserSince(userID string, teamID strin return result, err } +func (s *TimerLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + start := time.Now() + + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.GetUniqueCountForPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { start := time.Now()
server/config/client.go+1 −0 modified@@ -144,6 +144,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10) props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete) + props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
server/i18n/en.json+4 −0 modified@@ -6399,6 +6399,10 @@ "id": "app.recent_searches.app_error", "translation": "Error fetching recent searches" }, + { + "id": "app.reaction.save.save.too_many_reactions", + "translation": "Reaction limit has been reached for this post." + }, { "id": "app.recover.delete.app_error", "translation": "Unable to delete token."
server/public/model/config.go+24 −13 modified@@ -100,19 +100,21 @@ const ( SitenameMaxLength = 30 - ServiceSettingsDefaultSiteURL = "http://localhost:8065" - ServiceSettingsDefaultTLSCertFile = "" - ServiceSettingsDefaultTLSKeyFile = "" - ServiceSettingsDefaultReadTimeout = 300 - ServiceSettingsDefaultWriteTimeout = 300 - ServiceSettingsDefaultIdleTimeout = 60 - ServiceSettingsDefaultMaxLoginAttempts = 10 - ServiceSettingsDefaultAllowCorsFrom = "" - ServiceSettingsDefaultListenAndAddress = ":8065" - ServiceSettingsDefaultGfycatAPIKey = "2_KtH_W5" - ServiceSettingsDefaultGfycatAPISecret = "3wLVZPiswc3DnaiaFoLkDvB4X0IV6CpMkj4tf2inJRsBY6-FnkT08zGmppWFgeof" - ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" - ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultSiteURL = "http://localhost:8065" + ServiceSettingsDefaultTLSCertFile = "" + ServiceSettingsDefaultTLSKeyFile = "" + ServiceSettingsDefaultReadTimeout = 300 + ServiceSettingsDefaultWriteTimeout = 300 + ServiceSettingsDefaultIdleTimeout = 60 + ServiceSettingsDefaultMaxLoginAttempts = 10 + ServiceSettingsDefaultAllowCorsFrom = "" + ServiceSettingsDefaultListenAndAddress = ":8065" + ServiceSettingsDefaultGfycatAPIKey = "2_KtH_W5" + ServiceSettingsDefaultGfycatAPISecret = "3wLVZPiswc3DnaiaFoLkDvB4X0IV6CpMkj4tf2inJRsBY6-FnkT08zGmppWFgeof" + ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" + ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultUniqueReactionsPerPost = 50 + ServiceSettingsMaxUniqueReactionsPerPost = 500 TeamSettingsDefaultSiteName = "Mattermost" TeamSettingsDefaultMaxUsersPerTeam = 50 @@ -398,6 +400,7 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` + UniqueEmojiReactionLimitPerPost *int `access:"site_posts"` } var MattermostGiphySdkKey string @@ -896,6 +899,14 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } + + if s.UniqueEmojiReactionLimitPerPost == nil { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsDefaultUniqueReactionsPerPost) + } + + if *s.UniqueEmojiReactionLimitPerPost > ServiceSettingsMaxUniqueReactionsPerPost { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsMaxUniqueReactionsPerPost) + } } type ClusterSettings struct {
webapp/channels/src/actions/post_actions.test.ts+40 −7 modified@@ -11,6 +11,7 @@ import {Posts} from 'mattermost-redux/constants'; import * as Actions from 'actions/post_actions'; import {Constants, ActionTypes, RHSStates} from 'utils/constants'; +import * as PostUtils from 'utils/post_utils'; import mockStore from 'tests/test_store'; @@ -47,6 +48,12 @@ jest.mock('utils/user_agent', () => ({ isDesktopApp: jest.fn().mockReturnValue(false), })); +jest.mock('utils/post_utils', () => ({ + makeGetUniqueEmojiNameReactionsForPost: jest.fn(), +})); + +const mockMakeGetUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost as unknown as jest.Mock<() => string[]>; + const POST_CREATED_TIME = Date.now(); // This mocks the Date.now() function so it returns a constant value @@ -432,14 +439,40 @@ describe('Actions.Posts', () => { }); }); - test('addReaction', async () => { - const testStore = mockStore(initialState); + describe('addReaction', () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => []); - await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); - expect(testStore.getActions()).toEqual([ - {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, - {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, - ]); + test('should add reaction', async () => { + const testStore = mockStore(initialState); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); + test('should not add reaction if we are over the limit', async () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => ['another_emoji']); + const testStore = mockStore({ + ...initialState, + entities: { + ...initialState.entities, + general: { + ...initialState.entities.general, + config: { + ...initialState.entities.general.config, + UniqueEmojiReactionLimitPerPost: '1', + }, + }, + }, + }); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).not.toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); }); test('flagPost', async () => {
webapp/channels/src/actions/post_actions.ts+28 −3 modified@@ -11,29 +11,36 @@ import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'matt import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import * as ThreadActions from 'mattermost-redux/actions/threads'; import * as PostActions from 'mattermost-redux/actions/posts'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils'; -import {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions'; +import {closeModal, openModal} from 'actions/views/modals'; import * as RhsActions from 'actions/views/rhs'; import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; import {removeDraft} from 'actions/views/drafts'; import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts'; import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs'; import {getGlobalItem} from 'selectors/storage'; import {GlobalState} from 'types/store'; + +import ReactionLimitReachedModal from 'components/reaction_limit_reached_modal'; + import { ActionTypes, Constants, + ModalIdentifiers, RHSStates, StoragePrefixes, } from 'utils/constants'; import {matchEmoticons} from 'utils/emoticons'; +import {makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils'; import * as UserAgent from 'utils/user_agent'; import {completePostReceive, NewPostMessageProps} from './new_post'; @@ -138,7 +145,25 @@ function storeCommentDraft(rootPostId: string, draft: null) { } export function addReaction(postId: string, emojiName: string) { - return (dispatch: DispatchFunc) => { + const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost(); + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const config = getConfig(state); + const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? []; + + // If we're adding a new reaction but we're already at or over the limit, stop + if (uniqueEmojiNames.length >= Number(config.UniqueEmojiReactionLimitPerPost) && !uniqueEmojiNames.some((name) => name === emojiName)) { + dispatch(openModal({ + modalId: ModalIdentifiers.REACTION_LIMIT_REACHED, + dialogType: ReactionLimitReachedModal, + dialogProps: { + isAdmin: isCurrentUserSystemAdmin(state), + onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED), + }, + })); + return {data: false}; + } + dispatch(PostActions.addReaction(postId, emojiName)); dispatch(addRecentEmoji(emojiName)); return {data: true};
webapp/channels/src/components/admin_console/admin_definition.jsx+31 −0 modified@@ -225,6 +225,7 @@ export const it = { export const validators = { isRequired: (text, textDefault) => (value) => new ValidationResult(Boolean(value), text, textDefault), minValue: (min, text, textDefault) => (value) => new ValidationResult((value >= min), text, textDefault), + maxValue: (max, text, textDefault) => (value) => new ValidationResult((value <= max), text, textDefault), }; const usesLegacyOauth = (config, state, license, enterpriseReady, consoleAccess, cloud) => { @@ -3079,6 +3080,36 @@ const AdminDefinition = { help_text_default: 'When enabled, users message drafts will sync with the server so they can be accessed from any device. Users may opt out of this behaviour in Account settings.', help_text_markdown: false, }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.UniqueEmojiReactionLimitPerPost', + label: t('admin.customization.uniqueEmojiReactionLimitPerPost'), + label_default: 'Unique Emoji Reaction Limit:', + placeholder: t('admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder'), + placeholder_default: 'E.g.: 25', + help_text: t('admin.customization.uniqueEmojiReactionLimitPerPostDesc'), + help_text_default: 'The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.', + help_text_markdown: false, + validate: (value) => { + const maxResult = validators.maxValue( + 500, + t('admin.customization.uniqueEmojiReactionLimitPerPost.maxValue'), + 'Cannot increase the limit to a value above 500.', + )(value); + if (!maxResult.isValid()) { + return maxResult; + } + const minResult = validators.minValue(0, + t('admin.customization.uniqueEmojiReactionLimitPerPost.minValue'), + 'Cannot decrease the limit below 0.', + )(value); + if (!minResult.isValid()) { + return minResult; + } + + return new ValidationResult(true, '', ''); + }, + }, ], }, },
webapp/channels/src/components/reaction_limit_reached_modal.tsx+63 −0 added@@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {Link} from 'react-router-dom'; + +import {GenericModal} from '@mattermost/components'; + +import ExternalLink from 'components/external_link'; + +export default function ReactionLimitReachedModal(props: {isAdmin: boolean; onExited: () => void}) { + const body = props.isAdmin ? ( + <FormattedMessage + id='reaction_limit_reached_modal.body.admin' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>." + values={{ + link: (msg: React.ReactNode) => ( + <ExternalLink + href='https://mattermost.com/pl/configure-unique-emoji-reaction-limit' + > + {msg} + </ExternalLink> + ), + linkAdmin: (msg: React.ReactNode) => ( + <Link + onClick={props.onExited} + to='/admin_console' + > + {msg} + </Link> + ), + }} + /> + ) : ( + <FormattedMessage + id='reaction_limit_reached_modal.body' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit." + /> + ); + + return ( + <GenericModal + modalHeaderText={ + <FormattedMessage + id='reaction_limit_reached_modal.title' + defaultMessage="You've reached the reaction limit" + /> + } + compassDesign={true} + confirmButtonText={ + <FormattedMessage + id='generic.okay' + defaultMessage='Okay' + /> + } + onExited={props.onExited} + handleConfirm={props.onExited} + > + {body} + </GenericModal> + ); +}
webapp/channels/src/i18n/en.json+9 −0 modified@@ -677,6 +677,11 @@ "admin.customization.restrictLinkPreviewsDesc": "Link previews and image link previews will not be shown for the above list of comma-separated domains.", "admin.customization.restrictLinkPreviewsExample": "E.g.: \"internal.mycompany.com, images.example.com\"", "admin.customization.restrictLinkPreviewsTitle": "Disable website link previews from these domains:", + "admin.customization.uniqueEmojiReactionLimitPerPost": "Unique Emoji Reaction Limit:", + "admin.customization.uniqueEmojiReactionLimitPerPost.maxValue": "Cannot increase the limit to a value above 500.", + "admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Cannot decrease the limit below 0.", + "admin.customization.uniqueEmojiReactionLimitPerPostDesc": "The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.", + "admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder": "E.g.: 25", "admin.data_grid.empty": "No items found", "admin.data_grid.loading": "Loading", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}", @@ -3606,6 +3611,7 @@ "generic.close": "Close", "generic.done": "Done", "generic.next": "Next", + "generic.okay": "Okay", "generic.previous": "Previous", "get_app.continueToBrowser": "View in Browser", "get_app.dontHaveTheDesktopApp": "Don't have the Desktop App?", @@ -4579,6 +4585,9 @@ "quick_switch_modal.help_no_team": "Type to find a channel. Use **UP/DOWN** to browse, **ENTER** to select, **ESC** to dismiss.", "quick_switch_modal.input": "quick switch input", "quick_switch_modal.switchChannels": "Find Channels", + "reaction_limit_reached_modal.body": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit.", + "reaction_limit_reached_modal.body.admin": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>.", + "reaction_limit_reached_modal.title": "You've reached the reaction limit", "reaction_list.addReactionTooltip": "Add a reaction", "reaction.add.ariaLabel": "Add a reaction", "reaction.clickToAdd": "(click to add)",
webapp/channels/src/utils/constants.tsx+1 −0 modified@@ -460,6 +460,7 @@ export const ModalIdentifiers = { SELF_HOSTED_EXPANSION: 'self_hosted_expansion', START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', + REACTION_LIMIT_REACHED: 'reaction_limit_reached', }; export const UserStatuses = {
webapp/channels/src/utils/post_utils.test.tsx+40 −0 modified@@ -1118,3 +1118,43 @@ describe('PostUtils.getPostURL', () => { expect(PostUtils.getPostURL(state, postCase)).toBe(expected); }); }); + +describe('makeGetUniqueEmojiNameReactionsForPost', () => { + const baseState = { + entities: { + posts: { + reactions: { + post_id_1: { + user_1_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_2_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_3_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_1_post_id_1_cry: { + emoji_name: 'cry', + post_id: 'post_id_1', + }, + }, + + }, + }, + general: { + config: {}, + }, + emojis: {}, + }, + } as unknown as GlobalState; + + test('should only return names of unique reactions', () => { + const getUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost(); + + expect(getUniqueEmojiNameReactionsForPost(baseState, 'post_id_1')).toEqual(['smile', 'cry']); + }); +});
webapp/channels/src/utils/post_utils.ts+25 −0 modified@@ -705,6 +705,31 @@ export function makeGetUniqueReactionsToPost(): (state: GlobalState, postId: Pos ); } +export function makeGetUniqueEmojiNameReactionsForPost(): (state: GlobalState, postId: Post['id']) => string[] | undefined | null { + const getReactionsForPost = makeGetReactionsForPost(); + + return createSelector( + 'makeGetUniqueEmojiReactionsForPost', + (state: GlobalState, postId: string) => getReactionsForPost(state, postId), + getEmojiMap, + (reactions, emojiMap) => { + if (!reactions) { + return null; + } + + const emojiNames: string[] = []; + + Object.values(reactions).forEach((reaction) => { + if (emojiMap.get(reaction.emoji_name) && !emojiNames.includes(reaction.emoji_name)) { + emojiNames.push(reaction.emoji_name); + } + }); + + return emojiNames; + }, + ); +} + export function getUserOrGroupFromMentionName(usersByUsername: Record<string, UserProfile | Group>, mentionName: string) { let mentionNameToLowerCase = mentionName.toLowerCase();
webapp/platform/types/src/config.ts+2 −0 modified@@ -205,6 +205,7 @@ export type ClientConfig = { AllowPersistentNotificationsForGuests: string; DelayChannelAutocomplete: 'true' | 'false'; ServiceEnvironment: string; + UniqueEmojiReactionLimitPerPost: string; }; export type License = { @@ -385,6 +386,7 @@ export type ServiceSettings = { PersistentNotificationIntervalMinutes: number; PersistentNotificationMaxCount: number; PersistentNotificationMaxRecipients: number; + UniqueEmojiReactionLimitPerPost: number; }; export type TeamSettings = {
81190e2da128[MM-55143] Disallow reacting with an emoji that does not exist, limit the total number of unique reactions per post (#25331) (#25574) (#25576)
24 files changed · +652 −27
e2e-tests/playwright/support/server/default_config.ts+1 −0 modified@@ -175,6 +175,7 @@ const defaultServerConfig: AdminConfig = { PersistentNotificationMaxRecipients: 5, PersistentNotificationIntervalMinutes: 5, AllowPersistentNotificationsForGuests: false, + UniqueEmojiReactionLimitPerPost: 25, }, TeamSettings: { SiteName: 'Mattermost',
server/channels/api4/reaction_test.go+3 −3 modified@@ -55,7 +55,7 @@ func TestSaveReaction(t *testing.T) { }) t.Run("save-second-reaction", func(t *testing.T) { - reaction.EmojiName = "sad" + reaction.EmojiName = "cry" rr, _, err := client.SaveReaction(context.Background(), reaction) require.NoError(t, err) @@ -290,7 +290,7 @@ func TestDeleteReaction(t *testing.T) { r2 := &model.Reaction{ UserId: userId, PostId: postId, - EmojiName: "smile-", + EmojiName: "cry", } r3 := &model.Reaction{ @@ -302,7 +302,7 @@ func TestDeleteReaction(t *testing.T) { r4 := &model.Reaction{ UserId: user2Id, PostId: postId, - EmojiName: "smile_", + EmojiName: "grin", } // Check the appropriate permissions are enforced.
server/channels/app/export_test.go+6 −4 modified@@ -29,18 +29,20 @@ func TestReactionsOfPost(t *testing.T) { reactionObject := model.Reaction{ UserId: th.BasicUser.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } reactionObjectDeleted := model.Reaction{ UserId: th.BasicUser2.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } - th.App.SaveReactionForPost(th.Context, &reactionObject) - th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + _, err := th.App.SaveReactionForPost(th.Context, &reactionObject) + require.Nil(t, err) + _, err = th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + require.Nil(t, err) reactionsOfPost, err := th.App.BuildPostReactions(th.Context, post.Id) require.Nil(t, err)
server/channels/app/reaction.go+24 −0 modified@@ -20,6 +20,30 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) return nil, err } + // Check whether this is a valid emoji + if _, ok := model.GetSystemEmojiId(reaction.EmojiName); !ok { + if _, emojiErr := a.GetEmojiByName(c, reaction.EmojiName); emojiErr != nil { + return nil, emojiErr + } + } + + existing, dErr := a.Srv().Store().Reaction().ExistsOnPost(reaction.PostId, reaction.EmojiName) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + // If it exists already, we don't need to check for the limit + if !existing { + count, dErr := a.Srv().Store().Reaction().GetUniqueCountForPost(reaction.PostId) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + if count >= *a.Config().ServiceSettings.UniqueEmojiReactionLimitPerPost { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.too_many_reactions", nil, "", http.StatusBadRequest) + } + } + channel, err := a.GetChannel(c, post.ChannelId) if err != nil { return nil, err
server/channels/app/reaction_test.go+95 −0 modified@@ -13,6 +13,89 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/testlib" ) +func TestSaveReactionForPost(t *testing.T) { + th := Setup(t).InitBasic() + + post := th.CreatePost(th.BasicChannel) + reaction1, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "cry", + }) + require.NotNil(t, reaction1) + require.Nil(t, err) + reaction2, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "smile", + }) + require.NotNil(t, reaction2) + require.Nil(t, err) + reaction3, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "rofl", + }) + require.NotNil(t, reaction3) + require.Nil(t, err) + + t.Run("should not add reaction if it does not exist on the system", func(t *testing.T) { + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: th.BasicPost.Id, + EmojiName: "definitely-not-a-real-emoji", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should not add reaction if we are over the limit", func(t *testing.T) { + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "joy", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should always add reaction if we are over the limit but the reaction is not unique", func(t *testing.T) { + user := th.CreateUser() + + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: user.Id, + PostId: post.Id, + EmojiName: "cry", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.Nil(t, err) + require.NotNil(t, result) + }) +} + 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() @@ -84,3 +167,15 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1]) }) } + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.ConfigStore.IsReadOnly() { + return + } + old := th.ConfigStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.ConfigStore.Set(updated); err != nil { + panic(err) + } +}
server/channels/store/opentracinglayer/opentracinglayer.go+36 −0 modified@@ -7345,6 +7345,24 @@ func (s *OpenTracingLayerReactionStore) DeleteOrphanedRowsByIds(r *model.Retenti return err } +func (s *OpenTracingLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.ExistsOnPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetForPost") @@ -7381,6 +7399,24 @@ func (s *OpenTracingLayerReactionStore) GetForPostSince(postId string, since int return result, err } +func (s *OpenTracingLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetUniqueCountForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.PermanentDeleteBatch")
server/channels/store/retrylayer/retrylayer.go+42 −0 modified@@ -8338,6 +8338,27 @@ func (s *RetryLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF } +func (s *RetryLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + + tries := 0 + for { + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { tries := 0 @@ -8380,6 +8401,27 @@ func (s *RetryLayerReactionStore) GetForPostSince(postId string, since int64, ex } +func (s *RetryLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + + tries := 0 + for { + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { tries := 0
server/channels/store/sqlstore/reaction_store.go+35 −0 modified@@ -4,6 +4,7 @@ package sqlstore import ( + "database/sql" "time" sq "github.com/mattermost/squirrel" @@ -107,6 +108,25 @@ func (s *SqlReactionStore) GetForPost(postId string, allowFromCache bool) ([]*mo return reactions, nil } +func (s *SqlReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + query := s.getQueryBuilder(). + Select("1"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"EmojiName": emojiName}). + Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0}) + + var hasRows bool + if err := s.GetReplicaX().GetBuilder(&hasRows, query); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to check for existing reaction") + } + + return hasRows, nil +} + // GetForPostSince returns all reactions associated with `postId` updated after `since`. func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) { query := s.getQueryBuilder(). @@ -138,6 +158,21 @@ func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRe return reactions, nil } +func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) { + query := s.getQueryBuilder(). + Select("COUNT(DISTINCT EmojiName)"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"DeleteAt": 0}) + + var count int64 + err := s.GetReplicaX().GetBuilder(&count, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count Reactions") + } + return int(count), nil +} + func (s *SqlReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { placeholder, values := constructArrayArgs(postIds) var reactions []*model.Reaction
server/channels/store/store.go+2 −0 modified@@ -722,6 +722,8 @@ type ReactionStore interface { Delete(reaction *model.Reaction) (*model.Reaction, error) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) + GetUniqueCountForPost(postId string) (int, error) + ExistsOnPost(postId string, emojiName string) (bool, error) DeleteAllWithEmojiName(emojiName string) error BulkGetForPosts(postIds []string) ([]*model.Reaction, error) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) error
server/channels/store/storetest/mocks/ReactionStore.go+48 −0 modified@@ -94,6 +94,30 @@ func (_m *ReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletio return r0 } +// ExistsOnPost provides a mock function with given fields: postId, emojiName +func (_m *ReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + ret := _m.Called(postId, emojiName) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (bool, error)); ok { + return rf(postId, emojiName) + } + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(postId, emojiName) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postId, emojiName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetForPost provides a mock function with given fields: postID, allowFromCache func (_m *ReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { ret := _m.Called(postID, allowFromCache) @@ -146,6 +170,30 @@ func (_m *ReactionStore) GetForPostSince(postId string, since int64, excludeRemo return r0, r1 } +// GetUniqueCountForPost provides a mock function with given fields: postId +func (_m *ReactionStore) GetUniqueCountForPost(postId string) (int, error) { + ret := _m.Called(postId) + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(string) (int, error)); ok { + return rf(postId) + } + if rf, ok := ret.Get(0).(func(string) int); ok { + r0 = rf(postId) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PermanentDeleteBatch provides a mock function with given fields: endTime, limit func (_m *ReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { ret := _m.Called(endTime, limit)
server/channels/store/storetest/reaction_store.go+65 −0 modified@@ -28,6 +28,8 @@ func TestReactionStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("PermanentDeleteBatch", func(t *testing.T) { testReactionStorePermanentDeleteBatch(t, ss) }) t.Run("ReactionBulkGetForPosts", func(t *testing.T) { testReactionBulkGetForPosts(t, ss) }) t.Run("ReactionDeadlock", func(t *testing.T) { testReactionDeadlock(t, ss) }) + t.Run("ExistsOnPost", func(t *testing.T) { testExistsOnPost(t, ss) }) + t.Run("GetUniqueCountForPost", func(t *testing.T) { testGetUniqueCountForPost(t, ss) }) } func testReactionSave(t *testing.T, ss store.Store) { @@ -876,3 +878,66 @@ func testReactionDeadlock(t *testing.T, ss store.Store) { }() wg.Wait() } + +func testExistsOnPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + emojiName := model.NewId() + reaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + exists, err := ss.Reaction().ExistsOnPost(post.Id, emojiName) + require.NoError(t, err) + require.True(t, exists) + exists, err = ss.Reaction().ExistsOnPost(post.Id, model.NewId()) + require.NoError(t, err) + require.False(t, exists) +} + +func testGetUniqueCountForPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + + userId := model.NewId() + emojiName := model.NewId() + + reaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + + sameReaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr = ss.Reaction().Save(sameReaction) + require.NoError(t, nErr) + + newReaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: model.NewId(), + } + _, nErr = ss.Reaction().Save(newReaction) + require.NoError(t, nErr) + + totalReactions, err := ss.Reaction().GetForPost(post.Id, false) + require.NoError(t, err) + require.Equal(t, 3, len(totalReactions)) + + count, err := ss.Reaction().GetUniqueCountForPost(post.Id) + require.NoError(t, err) + require.Equal(t, 2, count) +}
server/channels/store/timerlayer/timerlayer.go+32 −0 modified@@ -6639,6 +6639,22 @@ func (s *TimerLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF return err } +func (s *TimerLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + start := time.Now() + + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.ExistsOnPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { start := time.Now() @@ -6671,6 +6687,22 @@ func (s *TimerLayerReactionStore) GetForPostSince(postId string, since int64, ex return result, err } +func (s *TimerLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + start := time.Now() + + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.GetUniqueCountForPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { start := time.Now()
server/config/client.go+1 −0 modified@@ -141,6 +141,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10) props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete) + props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
server/i18n/en.json+4 −0 modified@@ -6466,6 +6466,10 @@ "id": "app.reaction.save.save.app_error", "translation": "Unable to save reaction." }, + { + "id": "app.reaction.save.save.too_many_reactions", + "translation": "Reaction limit has been reached for this post." + }, { "id": "app.recover.delete.app_error", "translation": "Unable to delete token."
server/public/model/config.go+22 −11 modified@@ -100,17 +100,19 @@ const ( SitenameMaxLength = 30 - ServiceSettingsDefaultSiteURL = "http://localhost:8065" - ServiceSettingsDefaultTLSCertFile = "" - ServiceSettingsDefaultTLSKeyFile = "" - ServiceSettingsDefaultReadTimeout = 300 - ServiceSettingsDefaultWriteTimeout = 300 - ServiceSettingsDefaultIdleTimeout = 60 - ServiceSettingsDefaultMaxLoginAttempts = 10 - ServiceSettingsDefaultAllowCorsFrom = "" - ServiceSettingsDefaultListenAndAddress = ":8065" - ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" - ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultSiteURL = "http://localhost:8065" + ServiceSettingsDefaultTLSCertFile = "" + ServiceSettingsDefaultTLSKeyFile = "" + ServiceSettingsDefaultReadTimeout = 300 + ServiceSettingsDefaultWriteTimeout = 300 + ServiceSettingsDefaultIdleTimeout = 60 + ServiceSettingsDefaultMaxLoginAttempts = 10 + ServiceSettingsDefaultAllowCorsFrom = "" + ServiceSettingsDefaultListenAndAddress = ":8065" + ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" + ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultUniqueReactionsPerPost = 50 + ServiceSettingsMaxUniqueReactionsPerPost = 500 TeamSettingsDefaultSiteName = "Mattermost" TeamSettingsDefaultMaxUsersPerTeam = 50 @@ -394,6 +396,7 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` + UniqueEmojiReactionLimitPerPost *int `access:"site_posts"` } var MattermostGiphySdkKey string @@ -884,6 +887,14 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } + + if s.UniqueEmojiReactionLimitPerPost == nil { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsDefaultUniqueReactionsPerPost) + } + + if *s.UniqueEmojiReactionLimitPerPost > ServiceSettingsMaxUniqueReactionsPerPost { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsMaxUniqueReactionsPerPost) + } } type ClusterSettings struct {
webapp/channels/src/actions/post_actions.test.ts+40 −7 modified@@ -12,6 +12,7 @@ import * as Actions from 'actions/post_actions'; import mockStore from 'tests/test_store'; import {Constants, ActionTypes, RHSStates} from 'utils/constants'; +import * as PostUtils from 'utils/post_utils'; import type {GlobalState} from 'types/store'; @@ -48,6 +49,12 @@ jest.mock('utils/user_agent', () => ({ isDesktopApp: jest.fn().mockReturnValue(false), })); +jest.mock('utils/post_utils', () => ({ + makeGetUniqueEmojiNameReactionsForPost: jest.fn(), +})); + +const mockMakeGetUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost as unknown as jest.Mock<() => string[]>; + const POST_CREATED_TIME = Date.now(); // This mocks the Date.now() function so it returns a constant value @@ -433,14 +440,40 @@ describe('Actions.Posts', () => { }); }); - test('addReaction', async () => { - const testStore = mockStore(initialState); + describe('addReaction', () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => []); - await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); - expect(testStore.getActions()).toEqual([ - {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, - {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, - ]); + test('should add reaction', async () => { + const testStore = mockStore(initialState); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); + test('should not add reaction if we are over the limit', async () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => ['another_emoji']); + const testStore = mockStore({ + ...initialState, + entities: { + ...initialState.entities, + general: { + ...initialState.entities.general, + config: { + ...initialState.entities.general.config, + UniqueEmojiReactionLimitPerPost: '1', + }, + }, + }, + }); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).not.toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); }); test('flagPost', async () => {
webapp/channels/src/actions/post_actions.ts+26 −2 modified@@ -10,30 +10,36 @@ import {getMyChannelMember} from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; import * as ThreadActions from 'mattermost-redux/actions/threads'; import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils'; import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions'; import {removeDraft} from 'actions/views/drafts'; +import {closeModal, openModal} from 'actions/views/modals'; import * as RhsActions from 'actions/views/rhs'; import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts'; import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs'; import {getGlobalItem} from 'selectors/storage'; +import ReactionLimitReachedModal from 'components/reaction_limit_reached_modal'; + import { ActionTypes, Constants, + ModalIdentifiers, RHSStates, StoragePrefixes, } from 'utils/constants'; import {matchEmoticons} from 'utils/emoticons'; +import {makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils'; import * as UserAgent from 'utils/user_agent'; import type {GlobalState} from 'types/store'; @@ -141,7 +147,25 @@ function storeCommentDraft(rootPostId: string, draft: null) { } export function addReaction(postId: string, emojiName: string) { - return (dispatch: DispatchFunc) => { + const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost(); + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const config = getConfig(state); + const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? []; + + // If we're adding a new reaction but we're already at or over the limit, stop + if (uniqueEmojiNames.length >= Number(config.UniqueEmojiReactionLimitPerPost) && !uniqueEmojiNames.some((name) => name === emojiName)) { + dispatch(openModal({ + modalId: ModalIdentifiers.REACTION_LIMIT_REACHED, + dialogType: ReactionLimitReachedModal, + dialogProps: { + isAdmin: isCurrentUserSystemAdmin(state), + onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED), + }, + })); + return {data: false}; + } + dispatch(PostActions.addReaction(postId, emojiName)); dispatch(addRecentEmoji(emojiName)); return {data: true};
webapp/channels/src/components/admin_console/admin_definition.jsx+31 −0 modified@@ -224,6 +224,7 @@ export const it = { export const validators = { isRequired: (text, textDefault) => (value) => new ValidationResult(Boolean(value), text, textDefault), minValue: (min, text, textDefault) => (value) => new ValidationResult((value >= min), text, textDefault), + maxValue: (max, text, textDefault) => (value) => new ValidationResult((value <= max), text, textDefault), }; const usesLegacyOauth = (config, state, license, enterpriseReady, consoleAccess, cloud) => { @@ -3096,6 +3097,36 @@ const AdminDefinition = { help_text_default: 'When enabled, users message drafts will sync with the server so they can be accessed from any device. Users may opt out of this behaviour in Account settings.', help_text_markdown: false, }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.UniqueEmojiReactionLimitPerPost', + label: t('admin.customization.uniqueEmojiReactionLimitPerPost'), + label_default: 'Unique Emoji Reaction Limit:', + placeholder: t('admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder'), + placeholder_default: 'E.g.: 25', + help_text: t('admin.customization.uniqueEmojiReactionLimitPerPostDesc'), + help_text_default: 'The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.', + help_text_markdown: false, + validate: (value) => { + const maxResult = validators.maxValue( + 500, + t('admin.customization.uniqueEmojiReactionLimitPerPost.maxValue'), + 'Cannot increase the limit to a value above 500.', + )(value); + if (!maxResult.isValid()) { + return maxResult; + } + const minResult = validators.minValue(0, + t('admin.customization.uniqueEmojiReactionLimitPerPost.minValue'), + 'Cannot decrease the limit below 0.', + )(value); + if (!minResult.isValid()) { + return minResult; + } + + return new ValidationResult(true, '', ''); + }, + }, ], }, },
webapp/channels/src/components/reaction_limit_reached_modal.tsx+63 −0 added@@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {Link} from 'react-router-dom'; + +import {GenericModal} from '@mattermost/components'; + +import ExternalLink from 'components/external_link'; + +export default function ReactionLimitReachedModal(props: {isAdmin: boolean; onExited: () => void}) { + const body = props.isAdmin ? ( + <FormattedMessage + id='reaction_limit_reached_modal.body.admin' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>." + values={{ + link: (msg: React.ReactNode) => ( + <ExternalLink + href='https://mattermost.com/pl/configure-unique-emoji-reaction-limit' + > + {msg} + </ExternalLink> + ), + linkAdmin: (msg: React.ReactNode) => ( + <Link + onClick={props.onExited} + to='/admin_console' + > + {msg} + </Link> + ), + }} + /> + ) : ( + <FormattedMessage + id='reaction_limit_reached_modal.body' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit." + /> + ); + + return ( + <GenericModal + modalHeaderText={ + <FormattedMessage + id='reaction_limit_reached_modal.title' + defaultMessage="You've reached the reaction limit" + /> + } + compassDesign={true} + confirmButtonText={ + <FormattedMessage + id='generic.okay' + defaultMessage='Okay' + /> + } + onExited={props.onExited} + handleConfirm={props.onExited} + > + {body} + </GenericModal> + ); +}
webapp/channels/src/i18n/en.json+8 −0 modified@@ -673,6 +673,11 @@ "admin.customization.restrictLinkPreviewsDesc": "Link previews and image link previews will not be shown for the above list of comma-separated domains.", "admin.customization.restrictLinkPreviewsExample": "E.g.: \"internal.mycompany.com, images.example.com\"", "admin.customization.restrictLinkPreviewsTitle": "Disable website link previews from these domains:", + "admin.customization.uniqueEmojiReactionLimitPerPost": "Unique Emoji Reaction Limit:", + "admin.customization.uniqueEmojiReactionLimitPerPost.maxValue": "Cannot increase the limit to a value above 500.", + "admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Cannot decrease the limit below 0.", + "admin.customization.uniqueEmojiReactionLimitPerPostDesc": "The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.", + "admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder": "E.g.: 25", "admin.data_grid.empty": "No items found", "admin.data_grid.loading": "Loading", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}", @@ -4513,6 +4518,9 @@ "quick_switch_modal.help_no_team": "Type to find a channel. Use **UP/DOWN** to browse, **ENTER** to select, **ESC** to dismiss.", "quick_switch_modal.input": "quick switch input", "quick_switch_modal.switchChannels": "Find Channels", + "reaction_limit_reached_modal.body": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit.", + "reaction_limit_reached_modal.body.admin": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>.", + "reaction_limit_reached_modal.title": "You've reached the reaction limit", "reaction_list.addReactionTooltip": "Add a reaction", "reaction.add.ariaLabel": "Add a reaction", "reaction.clickToAdd": "(click to add)",
webapp/channels/src/utils/constants.tsx+1 −0 modified@@ -446,6 +446,7 @@ export const ModalIdentifiers = { START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel', + REACTION_LIMIT_REACHED: 'reaction_limit_reached', }; export const UserStatuses = {
webapp/channels/src/utils/post_utils.test.tsx+40 −0 modified@@ -1118,3 +1118,43 @@ describe('PostUtils.getPostURL', () => { expect(PostUtils.getPostURL(state, postCase)).toBe(expected); }); }); + +describe('makeGetUniqueEmojiNameReactionsForPost', () => { + const baseState = { + entities: { + posts: { + reactions: { + post_id_1: { + user_1_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_2_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_3_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_1_post_id_1_cry: { + emoji_name: 'cry', + post_id: 'post_id_1', + }, + }, + + }, + }, + general: { + config: {}, + }, + emojis: {}, + }, + } as unknown as GlobalState; + + test('should only return names of unique reactions', () => { + const getUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost(); + + expect(getUniqueEmojiNameReactionsForPost(baseState, 'post_id_1')).toEqual(['smile', 'cry']); + }); +});
webapp/channels/src/utils/post_utils.ts+25 −0 modified@@ -704,6 +704,31 @@ export function makeGetUniqueReactionsToPost(): (state: GlobalState, postId: Pos ); } +export function makeGetUniqueEmojiNameReactionsForPost(): (state: GlobalState, postId: Post['id']) => string[] | undefined | null { + const getReactionsForPost = makeGetReactionsForPost(); + + return createSelector( + 'makeGetUniqueEmojiReactionsForPost', + (state: GlobalState, postId: string) => getReactionsForPost(state, postId), + getEmojiMap, + (reactions, emojiMap) => { + if (!reactions) { + return null; + } + + const emojiNames: string[] = []; + + Object.values(reactions).forEach((reaction) => { + if (emojiMap.get(reaction.emoji_name) && !emojiNames.includes(reaction.emoji_name)) { + emojiNames.push(reaction.emoji_name); + } + }); + + return emojiNames; + }, + ); +} + export function getUserOrGroupFromMentionName(usersByUsername: Record<string, UserProfile | Group>, mentionName: string) { let mentionNameToLowerCase = mentionName.toLowerCase();
webapp/platform/types/src/config.ts+2 −0 modified@@ -202,6 +202,7 @@ export type ClientConfig = { AllowPersistentNotificationsForGuests: string; DelayChannelAutocomplete: 'true' | 'false'; ServiceEnvironment: string; + UniqueEmojiReactionLimitPerPost: string; }; export type License = { @@ -380,6 +381,7 @@ export type ServiceSettings = { PersistentNotificationIntervalMinutes: number; PersistentNotificationMaxCount: number; PersistentNotificationMaxRecipients: number; + UniqueEmojiReactionLimitPerPost: number; }; export type TeamSettings = {
6d2440de9fd7[MM-55143] Disallow reacting with an emoji that does not exist, limit the total number of unique reactions per post (#25331) (#25574)
24 files changed · +652 −27
e2e-tests/playwright/support/server/default_config.ts+1 −0 modified@@ -179,6 +179,7 @@ const defaultServerConfig: AdminConfig = { EnableCustomGroups: true, SelfHostedPurchase: true, AllowSyncedDrafts: true, + UniqueEmojiReactionLimitPerPost: 25, }, TeamSettings: { SiteName: 'Mattermost',
server/channels/api4/reaction_test.go+3 −3 modified@@ -55,7 +55,7 @@ func TestSaveReaction(t *testing.T) { }) t.Run("save-second-reaction", func(t *testing.T) { - reaction.EmojiName = "sad" + reaction.EmojiName = "cry" rr, _, err := client.SaveReaction(context.Background(), reaction) require.NoError(t, err) @@ -290,7 +290,7 @@ func TestDeleteReaction(t *testing.T) { r2 := &model.Reaction{ UserId: userId, PostId: postId, - EmojiName: "smile-", + EmojiName: "cry", } r3 := &model.Reaction{ @@ -302,7 +302,7 @@ func TestDeleteReaction(t *testing.T) { r4 := &model.Reaction{ UserId: user2Id, PostId: postId, - EmojiName: "smile_", + EmojiName: "grin", } // Check the appropriate permissions are enforced.
server/channels/app/export_test.go+6 −4 modified@@ -29,18 +29,20 @@ func TestReactionsOfPost(t *testing.T) { reactionObject := model.Reaction{ UserId: th.BasicUser.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } reactionObjectDeleted := model.Reaction{ UserId: th.BasicUser2.Id, PostId: post.Id, - EmojiName: "emoji", + EmojiName: "smile", CreateAt: model.GetMillis(), } - th.App.SaveReactionForPost(th.Context, &reactionObject) - th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + _, err := th.App.SaveReactionForPost(th.Context, &reactionObject) + require.Nil(t, err) + _, err = th.App.SaveReactionForPost(th.Context, &reactionObjectDeleted) + require.Nil(t, err) reactionsOfPost, err := th.App.BuildPostReactions(th.Context, post.Id) require.Nil(t, err)
server/channels/app/reaction.go+24 −0 modified@@ -20,6 +20,30 @@ func (a *App) SaveReactionForPost(c *request.Context, reaction *model.Reaction) return nil, err } + // Check whether this is a valid emoji + if _, ok := model.GetSystemEmojiId(reaction.EmojiName); !ok { + if _, emojiErr := a.GetEmojiByName(c, reaction.EmojiName); emojiErr != nil { + return nil, emojiErr + } + } + + existing, dErr := a.Srv().Store().Reaction().ExistsOnPost(reaction.PostId, reaction.EmojiName) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + // If it exists already, we don't need to check for the limit + if !existing { + count, dErr := a.Srv().Store().Reaction().GetUniqueCountForPost(reaction.PostId) + if dErr != nil { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.app_error", nil, "", http.StatusInternalServerError).Wrap(dErr) + } + + if count >= *a.Config().ServiceSettings.UniqueEmojiReactionLimitPerPost { + return nil, model.NewAppError("SaveReactionForPost", "app.reaction.save.save.too_many_reactions", nil, "", http.StatusBadRequest) + } + } + channel, err := a.GetChannel(c, post.ChannelId) if err != nil { return nil, err
server/channels/app/reaction_test.go+95 −0 modified@@ -13,6 +13,89 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/testlib" ) +func TestSaveReactionForPost(t *testing.T) { + th := Setup(t).InitBasic() + + post := th.CreatePost(th.BasicChannel) + reaction1, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "cry", + }) + require.NotNil(t, reaction1) + require.Nil(t, err) + reaction2, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "smile", + }) + require.NotNil(t, reaction2) + require.Nil(t, err) + reaction3, err := th.App.SaveReactionForPost(th.Context, &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "rofl", + }) + require.NotNil(t, reaction3) + require.Nil(t, err) + + t.Run("should not add reaction if it does not exist on the system", func(t *testing.T) { + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: th.BasicPost.Id, + EmojiName: "definitely-not-a-real-emoji", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should not add reaction if we are over the limit", func(t *testing.T) { + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: th.BasicUser.Id, + PostId: post.Id, + EmojiName: "joy", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.NotNil(t, err) + require.Nil(t, result) + }) + + t.Run("should always add reaction if we are over the limit but the reaction is not unique", func(t *testing.T) { + user := th.CreateUser() + + var originalLimit *int + th.UpdateConfig(func(cfg *model.Config) { + originalLimit = cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost + *cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = 3 + }) + defer th.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.UniqueEmojiReactionLimitPerPost = originalLimit + }) + + reaction := &model.Reaction{ + UserId: user.Id, + PostId: post.Id, + EmojiName: "cry", + } + + result, err := th.App.SaveReactionForPost(th.Context, reaction) + require.Nil(t, err) + require.NotNil(t, result) + }) +} + 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() @@ -84,3 +167,15 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { assert.Equal(t, channel.Id, sharedChannelService.channelNotifications[1]) }) } + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.ConfigStore.IsReadOnly() { + return + } + old := th.ConfigStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.ConfigStore.Set(updated); err != nil { + panic(err) + } +}
server/channels/store/opentracinglayer/opentracinglayer.go+36 −0 modified@@ -7345,6 +7345,24 @@ func (s *OpenTracingLayerReactionStore) DeleteOrphanedRowsByIds(r *model.Retenti return err } +func (s *OpenTracingLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.ExistsOnPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetForPost") @@ -7381,6 +7399,24 @@ func (s *OpenTracingLayerReactionStore) GetForPostSince(postId string, since int return result, err } +func (s *OpenTracingLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.GetUniqueCountForPost") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ReactionStore.PermanentDeleteBatch")
server/channels/store/retrylayer/retrylayer.go+42 −0 modified@@ -8338,6 +8338,27 @@ func (s *RetryLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF } +func (s *RetryLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + + tries := 0 + for { + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { tries := 0 @@ -8380,6 +8401,27 @@ func (s *RetryLayerReactionStore) GetForPostSince(postId string, since int64, ex } +func (s *RetryLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + + tries := 0 + for { + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { tries := 0
server/channels/store/sqlstore/reaction_store.go+35 −0 modified@@ -4,6 +4,7 @@ package sqlstore import ( + "database/sql" "time" sq "github.com/mattermost/squirrel" @@ -107,6 +108,25 @@ func (s *SqlReactionStore) GetForPost(postId string, allowFromCache bool) ([]*mo return reactions, nil } +func (s *SqlReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + query := s.getQueryBuilder(). + Select("1"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"EmojiName": emojiName}). + Where(sq.Eq{"COALESCE(DeleteAt, 0)": 0}) + + var hasRows bool + if err := s.GetReplicaX().GetBuilder(&hasRows, query); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to check for existing reaction") + } + + return hasRows, nil +} + // GetForPostSince returns all reactions associated with `postId` updated after `since`. func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) { query := s.getQueryBuilder(). @@ -138,6 +158,21 @@ func (s *SqlReactionStore) GetForPostSince(postId string, since int64, excludeRe return reactions, nil } +func (s *SqlReactionStore) GetUniqueCountForPost(postId string) (int, error) { + query := s.getQueryBuilder(). + Select("COUNT(DISTINCT EmojiName)"). + From("Reactions"). + Where(sq.Eq{"PostId": postId}). + Where(sq.Eq{"DeleteAt": 0}) + + var count int64 + err := s.GetReplicaX().GetBuilder(&count, query) + if err != nil { + return 0, errors.Wrap(err, "failed to count Reactions") + } + return int(count), nil +} + func (s *SqlReactionStore) BulkGetForPosts(postIds []string) ([]*model.Reaction, error) { placeholder, values := constructArrayArgs(postIds) var reactions []*model.Reaction
server/channels/store/store.go+2 −0 modified@@ -720,6 +720,8 @@ type ReactionStore interface { Delete(reaction *model.Reaction) (*model.Reaction, error) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) GetForPostSince(postId string, since int64, excludeRemoteId string, inclDeleted bool) ([]*model.Reaction, error) + GetUniqueCountForPost(postId string) (int, error) + ExistsOnPost(postId string, emojiName string) (bool, error) DeleteAllWithEmojiName(emojiName string) error BulkGetForPosts(postIds []string) ([]*model.Reaction, error) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletion) error
server/channels/store/storetest/mocks/ReactionStore.go+48 −0 modified@@ -94,6 +94,30 @@ func (_m *ReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDeletio return r0 } +// ExistsOnPost provides a mock function with given fields: postId, emojiName +func (_m *ReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + ret := _m.Called(postId, emojiName) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (bool, error)); ok { + return rf(postId, emojiName) + } + if rf, ok := ret.Get(0).(func(string, string) bool); ok { + r0 = rf(postId, emojiName) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(postId, emojiName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetForPost provides a mock function with given fields: postID, allowFromCache func (_m *ReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { ret := _m.Called(postID, allowFromCache) @@ -146,6 +170,30 @@ func (_m *ReactionStore) GetForPostSince(postId string, since int64, excludeRemo return r0, r1 } +// GetUniqueCountForPost provides a mock function with given fields: postId +func (_m *ReactionStore) GetUniqueCountForPost(postId string) (int, error) { + ret := _m.Called(postId) + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(string) (int, error)); ok { + return rf(postId) + } + if rf, ok := ret.Get(0).(func(string) int); ok { + r0 = rf(postId) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(postId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PermanentDeleteBatch provides a mock function with given fields: endTime, limit func (_m *ReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { ret := _m.Called(endTime, limit)
server/channels/store/storetest/reaction_store.go+65 −0 modified@@ -28,6 +28,8 @@ func TestReactionStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("PermanentDeleteBatch", func(t *testing.T) { testReactionStorePermanentDeleteBatch(t, ss) }) t.Run("ReactionBulkGetForPosts", func(t *testing.T) { testReactionBulkGetForPosts(t, ss) }) t.Run("ReactionDeadlock", func(t *testing.T) { testReactionDeadlock(t, ss) }) + t.Run("ExistsOnPost", func(t *testing.T) { testExistsOnPost(t, ss) }) + t.Run("GetUniqueCountForPost", func(t *testing.T) { testGetUniqueCountForPost(t, ss) }) } func testReactionSave(t *testing.T, ss store.Store) { @@ -872,3 +874,66 @@ func testReactionDeadlock(t *testing.T, ss store.Store) { }() wg.Wait() } + +func testExistsOnPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + emojiName := model.NewId() + reaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + exists, err := ss.Reaction().ExistsOnPost(post.Id, emojiName) + require.NoError(t, err) + require.True(t, exists) + exists, err = ss.Reaction().ExistsOnPost(post.Id, model.NewId()) + require.NoError(t, err) + require.False(t, exists) +} + +func testGetUniqueCountForPost(t *testing.T, ss store.Store) { + post, _ := ss.Post().Save(&model.Post{ + ChannelId: model.NewId(), + UserId: model.NewId(), + }) + + userId := model.NewId() + emojiName := model.NewId() + + reaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr := ss.Reaction().Save(reaction) + require.NoError(t, nErr) + + sameReaction := &model.Reaction{ + UserId: model.NewId(), + PostId: post.Id, + EmojiName: emojiName, + } + _, nErr = ss.Reaction().Save(sameReaction) + require.NoError(t, nErr) + + newReaction := &model.Reaction{ + UserId: userId, + PostId: post.Id, + EmojiName: model.NewId(), + } + _, nErr = ss.Reaction().Save(newReaction) + require.NoError(t, nErr) + + totalReactions, err := ss.Reaction().GetForPost(post.Id, false) + require.NoError(t, err) + require.Equal(t, 3, len(totalReactions)) + + count, err := ss.Reaction().GetUniqueCountForPost(post.Id) + require.NoError(t, err) + require.Equal(t, 2, count) +}
server/channels/store/timerlayer/timerlayer.go+32 −0 modified@@ -6639,6 +6639,22 @@ func (s *TimerLayerReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsF return err } +func (s *TimerLayerReactionStore) ExistsOnPost(postId string, emojiName string) (bool, error) { + start := time.Now() + + result, err := s.ReactionStore.ExistsOnPost(postId, emojiName) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.ExistsOnPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) GetForPost(postID string, allowFromCache bool) ([]*model.Reaction, error) { start := time.Now() @@ -6671,6 +6687,22 @@ func (s *TimerLayerReactionStore) GetForPostSince(postId string, since int64, ex return result, err } +func (s *TimerLayerReactionStore) GetUniqueCountForPost(postId string) (int, error) { + start := time.Now() + + result, err := s.ReactionStore.GetUniqueCountForPost(postId) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ReactionStore.GetUniqueCountForPost", success, elapsed) + } + return result, err +} + func (s *TimerLayerReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { start := time.Now()
server/config/client.go+1 −0 modified@@ -141,6 +141,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10) props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts) props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete) + props["UniqueEmojiReactionLimitPerPost"] = strconv.FormatInt(int64(*c.ServiceSettings.UniqueEmojiReactionLimitPerPost), 10) if license != nil { props["ExperimentalEnableAuthenticationTransfer"] = strconv.FormatBool(*c.ServiceSettings.ExperimentalEnableAuthenticationTransfer)
server/i18n/en.json+4 −0 modified@@ -6466,6 +6466,10 @@ "id": "app.reaction.save.save.app_error", "translation": "Unable to save reaction." }, + { + "id": "app.reaction.save.save.too_many_reactions", + "translation": "Reaction limit has been reached for this post." + }, { "id": "app.recover.delete.app_error", "translation": "Unable to delete token."
server/public/model/config.go+22 −11 modified@@ -100,17 +100,19 @@ const ( SitenameMaxLength = 30 - ServiceSettingsDefaultSiteURL = "http://localhost:8065" - ServiceSettingsDefaultTLSCertFile = "" - ServiceSettingsDefaultTLSKeyFile = "" - ServiceSettingsDefaultReadTimeout = 300 - ServiceSettingsDefaultWriteTimeout = 300 - ServiceSettingsDefaultIdleTimeout = 60 - ServiceSettingsDefaultMaxLoginAttempts = 10 - ServiceSettingsDefaultAllowCorsFrom = "" - ServiceSettingsDefaultListenAndAddress = ":8065" - ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" - ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultSiteURL = "http://localhost:8065" + ServiceSettingsDefaultTLSCertFile = "" + ServiceSettingsDefaultTLSKeyFile = "" + ServiceSettingsDefaultReadTimeout = 300 + ServiceSettingsDefaultWriteTimeout = 300 + ServiceSettingsDefaultIdleTimeout = 60 + ServiceSettingsDefaultMaxLoginAttempts = 10 + ServiceSettingsDefaultAllowCorsFrom = "" + ServiceSettingsDefaultListenAndAddress = ":8065" + ServiceSettingsDefaultGiphySdkKeyTest = "s0glxvzVg9azvPipKxcPLpXV0q1x1fVP" + ServiceSettingsDefaultDeveloperFlags = "" + ServiceSettingsDefaultUniqueReactionsPerPost = 50 + ServiceSettingsMaxUniqueReactionsPerPost = 500 TeamSettingsDefaultSiteName = "Mattermost" TeamSettingsDefaultMaxUsersPerTeam = 50 @@ -394,6 +396,7 @@ type ServiceSettings struct { EnableCustomGroups *bool `access:"site_users_and_teams"` SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` + UniqueEmojiReactionLimitPerPost *int `access:"site_posts"` } var MattermostGiphySdkKey string @@ -884,6 +887,14 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.SelfHostedPurchase == nil { s.SelfHostedPurchase = NewBool(true) } + + if s.UniqueEmojiReactionLimitPerPost == nil { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsDefaultUniqueReactionsPerPost) + } + + if *s.UniqueEmojiReactionLimitPerPost > ServiceSettingsMaxUniqueReactionsPerPost { + s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsMaxUniqueReactionsPerPost) + } } type ClusterSettings struct {
webapp/channels/src/actions/post_actions.test.ts+40 −7 modified@@ -12,6 +12,7 @@ import * as Actions from 'actions/post_actions'; import mockStore from 'tests/test_store'; import {Constants, ActionTypes, RHSStates} from 'utils/constants'; +import * as PostUtils from 'utils/post_utils'; import type {GlobalState} from 'types/store'; @@ -48,6 +49,12 @@ jest.mock('utils/user_agent', () => ({ isDesktopApp: jest.fn().mockReturnValue(false), })); +jest.mock('utils/post_utils', () => ({ + makeGetUniqueEmojiNameReactionsForPost: jest.fn(), +})); + +const mockMakeGetUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost as unknown as jest.Mock<() => string[]>; + const POST_CREATED_TIME = Date.now(); // This mocks the Date.now() function so it returns a constant value @@ -433,14 +440,40 @@ describe('Actions.Posts', () => { }); }); - test('addReaction', async () => { - const testStore = mockStore(initialState); + describe('addReaction', () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => []); - await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); - expect(testStore.getActions()).toEqual([ - {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, - {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, - ]); + test('should add reaction', async () => { + const testStore = mockStore(initialState); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); + test('should not add reaction if we are over the limit', async () => { + mockMakeGetUniqueEmojiNameReactionsForPost.mockReturnValue(() => ['another_emoji']); + const testStore = mockStore({ + ...initialState, + entities: { + ...initialState.entities, + general: { + ...initialState.entities.general, + config: { + ...initialState.entities.general.config, + UniqueEmojiReactionLimitPerPost: '1', + }, + }, + }, + }); + + await testStore.dispatch(Actions.addReaction('post_id_1', 'emoji_name_1')); + expect(testStore.getActions()).not.toEqual([ + {args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'}, + {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, + ]); + }); }); test('flagPost', async () => {
webapp/channels/src/actions/post_actions.ts+26 −2 modified@@ -10,30 +10,36 @@ import {getMyChannelMember} from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; import * as ThreadActions from 'mattermost-redux/actions/threads'; import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils'; import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions'; import {removeDraft} from 'actions/views/drafts'; +import {closeModal, openModal} from 'actions/views/modals'; import * as RhsActions from 'actions/views/rhs'; import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts'; import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs'; import {getGlobalItem} from 'selectors/storage'; +import ReactionLimitReachedModal from 'components/reaction_limit_reached_modal'; + import { ActionTypes, Constants, + ModalIdentifiers, RHSStates, StoragePrefixes, } from 'utils/constants'; import {matchEmoticons} from 'utils/emoticons'; +import {makeGetUniqueEmojiNameReactionsForPost} from 'utils/post_utils'; import * as UserAgent from 'utils/user_agent'; import type {GlobalState} from 'types/store'; @@ -141,7 +147,25 @@ function storeCommentDraft(rootPostId: string, draft: null) { } export function addReaction(postId: string, emojiName: string) { - return (dispatch: DispatchFunc) => { + const getUniqueEmojiNameReactionsForPost = makeGetUniqueEmojiNameReactionsForPost(); + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const config = getConfig(state); + const uniqueEmojiNames = getUniqueEmojiNameReactionsForPost(state, postId) ?? []; + + // If we're adding a new reaction but we're already at or over the limit, stop + if (uniqueEmojiNames.length >= Number(config.UniqueEmojiReactionLimitPerPost) && !uniqueEmojiNames.some((name) => name === emojiName)) { + dispatch(openModal({ + modalId: ModalIdentifiers.REACTION_LIMIT_REACHED, + dialogType: ReactionLimitReachedModal, + dialogProps: { + isAdmin: isCurrentUserSystemAdmin(state), + onExited: () => closeModal(ModalIdentifiers.REACTION_LIMIT_REACHED), + }, + })); + return {data: false}; + } + dispatch(PostActions.addReaction(postId, emojiName)); dispatch(addRecentEmoji(emojiName)); return {data: true};
webapp/channels/src/components/admin_console/admin_definition.jsx+31 −0 modified@@ -224,6 +224,7 @@ export const it = { export const validators = { isRequired: (text, textDefault) => (value) => new ValidationResult(Boolean(value), text, textDefault), minValue: (min, text, textDefault) => (value) => new ValidationResult((value >= min), text, textDefault), + maxValue: (max, text, textDefault) => (value) => new ValidationResult((value <= max), text, textDefault), }; const usesLegacyOauth = (config, state, license, enterpriseReady, consoleAccess, cloud) => { @@ -3096,6 +3097,36 @@ const AdminDefinition = { help_text_default: 'When enabled, users message drafts will sync with the server so they can be accessed from any device. Users may opt out of this behaviour in Account settings.', help_text_markdown: false, }, + { + type: Constants.SettingsTypes.TYPE_NUMBER, + key: 'ServiceSettings.UniqueEmojiReactionLimitPerPost', + label: t('admin.customization.uniqueEmojiReactionLimitPerPost'), + label_default: 'Unique Emoji Reaction Limit:', + placeholder: t('admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder'), + placeholder_default: 'E.g.: 25', + help_text: t('admin.customization.uniqueEmojiReactionLimitPerPostDesc'), + help_text_default: 'The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.', + help_text_markdown: false, + validate: (value) => { + const maxResult = validators.maxValue( + 500, + t('admin.customization.uniqueEmojiReactionLimitPerPost.maxValue'), + 'Cannot increase the limit to a value above 500.', + )(value); + if (!maxResult.isValid()) { + return maxResult; + } + const minResult = validators.minValue(0, + t('admin.customization.uniqueEmojiReactionLimitPerPost.minValue'), + 'Cannot decrease the limit below 0.', + )(value); + if (!minResult.isValid()) { + return minResult; + } + + return new ValidationResult(true, '', ''); + }, + }, ], }, },
webapp/channels/src/components/reaction_limit_reached_modal.tsx+63 −0 added@@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {Link} from 'react-router-dom'; + +import {GenericModal} from '@mattermost/components'; + +import ExternalLink from 'components/external_link'; + +export default function ReactionLimitReachedModal(props: {isAdmin: boolean; onExited: () => void}) { + const body = props.isAdmin ? ( + <FormattedMessage + id='reaction_limit_reached_modal.body.admin' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>." + values={{ + link: (msg: React.ReactNode) => ( + <ExternalLink + href='https://mattermost.com/pl/configure-unique-emoji-reaction-limit' + > + {msg} + </ExternalLink> + ), + linkAdmin: (msg: React.ReactNode) => ( + <Link + onClick={props.onExited} + to='/admin_console' + > + {msg} + </Link> + ), + }} + /> + ) : ( + <FormattedMessage + id='reaction_limit_reached_modal.body' + defaultMessage="Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit." + /> + ); + + return ( + <GenericModal + modalHeaderText={ + <FormattedMessage + id='reaction_limit_reached_modal.title' + defaultMessage="You've reached the reaction limit" + /> + } + compassDesign={true} + confirmButtonText={ + <FormattedMessage + id='generic.okay' + defaultMessage='Okay' + /> + } + onExited={props.onExited} + handleConfirm={props.onExited} + > + {body} + </GenericModal> + ); +}
webapp/channels/src/i18n/en.json+8 −0 modified@@ -673,6 +673,11 @@ "admin.customization.restrictLinkPreviewsDesc": "Link previews and image link previews will not be shown for the above list of comma-separated domains.", "admin.customization.restrictLinkPreviewsExample": "E.g.: \"internal.mycompany.com, images.example.com\"", "admin.customization.restrictLinkPreviewsTitle": "Disable website link previews from these domains:", + "admin.customization.uniqueEmojiReactionLimitPerPost": "Unique Emoji Reaction Limit:", + "admin.customization.uniqueEmojiReactionLimitPerPost.maxValue": "Cannot increase the limit to a value above 500.", + "admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Cannot decrease the limit below 0.", + "admin.customization.uniqueEmojiReactionLimitPerPostDesc": "The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.", + "admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder": "E.g.: 25", "admin.data_grid.empty": "No items found", "admin.data_grid.loading": "Loading", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}", @@ -4515,6 +4520,9 @@ "quick_switch_modal.help_no_team": "Type to find a channel. Use **UP/DOWN** to browse, **ENTER** to select, **ESC** to dismiss.", "quick_switch_modal.input": "quick switch input", "quick_switch_modal.switchChannels": "Find Channels", + "reaction_limit_reached_modal.body": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit.", + "reaction_limit_reached_modal.body.admin": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>.", + "reaction_limit_reached_modal.title": "You've reached the reaction limit", "reaction_list.addReactionTooltip": "Add a reaction", "reaction.add.ariaLabel": "Add a reaction", "reaction.clickToAdd": "(click to add)",
webapp/channels/src/utils/constants.tsx+1 −0 modified@@ -446,6 +446,7 @@ export const ModalIdentifiers = { START_TRIAL_FORM_MODAL: 'start_trial_form_modal', START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result', CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel', + REACTION_LIMIT_REACHED: 'reaction_limit_reached', }; export const UserStatuses = {
webapp/channels/src/utils/post_utils.test.tsx+40 −0 modified@@ -1118,3 +1118,43 @@ describe('PostUtils.getPostURL', () => { expect(PostUtils.getPostURL(state, postCase)).toBe(expected); }); }); + +describe('makeGetUniqueEmojiNameReactionsForPost', () => { + const baseState = { + entities: { + posts: { + reactions: { + post_id_1: { + user_1_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_2_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_3_post_id_1_smile: { + emoji_name: 'smile', + post_id: 'post_id_1', + }, + user_1_post_id_1_cry: { + emoji_name: 'cry', + post_id: 'post_id_1', + }, + }, + + }, + }, + general: { + config: {}, + }, + emojis: {}, + }, + } as unknown as GlobalState; + + test('should only return names of unique reactions', () => { + const getUniqueEmojiNameReactionsForPost = PostUtils.makeGetUniqueEmojiNameReactionsForPost(); + + expect(getUniqueEmojiNameReactionsForPost(baseState, 'post_id_1')).toEqual(['smile', 'cry']); + }); +});
webapp/channels/src/utils/post_utils.ts+25 −0 modified@@ -704,6 +704,31 @@ export function makeGetUniqueReactionsToPost(): (state: GlobalState, postId: Pos ); } +export function makeGetUniqueEmojiNameReactionsForPost(): (state: GlobalState, postId: Post['id']) => string[] | undefined | null { + const getReactionsForPost = makeGetReactionsForPost(); + + return createSelector( + 'makeGetUniqueEmojiReactionsForPost', + (state: GlobalState, postId: string) => getReactionsForPost(state, postId), + getEmojiMap, + (reactions, emojiMap) => { + if (!reactions) { + return null; + } + + const emojiNames: string[] = []; + + Object.values(reactions).forEach((reaction) => { + if (emojiMap.get(reaction.emoji_name) && !emojiNames.includes(reaction.emoji_name)) { + emojiNames.push(reaction.emoji_name); + } + }); + + return emojiNames; + }, + ); +} + export function getUserOrGroupFromMentionName(usersByUsername: Record<string, UserProfile | Group>, mentionName: string) { let mentionNameToLowerCase = mentionName.toLowerCase();
webapp/platform/types/src/config.ts+2 −0 modified@@ -201,6 +201,7 @@ export type ClientConfig = { AllowPersistentNotificationsForGuests: string; DelayChannelAutocomplete: 'true' | 'false'; ServiceEnvironment: string; + UniqueEmojiReactionLimitPerPost: string; }; export type License = { @@ -379,6 +380,7 @@ export type ServiceSettings = { PersistentNotificationIntervalMinutes: number; PersistentNotificationMaxCount: number; PersistentNotificationMaxRecipients: number; + UniqueEmojiReactionLimitPerPost: number; }; export type TeamSettings = {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-32h7-7j94-8fc2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-1402ghsaADVISORY
- github.com/mattermost/mattermost/commit/64cb0ca8af2dbda1afcddd1604460591a4799b81ghsaWEB
- github.com/mattermost/mattermost/commit/6d2440de9fd774b67e65e3aac4ab8b6ef9aba2d8ghsaWEB
- github.com/mattermost/mattermost/commit/81190e2da128a6985914ea7023a69ac400513fc4ghsaWEB
- mattermost.com/security-updatesghsaWEB
News mentions
0No linked articles in our index yet.