VYPR
Moderate severityNVD Advisory· Published Feb 9, 2024· Updated Aug 1, 2024

Denial of service in mattermost mobile apps and server via emoji reactions

CVE-2024-1402

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.

PackageAffected versionsPatched versions
github.com/mattermost/mattermost/server/v8Go
< 8.1.88.1.8
github.com/mattermost/mattermost/server/v8Go
>= 9.2.0, < 9.2.49.2.4
github.com/mattermost/mattermost/server/v8Go
>= 9.1.0, < 9.1.59.1.5

Affected products

1

Patches

3
64cb0ca8af2d

Cherry-pick of #25331 (#25589)

https://github.com/mattermost/mattermostDevin BinnieNov 30, 2023via ghsa
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)

https://github.com/mattermost/mattermostDevin BinnieNov 29, 2023via ghsa
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)

https://github.com/mattermost/mattermostDevin BinnieNov 29, 2023via ghsa
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

News mentions

0

No linked articles in our index yet.