Skip to content

Commit

Permalink
MM-60340 Enforce max file size when exporting to file via slash comma…
Browse files Browse the repository at this point in the history
…nd (#61)

* add config for max file size; enforce max file size for slash command
  • Loading branch information
wiggin77 authored Nov 8, 2024
1 parent 3e5043f commit fc4f64b
Show file tree
Hide file tree
Showing 10 changed files with 343 additions and 15 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ require (
github.com/mattermost/mattermost-server/v6 v6.0.3
github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.9.0
github.com/wiggin77/merror v1.0.3
)
14 changes: 11 additions & 3 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -833,16 +833,23 @@ github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.0/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
Expand Down Expand Up @@ -1384,8 +1391,9 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
Expand Down
13 changes: 10 additions & 3 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"homepage_url": "https://github.com/mattermost/mattermost-plugin-channel-export/",
"support_url": "https://github.com/mattermost/mattermost-plugin-channel-export/issues",
"release_notes_url": "https://github.com/mattermost/mattermost-plugin-channel-export/releases/tag/v1.0.0",
"version": "1.0.0",
"version": "1.0.1",
"min_server_version": "5.37.0",
"server": {
"executables": {
Expand All @@ -28,8 +28,15 @@
"display_name": "Enable Admin Restrictions",
"type": "bool",
"help_text": "Restricts the exporting of channels to system administrators or channel administrators",
"default": true
}
"default": false
},
{
"key": "MaxFileSize",
"display_name": "Maximum size of channel export file in bytes",
"type": "number",
"help_text": "Determines the maximum size of the channel export file when using the slash command. A value of 0 will use the [FileSettings.MaxFileSize](https://docs.mattermost.com/configure/environment-configuration-settings.html#maximum-file-size) from Mattermost server.",
"default": 0
}
]
}
}
14 changes: 10 additions & 4 deletions server/command_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"

"github.com/mattermost/mattermost-plugin-channel-export/server/util"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
)
Expand Down Expand Up @@ -162,7 +163,8 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
"channel_id": channelToExport.Id,
})

exportedFileReader, exportedFileWriter := io.Pipe()
exportedFileReader, exportedPipeWriter := io.Pipe()
exportedFileWriter := util.NewLimitPipeWriter(exportedPipeWriter, p.getMaxFileSize())
wg := sync.WaitGroup{}
wg.Add(2)
active = true
Expand All @@ -178,7 +180,7 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
err = p.client.Post.CreatePost(&model.Post{
UserId: p.botID,
ChannelId: channelDM.Id,
Message: fmt.Sprintf("An error occurred exporting channel ~%s.", channelToExportName),
Message: fmt.Sprintf("An error occurred exporting channel ~%s: %s", channelToExport.Name, err.Error()),
})
if err != nil {
logger.WithError(err).Warn("failed to post message about failure to export channel")
Expand All @@ -197,11 +199,12 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
logger.WithError(err).Warn("failed to upload exported channel")

// Post the upload error only if the exporter did not do it before
if !errors.Is(err, exportError) {
var errLimitExceeded util.ErrLimitExceeded
if !errors.Is(err, exportError) && !errors.As(err, &errLimitExceeded) {
err = p.client.Post.CreatePost(&model.Post{
UserId: p.botID,
ChannelId: channelDM.Id,
Message: fmt.Sprintf("An error occurred uploading the exported channel ~%s.", channelToExportName),
Message: fmt.Sprintf("An error occurred uploading the exported channel ~%s: %s", channelToExport.Name, err.Error()),
})
if err != nil {
logger.WithError(err).Warn("failed to post message about failure to upload exported channel")
Expand Down Expand Up @@ -234,6 +237,9 @@ func (p *Plugin) executeCommandExport(args *model.CommandArgs) *model.CommandRes
}
}

// uploadFileTo uploads the contents of an io.Reader to a file in the specified channel. Unfortunately MM server
// does not support streaming the file, therefore the entire file is first read into memory in the plugin api layer,
// and the whole file is passed to MM server as a []byte.
func (p *Plugin) uploadFileTo(fileName string, contents io.Reader, channelID string) (*model.FileInfo, error) {
file, err := p.client.File.Upload(contents, fileName, channelID)
if err != nil {
Expand Down
68 changes: 68 additions & 0 deletions server/command_hooks_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"io"
"sync"
"testing"
Expand Down Expand Up @@ -448,6 +449,73 @@ func TestExecuteCommand(t *testing.T) {
wg.Wait()
})

var (
mockTypeString = gomock.AssignableToTypeOf("string")
)

t.Run("export file size exceeds max", func(t *testing.T) {
const maxFileSize = 50

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)

// set maximum file size
configuration := plugin.getConfiguration()
configuration.MaxFileSize = maxFileSize // bytes maximum
plugin.setConfiguration(configuration)

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", Type: model.ChannelTypeOpen}, 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{}).Times(1)
mockLog.EXPECT().Error(mockTypeString, "Channel ID", mockTypeString, "Error", gomock.Any())

mockFile.EXPECT().Upload(gomock.Any(), "channel_name.csv", "direct").DoAndReturn(func(reader io.Reader, _, _ string) (*model.FileInfo, error) {
_, err := io.ReadAll(reader)
require.Error(t, err)
return nil, err
})

mockPost.EXPECT().CreatePost(&model.Post{
UserId: "bot_id",
ChannelId: "direct",
Message: fmt.Sprintf("An error occurred uploading the exported channel ~channel_name: unable to upload the exported file: limit (%d bytes) exceeded", maxFileSize),
})

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)

// Export runs asynchronuosly, so give time for that to occur and complete above
// mock assertions.
time.Sleep(1 * time.Second)
})

t.Run("no permissions", func(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
Expand Down
19 changes: 19 additions & 0 deletions server/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
// copy appropriate for your types.
type configuration struct {
EnableAdminRestrictions bool
MaxFileSize uint64
}

// Clone shallow copies the configuration. Your implementation may require a deep copy if
Expand Down Expand Up @@ -82,3 +83,21 @@ func (p *Plugin) OnConfigurationChange() error {

return nil
}

// getMaxFileSize returns the maximum file size allowed when using the slash command.
// If not set in plugin config, then the MM server FileUpload size is used instead.
func (p *Plugin) getMaxFileSize() uint64 {
maxSize := p.getConfiguration().MaxFileSize
if maxSize == 0 && p.API != nil {
mmconfig := p.client.Configuration.GetConfig()
if mmconfig.FileSettings.MaxFileSize != nil {
maxSize = uint64(*mmconfig.FileSettings.MaxFileSize)
}
}

if maxSize == 0 {
maxSize = 100 * 1024 * 1024 // 100MB (IEC)
}

return maxSize
}
12 changes: 10 additions & 2 deletions server/manifest.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions server/util/limitpipewriter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package util

import (
"fmt"
"io"
"sync"
)

type ErrLimitExceeded struct {
limit uint64
}

func (e ErrLimitExceeded) Error() string {
return fmt.Sprintf("limit (%d bytes) exceeded", e.limit)
}

// LimitPipeWriter wraps an io.PipeWriter and provides a limit to the number of bytes that can be written.
// Exceeding the limit will cause the current Write call to error, and the underlying PipeWriter will be
// closed, causing subsequent reads on the pipe to error.
type LimitPipeWriter struct {
pw *io.PipeWriter
limit uint64
count uint64
mux sync.Mutex // protects limit, count
}

// NewLimitPipeWriter creates a new LimitPipeWriter with the specified limit.
func NewLimitPipeWriter(pw *io.PipeWriter, limit uint64) *LimitPipeWriter {
return &LimitPipeWriter{
pw: pw,
limit: limit,
}
}

// Write implements io.Writer
func (lpw *LimitPipeWriter) Write(p []byte) (int, error) {
lpw.mux.Lock()
defer lpw.mux.Unlock()

count := uint64(len(p))
if lpw.count+count > lpw.limit {
err := ErrLimitExceeded{lpw.limit}
lpw.pw.CloseWithError(err) // ok to call multiple times
return 0, err
}

n, err := lpw.pw.Write(p)
lpw.count += uint64(n)
return n, err
}

// CloseWithError closes the writer. Future reads from the underlying pipe will return the specified error.
// It is safe to call this multiple times - subsequent calls to CloseWithError will be a no-op.
func (lpw *LimitPipeWriter) CloseWithError(err error) error {
return lpw.pw.CloseWithError(err)
}

// Close closes the writer; subsequent reads from the
// read half of the pipe will return no bytes and io.EOF.
func (lpw *LimitPipeWriter) Close() error {
return lpw.pw.Close()
}
Loading

0 comments on commit fc4f64b

Please sign in to comment.