VYPR
Moderate severityNVD Advisory· Published Aug 23, 2024· Updated Aug 23, 2024

Excessive Resource Consumption via `/export`

CVE-2024-43105

Description

Mattermost Plugin Channel Export versions <=1.0.0 fail to restrict concurrent runs of the /export command which allows a user to consume excessive resource by running the /export command multiple times at once.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/mattermost/mattermost-plugin-channel-exportGo
< 1.0.11.0.1

Affected products

1

Patches

1
bb6da1f6bedd

MM-59032 Disallow concurrent exports (#43)

18 files changed · +488 67
  • .github/workflows/ci.yml+3 2 modified
    @@ -1,7 +1,5 @@
     name: ci
     on:
    -  schedule:
    -    - cron: "0 0 * * *"
       push:
         branches:
           - master
    @@ -15,4 +13,7 @@ permissions:
     jobs:
       plugin-ci:
         uses: mattermost/actions-workflows/.github/workflows/plugin-ci.yml@main
    +    with:
    +      golangci-lint-version: "v1.54.2"
    +      golang-version: "1.21"
         secrets: inherit
    
  • go.mod+2 1 modified
    @@ -1,6 +1,6 @@
     module github.com/mattermost/mattermost-plugin-channel-export
     
    -go 1.14
    +go 1.16
     
     require (
     	github.com/golang/mock v1.4.4
    @@ -10,4 +10,5 @@ require (
     	github.com/pkg/errors v0.9.1
     	github.com/sirupsen/logrus v1.8.1
     	github.com/stretchr/testify v1.7.0
    +	github.com/wiggin77/merror v1.0.3
     )
    
  • Makefile+15 0 modified
    @@ -213,6 +213,21 @@ else
     endif
     endif
     
    +## Generate mocks
    +mock:
    +ifneq ($(HAS_SERVER),)
    +	go install github.com/golang/mock/mockgen
    +	mockgen -destination server/pluginapi/mock_pluginapi/channel.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Channel
    +	mockgen -destination server/pluginapi/mock_pluginapi/file.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi File
    +	mockgen -destination server/pluginapi/mock_pluginapi/log.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Log
    +	mockgen -destination server/pluginapi/mock_pluginapi/post.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Post
    +	mockgen -destination server/pluginapi/mock_pluginapi/slashcommand.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi SlashCommand
    +	mockgen -destination server/pluginapi/mock_pluginapi/user.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi User
    +	mockgen -destination server/pluginapi/mock_pluginapi/system.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi System
    +	mockgen -destination server/pluginapi/mock_pluginapi/configuration.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Configuration
    +	mockgen -destination server/pluginapi/mock_pluginapi/cluster.go github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi Cluster
    +endif
    +
     ## Disable the plugin.
     .PHONY: disable
     disable: detach
    
  • server/api.go+26 1 modified
    @@ -1,9 +1,11 @@
     package main
     
     import (
    +	"context"
     	"encoding/json"
     	"fmt"
     	"net/http"
    +	"time"
     
     	"github.com/gorilla/mux"
     	"github.com/sirupsen/logrus"
    @@ -12,22 +14,34 @@ import (
     	"github.com/mattermost/mattermost-server/v6/model"
     )
     
    +const (
    +	KeyClusterMutex = "mutex_exporter"
    +)
    +
     // Handler encapsulates the context necessary for the channel export API.
     type Handler struct {
     	client            *pluginapi.Wrapper
     	makePostsIterator func(*model.Channel, bool) PostIterator
    +	clusterMutex      pluginapi.ClusterMutex
     }
     
     // registerAPI registers the API against the given router.
    -func registerAPI(router *mux.Router, client *pluginapi.Wrapper, makePostsIterator func(*model.Channel, bool) PostIterator) {
    +func registerAPI(router *mux.Router, client *pluginapi.Wrapper, makePostsIterator func(*model.Channel, bool) PostIterator) error {
    +	clusterMutex, err := client.Cluster.NewMutex(KeyClusterMutex)
    +	if err != nil {
    +		return fmt.Errorf("cannot create cluster mutex: %w", err)
    +	}
    +
     	handler := &Handler{
     		client:            client,
     		makePostsIterator: makePostsIterator,
    +		clusterMutex:      clusterMutex,
     	}
     
     	api := router.PathPrefix("/api/v1").Subrouter()
     	api.Use(mattermostAuthorizationRequired)
     	api.HandleFunc("/export", handler.Export)
    +	return nil
     }
     
     // APIError is a type of error returned by the API.
    @@ -88,6 +102,17 @@ func (h *Handler) hasPermissionToChannel(userID, channelID string) (*model.Chann
     
     // Export handles /api/v1/export, exporting the requested channel.
     func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
    +	// only allow one export at a time
    +	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
    +	defer cancel()
    +	if err := h.clusterMutex.LockWithContext(ctx); err != nil {
    +		handleError(w, http.StatusServiceUnavailable, "a channel export is already running.")
    +		return
    +	}
    +	defer func() {
    +		h.clusterMutex.Unlock()
    +	}()
    +
     	license := h.client.System.GetLicense()
     	if !isLicensed(license, h.client) {
     		handleError(w, http.StatusBadRequest, "the channel export plugin requires a valid E20 license.")
    
  • server/api_test.go+123 16 modified
    @@ -2,24 +2,29 @@ package main
     
     import (
     	"bytes"
    +	"io"
     	"io/ioutil"
     	"net/http"
     	"net/http/httptest"
    +	"sync"
     	"testing"
     	"time"
     
     	"github.com/golang/mock/gomock"
     	"github.com/gorilla/mux"
    -	"github.com/mattermost/mattermost-server/v6/model"
     	"github.com/stretchr/testify/require"
    +	"github.com/wiggin77/merror"
    +
    +	"github.com/mattermost/mattermost-server/v6/model"
     
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi"
     )
     
    -func setupAPI(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time, userID, channelID string) string {
    +func setupAPI(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time, userID, _ /*channelID*/ string) string {
     	router := mux.NewRouter()
    -	registerAPI(router, mockAPI, makeTestPostsIterator(t, now))
    +	err := registerAPI(router, mockAPI, makeTestPostsIterator(t, now))
    +	require.NoError(t, err)
     
     	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     		// Simulate what the Mattermost server would normally do after validating a token.
    @@ -41,10 +46,25 @@ func TestHandler(t *testing.T) {
     	falseValue := false
     
     	t.Run("unauthorized", func(t *testing.T) {
    -		address := setupAPI(t, nil, time.Now(), "", "channel_id")
    +		mockCtrl := gomock.NewController(t)
    +
    +		mockChannel := mock_pluginapi.NewMockChannel(mockCtrl)
    +		mockFile := mock_pluginapi.NewMockFile(mockCtrl)
    +		mockLog := mock_pluginapi.NewMockLog(mockCtrl)
    +		mockPost := mock_pluginapi.NewMockPost(mockCtrl)
    +		mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl)
    +		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
    +		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
    +		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
    +
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
    +
    +		address := setupAPI(t, mockAPI, time.Now(), "", "channel_id")
     		client := NewClient(address)
     
    -		err := client.ExportChannel(ioutil.Discard, "channel_id", FormatCSV)
    +		err := client.ExportChannel(io.Discard, "channel_id", FormatCSV)
     		require.EqualError(t, err, "failed with status code 401")
     	})
     
    @@ -59,8 +79,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id")
     		client := NewClient(address)
    @@ -74,7 +96,7 @@ func TestHandler(t *testing.T) {
     			},
     		}).Times(1)
     
    -		err := client.ExportChannel(ioutil.Discard, "channel_id", FormatCSV)
    +		err := client.ExportChannel(io.Discard, "channel_id", FormatCSV)
     		require.EqualError(t, err, "the channel export plugin requires a valid E20 license.")
     	})
     
    @@ -89,8 +111,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		userID := "user_id"
    @@ -137,8 +161,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id")
     		client := NewClient(address)
    @@ -164,8 +190,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id")
     		client := NewClient(address)
    @@ -191,8 +219,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", "channel_id")
     		client := NewClient(address)
    @@ -218,8 +248,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		channelID := "channel_id"
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", channelID)
    @@ -247,8 +279,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		channelID := "channel_id"
     		address := setupAPI(t, mockAPI, time.Now(), "user_id", channelID)
    @@ -276,8 +310,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		userID := "user_id"
     		channelID := "channel_id"
    @@ -307,8 +343,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		userID := "user_id"
    @@ -352,8 +390,10 @@ func TestHandler(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		userID := "user_id"
    @@ -385,4 +425,71 @@ func TestHandler(t *testing.T) {
     
     		require.Equal(t, expected, buffer.String())
     	})
    +
    +	t.Run("don't allow concurrent exports", func(t *testing.T) {
    +		mockCtrl := gomock.NewController(t)
    +
    +		mockChannel := mock_pluginapi.NewMockChannel(mockCtrl)
    +		mockFile := mock_pluginapi.NewMockFile(mockCtrl)
    +		mockLog := mock_pluginapi.NewMockLog(mockCtrl)
    +		mockPost := mock_pluginapi.NewMockPost(mockCtrl)
    +		mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl)
    +		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
    +		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
    +		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
    +
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
    +
    +		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
    +		userID := "user_id"
    +		channelID := "channel_id"
    +		address := setupAPI(t, mockAPI, now, userID, channelID)
    +		client := NewClient(address)
    +		client.SetToken("token")
    +
    +		mockSystem.EXPECT().GetLicense().Do(func() {
    +			t.Log("about to sleep for 3s")
    +			time.Sleep(time.Second * 3)
    +		}).Return(&model.License{Features: &model.Features{
    +			FutureFeatures: &trueValue,
    +		}}).Times(2)
    +		mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1)
    +		mockChannel.EXPECT().Get(channelID).Return(&model.Channel{Id: channelID}, nil).Times(1)
    +		mockUser.EXPECT().HasPermissionToChannel(userID, channelID, model.PermissionReadChannel).Return(true).Times(1)
    +		mockUser.EXPECT().HasPermissionTo(userID, model.PermissionManageSystem).Return(false).Times(1)
    +		mockConfiguration.EXPECT().GetConfig().Return(&model.Config{
    +			PrivacySettings: model.PrivacySettings{
    +				ShowEmailAddress: &trueValue,
    +			},
    +		})
    +
    +		countExec := 3
    +		merr := merror.New()
    +		wg := sync.WaitGroup{}
    +
    +		for i := 0; i < countExec; i++ {
    +			wg.Add(1)
    +			go func() {
    +				defer wg.Done()
    +				var buffer bytes.Buffer
    +				if err := client.ExportChannel(&buffer, channelID, FormatCSV); err != nil {
    +					merr.Append(err)
    +				} else {
    +					expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type
    +2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message
    +`
    +					require.Equal(t, expected, buffer.String())
    +				}
    +			}()
    +		}
    +
    +		wg.Wait()
    +
    +		require.Equal(t, countExec-1, merr.Len())
    +		for _, err := range merr.Errors() {
    +			require.Equal(t, "a channel export is already running.", err.Error())
    +		}
    +	})
     }
    
  • server/command_hooks.go+32 1 modified
    @@ -1,9 +1,12 @@
     package main
     
     import (
    +	"context"
     	"fmt"
     	"io"
     	"strings"
    +	"sync"
    +	"time"
     
     	"github.com/pkg/errors"
     	"github.com/sirupsen/logrus"
    @@ -28,7 +31,7 @@ func (p *Plugin) registerCommands() error {
     
     // ExecuteCommand executes a command that has been previously registered via the RegisterCommand
     // API.
    -func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
    +func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
     	trigger := strings.TrimPrefix(strings.Fields(args.Command)[0], "/")
     	switch trigger {
     	case exportCommandTrigger:
    @@ -43,6 +46,22 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
     }
     
     func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandResponse {
    +	// only allow one export at a time
    +	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
    +	defer cancel()
    +	if err := p.clusterMutex.LockWithContext(ctx); err != nil {
    +		return &model.CommandResponse{
    +			ResponseType: model.CommandResponseTypeEphemeral,
    +			Text:         "An export is already running.",
    +		}
    +	}
    +	var active bool
    +	defer func() {
    +		if !active {
    +			p.clusterMutex.Unlock()
    +		}
    +	}()
    +
     	license := p.client.System.GetLicense()
     	if !isLicensed(license, p.client) {
     		return &model.CommandResponse{
    @@ -84,7 +103,12 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
     	})
     
     	exportedFileReader, exportedFileWriter := io.Pipe()
    +	wg := sync.WaitGroup{}
    +	wg.Add(2)
    +	active = true
    +
     	go func() {
    +		defer wg.Done()
     		err := exporter.Export(p.makeChannelPostsIterator(channelToExport, showEmailAddress(p.client, args.UserId)), exportedFileWriter)
     		if err != nil {
     			logger.WithError(err).Warn("failed to export channel")
    @@ -107,6 +131,7 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
     	}()
     
     	go func() {
    +		defer wg.Done()
     		file, err := p.uploadFileTo(fileName, exportedFileReader, channelDM.Id)
     		if err != nil {
     			logger.WithError(err).Warn("failed to upload exported channel")
    @@ -137,6 +162,12 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
     		}
     	}()
     
    +	// wait until both goroutines above are completed then mark exporter inactive
    +	go func() {
    +		defer p.clusterMutex.Unlock()
    +		wg.Wait()
    +	}()
    +
     	return &model.CommandResponse{
     		ResponseType: model.CommandResponseTypeEphemeral,
     		Text: fmt.Sprintf("Exporting ~%s. @%s will send you a direct message when the export is ready.",
    
  • server/command_hooks_test.go+108 17 modified
    @@ -2,25 +2,30 @@ package main
     
     import (
     	"io"
    -	"io/ioutil"
    +	"sync"
     	"testing"
     	"time"
     
     	"github.com/golang/mock/gomock"
    +	"github.com/pkg/errors"
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi"
     	"github.com/mattermost/mattermost-server/v6/model"
     	"github.com/mattermost/mattermost-server/v6/plugin"
    -	"github.com/pkg/errors"
    -	"github.com/stretchr/testify/assert"
    -	"github.com/stretchr/testify/require"
     )
     
     func setupPlugin(t *testing.T, mockAPI *pluginapi.Wrapper, now time.Time) (*Plugin, *plugin.Context) {
    +	clusterMutex, err := mockAPI.Cluster.NewMutex(KeyClusterMutex)
    +	require.NoError(t, err)
    +
     	return &Plugin{
     		client:                   mockAPI,
     		botID:                    "bot_id",
     		makeChannelPostsIterator: makeTestPostsIterator(t, now),
    +		clusterMutex:             clusterMutex,
     	}, &plugin.Context{}
     }
     
    @@ -39,8 +44,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		plugin, pluginContext := setupPlugin(t, mockAPI, time.Now())
     
    @@ -64,8 +71,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		plugin, pluginContext := setupPlugin(t, mockAPI, time.Now())
     
    @@ -97,8 +106,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		plugin, pluginContext := setupPlugin(t, mockAPI, now)
    @@ -118,8 +129,8 @@ func TestExecuteCommand(t *testing.T) {
     				ShowEmailAddress: &trueValue,
     			},
     		})
    -		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) {
    -			contents, err := ioutil.ReadAll(reader)
    +		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) {
    +			contents, err := io.ReadAll(reader)
     			require.NoError(t, err)
     			expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type
     2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message
    @@ -160,8 +171,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		plugin, pluginContext := setupPlugin(t, mockAPI, time.Now())
     
    @@ -193,8 +206,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		plugin, pluginContext := setupPlugin(t, mockAPI, time.Now())
     
    @@ -228,8 +243,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		plugin, pluginContext := setupPlugin(t, mockAPI, now)
    @@ -242,8 +259,8 @@ func TestExecuteCommand(t *testing.T) {
     		mockChannel.EXPECT().GetDirect("user_id", "bot_id").Return(&model.Channel{Id: "direct"}, nil)
     		mockUser.EXPECT().HasPermissionTo("user_id", model.PermissionManageSystem).Return(false).Times(1)
     		mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1)
    -		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) {
    -			contents, err := ioutil.ReadAll(reader)
    +		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) {
    +			contents, err := io.ReadAll(reader)
     			require.NoError(t, err)
     			expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type
     2009-11-11 07:00:00 +0000 UTC,post_user_id,,user,post_user_nickname,post_id,post_parent_id,post_message,message
    @@ -284,8 +301,10 @@ func TestExecuteCommand(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
     		plugin, pluginContext := setupPlugin(t, mockAPI, now)
    @@ -302,8 +321,8 @@ func TestExecuteCommand(t *testing.T) {
     				ShowEmailAddress: &trueValue,
     			},
     		})
    -		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, fileName, channelID string) {
    -			contents, err := ioutil.ReadAll(reader)
    +		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) {
    +			contents, err := io.ReadAll(reader)
     			require.NoError(t, err)
     			expected := `Post Creation Time,User Id,User Email,User Type,User Name,Post Id,Parent Post Id,Post Message,Post Type
     2009-11-11 07:00:00 +0000 UTC,post_user_id,post_user_email,user,post_user_nickname,post_id,post_parent_id,post_message,message
    @@ -332,4 +351,76 @@ func TestExecuteCommand(t *testing.T) {
     		// mock assertions.
     		time.Sleep(1 * time.Second)
     	})
    +
    +	t.Run("disallow concurrent exports", func(t *testing.T) {
    +		mockCtrl := gomock.NewController(t)
    +
    +		mockChannel := mock_pluginapi.NewMockChannel(mockCtrl)
    +		mockFile := mock_pluginapi.NewMockFile(mockCtrl)
    +		mockLog := mock_pluginapi.NewMockLog(mockCtrl)
    +		mockPost := mock_pluginapi.NewMockPost(mockCtrl)
    +		mockSlashCommand := mock_pluginapi.NewMockSlashCommand(mockCtrl)
    +		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
    +		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
    +		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
    +		mockCluster.EXPECT().NewMutex(gomock.Eq(KeyClusterMutex)).Return(pluginapi.NewClusterMutexMock(), nil)
    +
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
    +
    +		now := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.FixedZone("UTC-8", -8*60*60))
    +		plugin, pluginContext := setupPlugin(t, mockAPI, now)
    +
    +		mockSystem.EXPECT().GetLicense().Return(&model.License{Features: &model.Features{
    +			FutureFeatures: &trueValue,
    +		}}).Times(2)
    +		mockConfiguration.EXPECT().GetConfig().Return(&model.Config{}).Times(1)
    +		mockChannel.EXPECT().Get("channel_id").Return(&model.Channel{Id: "channel_id", Name: "channel_name"}, nil)
    +		mockChannel.EXPECT().GetDirect("user_id", "bot_id").Return(&model.Channel{Id: "direct"}, nil)
    +		mockUser.EXPECT().HasPermissionTo("user_id", model.PermissionManageSystem).Return(false).Times(1)
    +		mockConfiguration.EXPECT().GetConfig().Return(&model.Config{
    +			PrivacySettings: model.PrivacySettings{
    +				ShowEmailAddress: &trueValue,
    +			},
    +		})
    +		wg := sync.WaitGroup{}
    +		wg.Add(1)
    +		mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").Do(func(reader io.Reader, _ /*fileName*/, _ /*channelID*/ string) {
    +			defer wg.Done()
    +			t.Log("about to sleep for 3s")
    +			time.Sleep(time.Second * 3)
    +			_, err := io.ReadAll(reader)
    +			require.NoError(t, err)
    +		}).Return(&model.FileInfo{Id: "file_id"}, nil)
    +		mockPost.EXPECT().CreatePost(&model.Post{
    +			UserId:    "bot_id",
    +			ChannelId: "direct",
    +			Message:   "Channel ~channel_name exported:",
    +			FileIds:   []string{"file_id"},
    +		})
    +
    +		commandResponse, appError := plugin.ExecuteCommand(pluginContext, &model.CommandArgs{
    +			Command:   "/export",
    +			ChannelId: "channel_id",
    +			UserId:    "user_id",
    +		})
    +
    +		require.Nil(t, appError)
    +		assert.Equal(t, model.CommandResponseTypeEphemeral, commandResponse.ResponseType)
    +		assert.Equal(t, "Exporting ~channel_name. @channelexport will send you a direct message when the export is ready.", commandResponse.Text)
    +
    +		// concurrent executions should fail
    +		for i := 0; i < 3; i++ {
    +			commandResponse, appError = plugin.ExecuteCommand(pluginContext, &model.CommandArgs{
    +				Command:   "/export",
    +				ChannelId: "channel_id",
    +				UserId:    "user_id",
    +			})
    +			require.Nil(t, appError)
    +			require.Equal(t, "An export is already running.", commandResponse.Text)
    +		}
    +
    +		// wait for upload to complete
    +		wg.Wait()
    +	})
     }
    
  • server/common_test.go+2 2 modified
    @@ -7,7 +7,7 @@ import (
     	"github.com/mattermost/mattermost-server/v6/model"
     )
     
    -func makeTestPostsIterator(t *testing.T, now time.Time) func(channel *model.Channel, showEmailAddress bool) PostIterator {
    +func makeTestPostsIterator(_ *testing.T, now time.Time) func(channel *model.Channel, showEmailAddress bool) PostIterator {
     	exportedPosts := []*ExportedPost{
     		{
     			CreateAt:     now.Round(time.Millisecond).UTC(),
    @@ -22,7 +22,7 @@ func makeTestPostsIterator(t *testing.T, now time.Time) func(channel *model.Chan
     		},
     	}
     
    -	return func(channel *model.Channel, showEmailAddress bool) PostIterator {
    +	return func(_ *model.Channel, showEmailAddress bool) PostIterator {
     		return func() ([]*ExportedPost, error) {
     			retExportedPosts := exportedPosts
     			if !showEmailAddress {
    
  • server/csv.go+4 0 modified
    @@ -4,6 +4,7 @@ import (
     	"encoding/csv"
     	"fmt"
     	"io"
    +	"time"
     
     	"github.com/pkg/errors"
     	"github.com/sirupsen/logrus"
    @@ -68,6 +69,9 @@ func (e *CSV) Export(nextPosts PostIterator, writer io.Writer) error {
     		if len(posts) == 0 {
     			break
     		}
    +
    +		// don't peg the CPU.
    +		time.Sleep(time.Millisecond * 20)
     	}
     
     	csvWriter.Flush()
    
  • server/exporter.go+5 3 modified
    @@ -3,16 +3,19 @@ package main
     import (
     	"time"
     
    +	"github.com/pkg/errors"
    +
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     	"github.com/mattermost/mattermost-server/v6/model"
     	"github.com/mattermost/mattermost-server/v6/utils"
    -	"github.com/pkg/errors"
     )
     
     const (
     	userTypeRegular = "user"
     	userTypeBot     = "bot"
     	userTypeSystem  = "system"
    +
    +	PerPage = 500
     )
     
     // PostIterator returns the next batch of posts when called
    @@ -38,9 +41,8 @@ type ExportedPost struct {
     func channelPostsIterator(client *pluginapi.Wrapper, channel *model.Channel, showEmailAddress bool) PostIterator {
     	usersCache := make(map[string]*model.User)
     	page := 0
    -	perPage := 1000
     	return func() ([]*ExportedPost, error) {
    -		postList, err := client.Post.GetPostsForChannel(channel.Id, page, perPage)
    +		postList, err := client.Post.GetPostsForChannel(channel.Id, page, PerPage)
     		if err != nil {
     			return nil, err
     		}
    
  • server/exporter_test.go+16 13 modified
    @@ -7,11 +7,12 @@ import (
     	"time"
     
     	"github.com/golang/mock/gomock"
    +	"github.com/pkg/errors"
    +	"github.com/stretchr/testify/require"
    +
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi"
     	"github.com/mattermost/mattermost-server/v6/model"
    -	"github.com/pkg/errors"
    -	"github.com/stretchr/testify/require"
     )
     
     func TestChannelPostsIterator(t *testing.T) {
    @@ -25,8 +26,9 @@ func TestChannelPostsIterator(t *testing.T) {
     	mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     	mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     	mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +	mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
     
    -	mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +	mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     	channel := &model.Channel{
     		Id: "jx2289hnvko3dypmc3thfcafpb",
    @@ -60,7 +62,7 @@ func TestChannelPostsIterator(t *testing.T) {
     	t.Run("One post iterator", func(t *testing.T) {
     		postIterator := channelPostsIterator(mockAPI, channel, false)
     
    -		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&postList, nil).Times(1)
    +		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&postList, nil).Times(1)
     		mockUser.EXPECT().Get(post.UserId).Return(&user, nil).Times(1)
     
     		posts, err := postIterator()
    @@ -71,7 +73,7 @@ func TestChannelPostsIterator(t *testing.T) {
     	t.Run("Paging is correct", func(t *testing.T) {
     		postIterator := channelPostsIterator(mockAPI, channel, false)
     
    -		length := 1000
    +		length := PerPage
     		posts := make(map[string]*model.Post, length)
     		order := make([]string, length)
     
    @@ -93,8 +95,8 @@ func TestChannelPostsIterator(t *testing.T) {
     		secondPage := postList
     
     		gomock.InOrder(
    -			mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&firstPage, nil).Times(1),
    -			mockPost.EXPECT().GetPostsForChannel(channel.Id, 1, 1000).Return(&secondPage, nil).Times(1),
    +			mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&firstPage, nil).Times(1),
    +			mockPost.EXPECT().GetPostsForChannel(channel.Id, 1, PerPage).Return(&secondPage, nil).Times(1),
     		)
     
     		// Called only once because we are setting the same user in all posts,
    @@ -128,7 +130,7 @@ func TestChannelPostsIterator(t *testing.T) {
     			Order: []string{editedPost.Id},
     		}
     
    -		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&editedPostList, nil).Times(1)
    +		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&editedPostList, nil).Times(1)
     
     		posts, err := postIterator()
     		require.NoError(t, err)
    @@ -139,7 +141,7 @@ func TestChannelPostsIterator(t *testing.T) {
     		postIterator := channelPostsIterator(mockAPI, channel, false)
     
     		expectedError := errors.New("error retreiving posts")
    -		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(nil, expectedError).Times(1)
    +		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(nil, expectedError).Times(1)
     
     		posts, err := postIterator()
     		require.Nil(t, posts)
    @@ -151,7 +153,7 @@ func TestChannelPostsIterator(t *testing.T) {
     
     		expectedError := fmt.Errorf("new error")
     		mockUser.EXPECT().Get(post.UserId).Return(nil, expectedError).Times(1)
    -		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, 1000).Return(&postList, nil).Times(1)
    +		mockPost.EXPECT().GetPostsForChannel(channel.Id, 0, PerPage).Return(&postList, nil).Times(1)
     
     		posts, err := postIterator()
     		require.Nil(t, posts)
    @@ -170,8 +172,9 @@ func TestToExportedPost(t *testing.T) {
     	mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     	mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     	mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +	mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
     
    -	mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +	mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     	now := time.Now().Round(time.Millisecond)
     	userID := "h6itnszvtit5k2jhi2c1o3p7ox"
    @@ -237,8 +240,8 @@ func TestToExportedPost(t *testing.T) {
     		require.NoError(t, post.ShallowCopy(&postWithoutUserID))
     		postWithoutUserID.UserId = "unknown_user_id"
     
    -		error := fmt.Errorf("new error")
    -		mockUser.EXPECT().Get(postWithoutUserID.UserId).Return(nil, error).Times(1)
    +		err := fmt.Errorf("new error")
    +		mockUser.EXPECT().Get(postWithoutUserID.UserId).Return(nil, err).Times(1)
     
     		usersCache := make(map[string]*model.User)
     		actualExportedPost, err := toExportedPost(mockAPI, &postWithoutUserID, false, usersCache)
    
  • server/license.go+1 1 modified
    @@ -7,6 +7,6 @@ import (
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     )
     
    -func isLicensed(license *model.License, api *pluginapi.Wrapper) bool {
    +func isLicensed(_ *model.License, api *pluginapi.Wrapper) bool {
     	return originalapi.IsE20LicensedOrDevelopment(api.Configuration.GetConfig(), api.System.GetLicense())
     }
    
  • server/permission_test.go+8 4 modified
    @@ -4,10 +4,11 @@ import (
     	"testing"
     
     	"github.com/golang/mock/gomock"
    +	"github.com/stretchr/testify/assert"
    +
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
     	"github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi/mock_pluginapi"
     	"github.com/mattermost/mattermost-server/v6/model"
    -	"github.com/stretchr/testify/assert"
     )
     
     func TestShowEmailAddress(t *testing.T) {
    @@ -25,8 +26,9 @@ func TestShowEmailAddress(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		userID := "user_id"
     
    @@ -45,8 +47,9 @@ func TestShowEmailAddress(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		userID := "user_id"
     
    @@ -70,8 +73,9 @@ func TestShowEmailAddress(t *testing.T) {
     		mockUser := mock_pluginapi.NewMockUser(mockCtrl)
     		mockSystem := mock_pluginapi.NewMockSystem(mockCtrl)
     		mockConfiguration := mock_pluginapi.NewMockConfiguration(mockCtrl)
    +		mockCluster := mock_pluginapi.NewMockCluster(mockCtrl)
     
    -		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration)
    +		mockAPI := pluginapi.CustomWrapper(mockChannel, mockFile, mockLog, mockPost, mockSlashCommand, mockUser, mockSystem, mockConfiguration, mockCluster)
     
     		userID := "user_id"
     
    
  • server/pluginapi/cluster.go+29 0 added
    @@ -0,0 +1,29 @@
    +package pluginapi
    +
    +import (
    +	"context"
    +
    +	"github.com/mattermost/mattermost-plugin-api/cluster"
    +	"github.com/mattermost/mattermost-server/v6/plugin"
    +)
    +
    +type ClusterMutex interface {
    +	LockWithContext(ctx context.Context) error
    +	Unlock()
    +}
    +
    +// ClusterService exposes methods from the mm server cluster package.
    +type ClusterService struct {
    +	api plugin.API
    +}
    +
    +func NewClusterService(api plugin.API) *ClusterService {
    +	return &ClusterService{
    +		api: api,
    +	}
    +}
    +
    +// NewMutex creates a mutex with the given key name.
    +func (c *ClusterService) NewMutex(key string) (ClusterMutex, error) {
    +	return cluster.NewMutex(c.api, key)
    +}
    
  • server/pluginapi/mock_pluginapi/cluster.go+49 0 added
    @@ -0,0 +1,49 @@
    +// Code generated by MockGen. DO NOT EDIT.
    +// Source: github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi (interfaces: Cluster)
    +
    +// Package mock_pluginapi is a generated GoMock package.
    +package mock_pluginapi
    +
    +import (
    +	gomock "github.com/golang/mock/gomock"
    +	pluginapi "github.com/mattermost/mattermost-plugin-channel-export/server/pluginapi"
    +	reflect "reflect"
    +)
    +
    +// MockCluster is a mock of Cluster interface
    +type MockCluster struct {
    +	ctrl     *gomock.Controller
    +	recorder *MockClusterMockRecorder
    +}
    +
    +// MockClusterMockRecorder is the mock recorder for MockCluster
    +type MockClusterMockRecorder struct {
    +	mock *MockCluster
    +}
    +
    +// NewMockCluster creates a new mock instance
    +func NewMockCluster(ctrl *gomock.Controller) *MockCluster {
    +	mock := &MockCluster{ctrl: ctrl}
    +	mock.recorder = &MockClusterMockRecorder{mock}
    +	return mock
    +}
    +
    +// EXPECT returns an object that allows the caller to indicate expected use
    +func (m *MockCluster) EXPECT() *MockClusterMockRecorder {
    +	return m.recorder
    +}
    +
    +// NewMutex mocks base method
    +func (m *MockCluster) NewMutex(arg0 string) (pluginapi.ClusterMutex, error) {
    +	m.ctrl.T.Helper()
    +	ret := m.ctrl.Call(m, "NewMutex", arg0)
    +	ret0, _ := ret[0].(pluginapi.ClusterMutex)
    +	ret1, _ := ret[1].(error)
    +	return ret0, ret1
    +}
    +
    +// NewMutex indicates an expected call of NewMutex
    +func (mr *MockClusterMockRecorder) NewMutex(arg0 interface{}) *gomock.Call {
    +	mr.mock.ctrl.T.Helper()
    +	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewMutex", reflect.TypeOf((*MockCluster)(nil).NewMutex), arg0)
    +}
    
  • server/pluginapi/mutex_mock.go+41 0 added
    @@ -0,0 +1,41 @@
    +package pluginapi
    +
    +import (
    +	"context"
    +	"errors"
    +	"sync/atomic"
    +	"time"
    +)
    +
    +var (
    +	ErrLockTimeout = errors.New("timeout")
    +)
    +
    +type ClusterMutexMock struct {
    +	locked int32
    +}
    +
    +func NewClusterMutexMock() *ClusterMutexMock {
    +	return &ClusterMutexMock{}
    +}
    +
    +func (m *ClusterMutexMock) LockWithContext(ctx context.Context) error {
    +	for {
    +		select {
    +		case <-ctx.Done():
    +			return ctx.Err()
    +		default:
    +			if atomic.CompareAndSwapInt32(&m.locked, 0, 1) {
    +				// we have the lock
    +				return nil
    +			}
    +		}
    +		time.Sleep(time.Millisecond * 20)
    +	}
    +}
    +
    +func (m *ClusterMutexMock) Unlock() {
    +	if !atomic.CompareAndSwapInt32(&m.locked, 1, 0) {
    +		panic("not locked")
    +	}
    +}
    
  • server/pluginapi/pluginapi.go+10 1 modified
    @@ -4,6 +4,7 @@ import (
     	"io"
     
     	"github.com/mattermost/mattermost-server/v6/model"
    +	"github.com/mattermost/mattermost-server/v6/plugin"
     
     	pluginapi "github.com/mattermost/mattermost-plugin-api"
     )
    @@ -60,6 +61,10 @@ type Configuration interface {
     	GetConfig() *model.Config
     }
     
    +type Cluster interface {
    +	NewMutex(key string) (ClusterMutex, error)
    +}
    +
     // Wrapper is a wrapper over the mattermost-plugin-api layer, defining
     // interfaces implemented by that package, that are also mockable
     type Wrapper struct {
    @@ -71,6 +76,7 @@ type Wrapper struct {
     	User          User
     	System        System
     	Configuration Configuration
    +	Cluster       Cluster
     }
     
     // CustomWrapper builds a Wrapper with the implementations of the different
    @@ -84,6 +90,7 @@ func CustomWrapper(
     	user User,
     	system System,
     	configuration Configuration,
    +	cluster Cluster,
     ) *Wrapper {
     	return &Wrapper{
     		Channel:       channel,
    @@ -94,12 +101,13 @@ func CustomWrapper(
     		User:          user,
     		System:        system,
     		Configuration: configuration,
    +		Cluster:       cluster,
     	}
     }
     
     // Wrap wraps a plugin.API with the mattermost-plugin-api layer, interfaced by
     // this package
    -func Wrap(client *pluginapi.Client) *Wrapper {
    +func Wrap(client *pluginapi.Client, api plugin.API) *Wrapper {
     	return CustomWrapper(
     		&client.Channel,
     		&client.File,
    @@ -109,5 +117,6 @@ func Wrap(client *pluginapi.Client) *Wrapper {
     		&client.User,
     		&client.System,
     		&client.Configuration,
    +		&ClusterService{api: api},
     	)
     }
    
  • server/plugin.go+14 5 modified
    @@ -1,6 +1,7 @@
     package main
     
     import (
    +	"fmt"
     	"net/http"
     	"sync"
     
    @@ -36,6 +37,9 @@ type Plugin struct {
     
     	// configurationLock synchronizes access to the configuration.
     	configurationLock sync.RWMutex
    +
    +	// clusterMutex is used to ensure only one export can be done at a time across the cluster
    +	clusterMutex pluginAPIWrapper.ClusterMutex
     }
     
     const (
    @@ -47,10 +51,17 @@ const (
     // OnActivate is invoked when the plugin is activated.
     func (p *Plugin) OnActivate() error {
     	client := pluginapi.NewClient(p.API, p.Driver)
    -	p.client = pluginAPIWrapper.Wrap(client)
    +	p.client = pluginAPIWrapper.Wrap(client, p.API)
     	p.clientPluginAPI = client
     	pluginapi.ConfigureLogrus(logrus.New(), client)
     
    +	clusterService := pluginAPIWrapper.NewClusterService(p.API)
    +	clusterMutex, err := clusterService.NewMutex(KeyClusterMutex)
    +	if err != nil {
    +		return fmt.Errorf("cannot create cluster mutex: %w", err)
    +	}
    +	p.clusterMutex = clusterMutex
    +
     	botID, err := p.clientPluginAPI.Bot.EnsureBot(&model.Bot{
     		Username:    botUsername,
     		DisplayName: botDisplayName,
    @@ -70,12 +81,10 @@ func (p *Plugin) OnActivate() error {
     	p.makeChannelPostsIterator = func(channel *model.Channel, showEmailAddress bool) PostIterator {
     		return channelPostsIterator(p.client, channel, showEmailAddress)
     	}
    -	registerAPI(p.router, p.client, p.makeChannelPostsIterator)
    -
    -	return nil
    +	return registerAPI(p.router, p.client, p.makeChannelPostsIterator)
     }
     
     // ServeHTTP handles requests to /plugins/com.mattermost.plugin-incident-response
    -func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
    +func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
     	p.router.ServeHTTP(w, r)
     }
    

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

4

News mentions

0

No linked articles in our index yet.