feat: pluginapi: filewillbedownloaded / sendtoastmessage (#34596)

* feat: filewillbedonwloaded hook

* feat: error popup

* chore: make generated pluginapi

* tests

* feat: different errors for different download types

* feat: allow toast positions

* fix: avoid using deprecated i18n function

* feat: add plugin API to show toasts

* feat: downloadType parameter

* tests: updated tests

* chore: make check-style

* chore: i18n

* chore: missing fields in tests

* chore: sorted i18n for webapp

* chore: run mmjstool

* test: fixed webapp tests with new changes

* test: missing mocks

* fix: ensure one-file attachments (previews) are handler properly as thumbnails

* chore: lint

* test: added new logic to tests

* chore: lint

* Add SendToastMessage API and FileWillBeDownloaded hook

- Introduced SendToastMessage method for sending toast notifications to users with customizable options.
- Added FileWillBeDownloaded hook to handle file download requests, allowing plugins to control access to files.
- Updated related types and constants for file download handling.
- Enhanced PluginSettings to include HookTimeoutSeconds for better timeout management.

* Update webapp/channels/src/components/single_image_view/single_image_view.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: copilot reviews

* test: head requests

* chore: linted the webapp

* tests: fixed path

* test: fixed mocked args

* allow sending message to a connection directly

* fix: hook thread safety

* chore: formatting

* chore: remove configuration from system console

* chore: release version

* chore: update signature

* chore: update release version

* chore: addressed comments

* fix: update file rejection handling to use 403 Forbidden status and include rejection reason header

* Fix nil pointer panic in runFileWillBeDownloadedHook

The atomic.Value in runFileWillBeDownloadedHook can be nil if no
plugins implement the FileWillBeDownloaded hook. This causes a panic
when trying to assert the nil interface to string.

This fix adds a nil check before the type assertion, defaulting to
an empty string (which allows the download) when no hooks have run.

Fixes:
- TestUploadDataMultipart/success panic
- TestUploadDataMultipart/resume_success panic

* test: move the logout test last

* chore: restored accidential deletion

* chore: lint

* chore: make generated

* refactor: move websocket events to new package

* chore: go vet

* chore: missing mock

* chore: revert incorrect fmt

* chore: import ordering

* chore: npm i18n-extract

* chore: update constants.tsx from master

* chore: make i18n-extract

* revert: conflict merge

* fix: add missing isFileRejected prop to SingleImageView tests

* fix: mock fetch in SingleImageView tests for async thumbnail check

The component now performs an async fetch to check thumbnail availability
before rendering. Tests need to mock fetch and use waitFor to handle
the async state updates.

* refactor: move hook logic to app layer

* chore: update version to 11.5

* Scope file download rejection toast to the requesting connection

Thread the Connection-Id header through RunFileWillBeDownloadedHook and
sendFileDownloadRejectedEvent so the WebSocket event is sent only to the
connection that initiated the download, instead of all connections for
the user.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Felipe Martin 2026-02-16 17:10:39 +01:00 committed by GitHub
parent 37a9a30f40
commit 1be8a68dd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1664 additions and 68 deletions

View file

@ -32,10 +32,10 @@ const maxMultipartFormDataBytes = 10 * 1024 // 10Kb
func (api *API) InitFile() {
api.BaseRoutes.Files.Handle("", api.APISessionRequired(uploadFileStream, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet, http.MethodHead)
api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet, http.MethodHead)
api.BaseRoutes.File.Handle("/link", api.APISessionRequired(getFileLink)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet, http.MethodHead)
api.BaseRoutes.File.Handle("/info", api.APISessionRequired(getFileInfo)).Methods(http.MethodGet)
api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFilesInTeam)).Methods(http.MethodPost)
@ -583,7 +583,18 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
// Run plugin hook before file download
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, fileInfo, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypeFile)
if rejectionReason != "" {
w.Header().Set(model.HeaderRejectReason, rejectionReason)
c.Err = model.NewAppError("getFile", "api.file.get_file.rejected_by_plugin",
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
return
}
fileReader, err := c.App.FileReader(fileInfo.Path)
if err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
@ -630,6 +641,16 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// Run plugin hook before file thumbnail download
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypeThumbnail)
if rejectionReason != "" {
w.Header().Set(model.HeaderRejectReason, rejectionReason)
c.Err = model.NewAppError("getFileThumbnail", "api.file.get_file_thumbnail.rejected_by_plugin",
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
return
}
if info.ThumbnailPath == "" {
c.Err = model.NewAppError("getFileThumbnail", "api.file.get_file_thumbnail.no_thumbnail.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
return
@ -741,11 +762,22 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// Check if preview exists before running hook (no point in running hook if there's no preview)
if info.PreviewPath == "" {
c.Err = model.NewAppError("getFilePreview", "api.file.get_file_preview.no_preview.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
return
}
// Run plugin hook before file preview download
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, c.AppContext.Session().UserId, r.Header.Get(model.ConnectionId), model.FileDownloadTypePreview)
if rejectionReason != "" {
w.Header().Set(model.HeaderRejectReason, rejectionReason)
c.Err = model.NewAppError("getFilePreview", "api.file.get_file_preview.rejected_by_plugin",
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
return
}
fileReader, err := c.App.FileReader(info.PreviewPath)
if err != nil {
c.Err = err
@ -839,6 +871,17 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// Run plugin hook before public file download (no user session for public files)
rejectionReason := c.App.RunFileWillBeDownloadedHook(c.AppContext, info, "", r.Header.Get(model.ConnectionId), model.FileDownloadTypePublic)
if rejectionReason != "" {
w.Header().Set(model.HeaderRejectReason, rejectionReason)
c.Err = model.NewAppError("getPublicFile", "api.file.get_public_file.rejected_by_plugin",
map[string]any{"Reason": rejectionReason}, "", http.StatusForbidden)
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
return
}
fileReader, err := c.App.FileReader(info.Path)
if err != nil {
c.Err = err

View file

@ -1614,3 +1614,99 @@ func TestSearchFilesAcrossTeams(t *testing.T) {
require.Len(t, fileInfos.Order, 1, "wrong search")
require.Equal(t, fileInfos.FileInfos[fileInfos.Order[0]].ChannelId, channels[0].Id, "wrong search")
}
// TestHeadRequestsFileEndpoints tests that HEAD requests work correctly for file endpoints
func TestHeadRequestsFileEndpoints(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
client := th.Client
// Upload a test file
sent, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
fileResp, _, err := client.UploadFile(context.Background(), sent, th.BasicChannel.Id, "test.png")
require.NoError(t, err)
fileId := fileResp.FileInfos[0].Id
// Helper function to make HEAD requests
makeHeadRequest := func(url string) (*http.Response, error) {
req, err := http.NewRequest("HEAD", url, nil)
require.NoError(t, err)
req.Header.Set(model.HeaderAuth, client.AuthType+" "+client.AuthToken)
return client.HTTPClient.Do(req)
}
t.Run("HEAD request to file endpoint returns 200 OK", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, resp.Header.Get("Content-Type"))
})
t.Run("HEAD request to thumbnail endpoint returns 200 OK", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s/thumbnail", client.APIURL, fileId)
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("HEAD request to preview endpoint returns 200 OK", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s/preview", client.APIURL, fileId)
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("HEAD request returns no body", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Empty(t, body, "HEAD response should not contain a body")
})
t.Run("HEAD request requires authentication", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s", client.APIURL, fileId)
req, err := http.NewRequest("HEAD", url, nil)
require.NoError(t, err)
// Don't set auth header
resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("HEAD request with invalid file ID returns 400", func(t *testing.T) {
// Use an ID that matches the route pattern [A-Za-z0-9]+ but is invalid (not 26 characters)
url := fmt.Sprintf("%s/files/%s", client.APIURL, "invalidid")
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("HEAD request for non-existent file returns 404", func(t *testing.T) {
url := fmt.Sprintf("%s/files/%s", client.APIURL, model.NewId())
resp, err := makeHeadRequest(url)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}

View file

@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"maps"
@ -1755,3 +1756,68 @@ func (a *App) RemoveFileFromFileStore(rctx request.CTX, path string) {
return
}
}
// sendFileDownloadRejectedEvent sends a websocket event to notify the user that their file download was rejected.
// When connectionID is provided, the event is only sent to that specific connection.
func (a *App) sendFileDownloadRejectedEvent(info *model.FileInfo, userID string, connectionID string, rejectionReason string, downloadType model.FileDownloadType) {
if userID == "" {
a.Log().Debug("Skipping websocket event for public file download rejection")
return
}
message := model.NewWebSocketEvent(model.WebsocketEventFileDownloadRejected, "", info.ChannelId, userID, nil, "")
if connectionID != "" {
message.GetBroadcast().ConnectionId = connectionID
}
message.Add("file_id", info.Id)
message.Add("file_name", info.Name)
message.Add("rejection_reason", rejectionReason)
message.Add("channel_id", info.ChannelId)
message.Add("post_id", info.PostId)
message.Add("download_type", string(downloadType))
a.Publish(message)
}
// RunFileWillBeDownloadedHook executes the FileWillBeDownloaded hook with a timeout.
// Returns empty string to allow download, or a rejection reason to block it.
func (a *App) RunFileWillBeDownloadedHook(rctx request.CTX, fileInfo *model.FileInfo, userID string, connectionID string, downloadType model.FileDownloadType) string {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(model.PluginSettingsDefaultHookTimeoutSeconds)*time.Second)
defer cancel()
var rejectionReason atomic.Value
done := make(chan struct{})
pluginCtx := pluginContext(rctx)
a.Srv().Go(func() {
defer close(done)
a.ch.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
rejectionReasonFromHook := hooks.FileWillBeDownloaded(pluginCtx, fileInfo, userID, downloadType)
rejectionReason.Store(rejectionReasonFromHook)
a.Log().Debug("FileWillBeDownloaded hook called",
mlog.String("file_id", fileInfo.Id),
mlog.String("user_id", userID),
mlog.String("download_type", string(downloadType)),
mlog.String("rejection_reason", rejectionReasonFromHook))
return rejectionReasonFromHook == ""
}, plugin.FileWillBeDownloadedID)
})
select {
case <-done:
rejectionReasonString := ""
if loaded := rejectionReason.Load(); loaded != nil {
rejectionReasonString = loaded.(string)
}
if rejectionReasonString != "" {
a.sendFileDownloadRejectedEvent(fileInfo, userID, connectionID, rejectionReasonString, downloadType)
}
return rejectionReasonString
case <-ctx.Done():
timeoutMessage := rctx.T("api.file.get_file.plugin_hook_timeout")
a.Log().Warn("FileWillBeDownloaded hook timed out, blocking download",
mlog.String("file_id", fileInfo.Id),
mlog.String("user_id", userID))
a.sendFileDownloadRejectedEvent(fileInfo, userID, connectionID, timeoutMessage, downloadType)
return timeoutMessage
}
}

View file

@ -1097,6 +1097,10 @@ func (api *PluginAPI) PublishWebSocketEvent(event string, payload map[string]any
api.app.Publish(ev)
}
func (api *PluginAPI) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
return api.app.SendToastMessage(userID, connectionID, message, options)
}
func (api *PluginAPI) HasPermissionTo(userID string, permission *model.Permission) bool {
return api.app.HasPermissionTo(userID, permission)
}

View file

@ -0,0 +1,529 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
)
func TestHookFileWillBeDownloaded(t *testing.T) {
mainHelper.Parallel(t)
t.Run("rejected", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
// Allow any logging calls (not verified in this test)
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Rejecting file download", "file_id", info.Id, "user_id", userID)
return "Download blocked by security policy"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file first
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info to pass to the hook
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call the hook through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// Verify the file download was rejected
assert.NotEmpty(t, rejectionReason)
assert.Contains(t, rejectionReason, "blocked by security policy")
mockAPI.AssertExpectations(t)
})
t.Run("allowed", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
// Allow any logging calls (not verified in this test)
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Allowing file download", "file_id", info.Id, "user_id", userID)
// Return empty string to allow download
return ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call the hook through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// Verify the file download was allowed
assert.Empty(t, rejectionReason)
mockAPI.AssertExpectations(t)
})
t.Run("multiple plugins - first rejects", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI1 plugintest.API
mockAPI1.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
// Allow any logging calls (not verified in this test)
mockAPI1.On("LogInfo", mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogWarn", mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI1.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
var mockAPI2 plugintest.API
mockAPI2.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
// This plugin should NOT be called because first one rejects
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
// First plugin - rejects
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogWarn("First plugin rejecting", "file_id", info.Id)
return "Rejected by first plugin"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
// Second plugin - should not be called
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Second plugin should not be called")
return ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API {
// Alternate between the two mock APIs
return &mockAPI1
})
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call hooks - first one should reject, second should not be called
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
assert.NotEmpty(t, rejectionReason)
assert.Contains(t, rejectionReason, "Rejected by first plugin")
// Only first mock API should have been called
mockAPI1.AssertExpectations(t)
// mockAPI2 should not have been called (no expectations set)
})
t.Run("no plugins installed", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// No plugins - hook should return empty
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// No plugins means no rejection
assert.Empty(t, rejectionReason)
})
}
// TestHookFileWillBeDownloadedHeadRequests tests that HEAD requests trigger the FileWillBeDownloaded hook
func TestHookFileWillBeDownloadedHeadRequests(t *testing.T) {
mainHelper.Parallel(t)
t.Run("HEAD request to file endpoint triggers hook - rejection", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Blocking file download", "file_id", info.Id, "download_type", string(downloadType))
return "File download blocked by security policy"
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file first
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info to pass to the hook
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call the hook through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// Verify the file download was rejected
assert.NotEmpty(t, rejectionReason)
assert.Contains(t, rejectionReason, "blocked by security policy")
mockAPI.AssertExpectations(t)
})
t.Run("HEAD request to thumbnail endpoint triggers hook - rejection", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
// Only block thumbnail requests
if downloadType == model.FileDownloadTypeThumbnail {
p.API.LogInfo("Blocking thumbnail download", "file_id", info.Id)
return "Thumbnail download blocked"
}
return ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call the hook for thumbnail download type through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeThumbnail)
// Verify the thumbnail download was rejected
assert.NotEmpty(t, rejectionReason)
assert.Contains(t, rejectionReason, "Thumbnail download blocked")
mockAPI.AssertExpectations(t)
})
t.Run("HEAD request to preview endpoint triggers hook - rejection", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
// Only block preview requests
if downloadType == model.FileDownloadTypePreview {
p.API.LogInfo("Blocking preview download", "file_id", info.Id)
return "Preview download blocked"
}
return ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Create plugin context
// Call the hook for preview download type through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypePreview)
// Verify the preview download was rejected
assert.NotEmpty(t, rejectionReason)
assert.Contains(t, rejectionReason, "Preview download blocked")
mockAPI.AssertExpectations(t)
})
t.Run("HEAD request to file endpoint triggers hook - allowed", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Allowing file download", "file_id", info.Id, "download_type", string(downloadType))
return "" // Allow download
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
require.NotNil(t, fileInfo)
// Get the file info
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Call the hook through the app method
rejectionReason := th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// Verify the file download was allowed
assert.Empty(t, rejectionReason)
mockAPI.AssertExpectations(t)
})
t.Run("HEAD and GET with different download types", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
downloadTypesReceived := []model.FileDownloadType{}
var mockAPI plugintest.API
mockAPI.On("LoadPluginConfiguration", mock.Anything).Return(nil).Maybe()
mockAPI.On("LogInfo", mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
if args.String(0) == "Hook called" {
downloadType := args.String(2)
downloadTypesReceived = append(downloadTypesReceived, model.FileDownloadType(downloadType))
}
}).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
mockAPI.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Maybe().Return(nil)
tearDown, _, _ := SetAppEnvironmentWithPlugins(t, []string{
`
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/model"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) FileWillBeDownloaded(c *plugin.Context, info *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
p.API.LogInfo("Hook called", "download_type", string(downloadType))
return "" // Allow download
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`,
}, th.App, func(*model.Manifest) plugin.API { return &mockAPI })
defer tearDown()
// Upload a file
fileInfo, appErr := th.App.UploadFile(th.Context, []byte("test content"), th.BasicChannel.Id, "test.txt")
require.Nil(t, appErr)
info, appErr := th.App.GetFileInfo(th.Context, fileInfo.Id)
require.Nil(t, appErr)
// Test File download type
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeFile)
// Test Thumbnail download type
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypeThumbnail)
// Test Preview download type
th.App.RunFileWillBeDownloadedHook(th.Context, info, th.BasicUser.Id, "", model.FileDownloadTypePreview)
// Verify all three download types were received
assert.Len(t, downloadTypesReceived, 3)
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypeFile)
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypeThumbnail)
assert.Contains(t, downloadTypesReceived, model.FileDownloadTypePreview)
mockAPI.AssertExpectations(t)
})
}

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
// SendToastMessage sends a toast notification to a specific user or user session via WebSocket.
func (a *App) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
if userID == "" {
return model.NewAppError("SendToastMessage", "app.toast.send_toast_message.user_id.app_error", nil, "", http.StatusBadRequest)
}
if message == "" {
return model.NewAppError("SendToastMessage", "app.toast.send_toast_message.message.app_error", nil, "", http.StatusBadRequest)
}
payload := map[string]any{
"message": message,
"position": options.Position,
}
broadcast := &model.WebsocketBroadcast{
UserId: userID,
ConnectionId: connectionID,
}
event := model.NewWebSocketEvent(model.WebsocketEventShowToast, "", "", userID, nil, "")
event = event.SetBroadcast(broadcast).SetData(payload)
a.Publish(event)
return nil
}

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSendToastMessage(t *testing.T) {
th := Setup(t).InitBasic(t)
t.Run("should return error when userID is empty", func(t *testing.T) {
options := model.SendToastMessageOptions{
Position: "bottom-right",
}
err := th.App.SendToastMessage("", "test-connection-id", "Test message", options)
require.NotNil(t, err)
assert.Equal(t, "app.toast.send_toast_message.user_id.app_error", err.Id)
})
t.Run("should return error when message is empty", func(t *testing.T) {
options := model.SendToastMessageOptions{
Position: "bottom-right",
}
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "", options)
require.NotNil(t, err)
assert.Equal(t, "app.toast.send_toast_message.message.app_error", err.Id)
})
t.Run("should successfully send toast to all user sessions", func(t *testing.T) {
options := model.SendToastMessageOptions{
Position: "top-center",
}
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
require.Nil(t, err)
})
t.Run("should successfully send toast to specific connection", func(t *testing.T) {
options := model.SendToastMessageOptions{
Position: "bottom-left",
}
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
require.Nil(t, err)
})
t.Run("should successfully send toast without position (default should be used)", func(t *testing.T) {
options := model.SendToastMessageOptions{}
err := th.App.SendToastMessage(th.BasicUser.Id, "test-connection-id", "Test toast message", options)
require.Nil(t, err)
})
}

View file

@ -72,6 +72,7 @@ func genStore() *mocks.Store {
mock.On("ReadReceipt").Return(&mocks.ReadReceiptStore{})
mock.On("Recap").Return(&mocks.RecapStore{})
mock.On("TemporaryPost").Return(&mocks.TemporaryPostStore{})
mock.On("Recap").Return(&mocks.RecapStore{})
return mock
}

View file

@ -2196,10 +2196,18 @@
"id": "api.file.get_file.invalid_flagged_post.app_error",
"translation": "Mismatched flagged post ID specified."
},
{
"id": "api.file.get_file.plugin_hook_timeout",
"translation": "Plugin hook timed out"
},
{
"id": "api.file.get_file.public_invalid.app_error",
"translation": "The public link does not appear to be valid."
},
{
"id": "api.file.get_file.rejected_by_plugin",
"translation": "File download rejected by plugin"
},
{
"id": "api.file.get_file_info.app_error",
"translation": "Failed to get file info."
@ -2208,10 +2216,22 @@
"id": "api.file.get_file_preview.no_preview.app_error",
"translation": "File doesn't have a preview image."
},
{
"id": "api.file.get_file_preview.rejected_by_plugin",
"translation": "File preview download rejected by plugin"
},
{
"id": "api.file.get_file_thumbnail.no_thumbnail.app_error",
"translation": "File doesn't have a thumbnail image."
},
{
"id": "api.file.get_file_thumbnail.rejected_by_plugin",
"translation": "File thumbnail download rejected by plugin"
},
{
"id": "api.file.get_public_file.rejected_by_plugin",
"translation": "Public file download rejected by plugin"
},
{
"id": "api.file.get_public_link.disabled.app_error",
"translation": "Public links have been disabled."
@ -8052,6 +8072,14 @@
"id": "app.thread.mark_all_as_read_by_channels.app_error",
"translation": "Unable to mark all threads as read by channel"
},
{
"id": "app.toast.send_toast_message.message.app_error",
"translation": "Message is required"
},
{
"id": "app.toast.send_toast_message.user_id.app_error",
"translation": "User ID is required"
},
{
"id": "app.update_error",
"translation": "update error"

View file

@ -41,6 +41,7 @@ const (
HeaderFirstInaccessiblePostTime = "First-Inaccessible-Post-Time"
HeaderFirstInaccessibleFileTime = "First-Inaccessible-File-Time"
HeaderRange = "Range"
HeaderRejectReason = "X-Reject-Reason"
STATUS = "status"
StatusOk = "OK"
StatusFail = "FAIL"

View file

@ -236,11 +236,12 @@ const (
OutgoingIntegrationRequestsDefaultTimeout = 30
PluginSettingsDefaultDirectory = "./plugins"
PluginSettingsDefaultClientDirectory = "./client/plugins"
PluginSettingsDefaultEnableMarketplace = true
PluginSettingsDefaultMarketplaceURL = "https://api.integrations.mattermost.com"
PluginSettingsOldMarketplaceURL = "https://marketplace.integrations.mattermost.com"
PluginSettingsDefaultDirectory = "./plugins"
PluginSettingsDefaultClientDirectory = "./client/plugins"
PluginSettingsDefaultEnableMarketplace = true
PluginSettingsDefaultMarketplaceURL = "https://api.integrations.mattermost.com"
PluginSettingsOldMarketplaceURL = "https://marketplace.integrations.mattermost.com"
PluginSettingsDefaultHookTimeoutSeconds = 30
ComplianceExportDirectoryFormat = "compliance-export-2006-01-02-15h04m"
ComplianceExportPath = "export"

View file

@ -15,6 +15,20 @@ const (
FileinfoSortBySize = "Size"
)
// FileDownloadType represents the type of file download or access being performed.
type FileDownloadType string
const (
// FileDownloadTypeFile represents a full file download request.
FileDownloadTypeFile FileDownloadType = "file"
// FileDownloadTypeThumbnail represents a thumbnail image request.
FileDownloadTypeThumbnail FileDownloadType = "thumbnail"
// FileDownloadTypePreview represents a preview image request.
FileDownloadTypePreview FileDownloadType = "preview"
// FileDownloadTypePublic represents a public link access (unauthenticated).
FileDownloadTypePublic FileDownloadType = "public"
)
// GetFileInfosOptions contains options for getting FileInfos
type GetFileInfosOptions struct {
// UserIds optionally limits the FileInfos to those created by the given users.

View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
// SendToastMessageOptions contains options for sending a toast message to a user.
type SendToastMessageOptions struct {
// Position is the position where the toast should appear.
// Valid values: "top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"
// If empty or invalid, defaults to "bottom-right" on the frontend.
Position string `json:"position,omitempty"`
}

View file

@ -102,6 +102,8 @@ const (
WebsocketEventPostRevealed WebsocketEventType = "post_revealed"
WebsocketEventPostBurned WebsocketEventType = "post_burned"
WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed"
WebsocketEventFileDownloadRejected WebsocketEventType = "file_download_rejected"
WebsocketEventShowToast WebsocketEventType = "show_toast"
WebSocketMsgTypeResponse = "response"
WebSocketMsgTypeEvent = "event"

View file

@ -860,6 +860,14 @@ type API interface {
// Minimum server version: 5.6
OpenInteractiveDialog(dialog model.OpenDialogRequest) *model.AppError
// SendToastMessage sends a toast notification to a specific user or user session.
// The userID parameter specifies the user to send the toast to.
// If connectionID is set, the toast will only be sent to that specific connection.
//
// @tag Frontend
// Minimum server version: 11.5
SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError
// Plugin Section
// GetPlugins will return a list of plugin manifests for currently active plugins.

View file

@ -930,6 +930,13 @@ func (api *apiTimerLayer) OpenInteractiveDialog(dialog model.OpenDialogRequest)
return _returnsA
}
func (api *apiTimerLayer) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
startTime := timePkg.Now()
_returnsA := api.apiImpl.SendToastMessage(userID, connectionID, message, options)
api.recordTime(startTime, "SendToastMessage", _returnsA == nil)
return _returnsA
}
func (api *apiTimerLayer) GetPlugins() ([]*model.Manifest, *model.AppError) {
startTime := timePkg.Now()
_returnsA, _returnsB := api.apiImpl.GetPlugins()

View file

@ -499,6 +499,43 @@ func (s *hooksRPCServer) UserHasLeftTeam(args *Z_UserHasLeftTeamArgs, returns *Z
return nil
}
func init() {
hookNameToId["FileWillBeDownloaded"] = FileWillBeDownloadedID
}
type Z_FileWillBeDownloadedArgs struct {
A *Context
B *model.FileInfo
C string
D model.FileDownloadType
}
type Z_FileWillBeDownloadedReturns struct {
A string
}
func (g *hooksRPCClient) FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
_args := &Z_FileWillBeDownloadedArgs{c, fileInfo, userID, downloadType}
_returns := &Z_FileWillBeDownloadedReturns{}
if g.implemented[FileWillBeDownloadedID] {
if err := g.client.Call("Plugin.FileWillBeDownloaded", _args, _returns); err != nil {
g.log.Error("RPC call FileWillBeDownloaded to plugin failed.", mlog.Err(err))
}
}
return _returns.A
}
func (s *hooksRPCServer) FileWillBeDownloaded(args *Z_FileWillBeDownloadedArgs, returns *Z_FileWillBeDownloadedReturns) error {
if hook, ok := s.impl.(interface {
FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string
}); ok {
returns.A = hook.FileWillBeDownloaded(args.A, args.B, args.C, args.D)
} else {
return encodableError(fmt.Errorf("Hook FileWillBeDownloaded called but not implemented."))
}
return nil
}
func init() {
hookNameToId["ReactionHasBeenAdded"] = ReactionHasBeenAddedID
}
@ -4991,6 +5028,37 @@ func (s *apiRPCServer) OpenInteractiveDialog(args *Z_OpenInteractiveDialogArgs,
return nil
}
type Z_SendToastMessageArgs struct {
A string
B string
C string
D model.SendToastMessageOptions
}
type Z_SendToastMessageReturns struct {
A *model.AppError
}
func (g *apiRPCClient) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError {
_args := &Z_SendToastMessageArgs{userID, connectionID, message, options}
_returns := &Z_SendToastMessageReturns{}
if err := g.client.Call("Plugin.SendToastMessage", _args, _returns); err != nil {
log.Printf("RPC call to SendToastMessage API failed: %s", err.Error())
}
return _returns.A
}
func (s *apiRPCServer) SendToastMessage(args *Z_SendToastMessageArgs, returns *Z_SendToastMessageReturns) error {
if hook, ok := s.impl.(interface {
SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) *model.AppError
}); ok {
returns.A = hook.SendToastMessage(args.A, args.B, args.C, args.D)
} else {
return encodableError(fmt.Errorf("API SendToastMessage called but not implemented."))
}
return nil
}
type Z_GetPluginsArgs struct {
}

View file

@ -64,6 +64,7 @@ const (
GenerateSupportDataID = 45
OnSAMLLoginID = 46
EmailNotificationWillBeSentID = 47
FileWillBeDownloadedID = 48
TotalHooksID = iota
)
@ -239,6 +240,20 @@ type Hooks interface {
// Minimum server version: 5.2
FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string)
// FileWillBeDownloaded is invoked when a file is requested for download, but before it is sent to the client.
//
// To reject a file download, return an non-empty string describing why the file was rejected.
// To allow the download, return an empty string.
//
// The downloadType parameter indicates the type of file access and can be one of:
// - model.FileDownloadTypeFile: Full file download
// - model.FileDownloadTypeThumbnail: Thumbnail request
// - model.FileDownloadTypePreview: Preview image request
// - model.FileDownloadTypePublic: Public link access (userID will be empty string in this case)
//
// Minimum server version: 11.5
FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string
// ReactionHasBeenAdded is invoked after the reaction has been committed to the database.
//
// Note that this method will be called for reactions added by plugins, including the plugin that

View file

@ -164,6 +164,13 @@ func (hooks *hooksTimerLayer) FileWillBeUploaded(c *Context, info *model.FileInf
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.FileWillBeDownloaded(c, fileInfo, userID, downloadType)
hooks.recordTime(startTime, "FileWillBeDownloaded", true)
return _returnsA
}
func (hooks *hooksTimerLayer) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) {
startTime := timePkg.Now()
hooks.hooksImpl.ReactionHasBeenAdded(c, reaction)

View file

@ -5203,6 +5203,26 @@ func (_m *API) SendPushNotification(notification *model.PushNotification, userID
return r0
}
// SendToastMessage provides a mock function with given fields: userID, connectionID, message, options
func (_m *API) SendToastMessage(userID string, connectionID string, message string, options model.SendToastMessageOptions) *model.AppError {
ret := _m.Called(userID, connectionID, message, options)
if len(ret) == 0 {
panic("no return value specified for SendToastMessage")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(string, string, string, model.SendToastMessageOptions) *model.AppError); ok {
r0 = rf(userID, connectionID, message, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// SetFileSearchableContent provides a mock function with given fields: fileID, content
func (_m *API) SetFileSearchableContent(fileID string, content string) *model.AppError {
ret := _m.Called(fileID, content)

View file

@ -119,6 +119,24 @@ func (_m *Hooks) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo
return r0, r1
}
// FileWillBeDownloaded provides a mock function with given fields: c, fileInfo, userID, downloadType
func (_m *Hooks) FileWillBeDownloaded(c *plugin.Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
ret := _m.Called(c, fileInfo, userID, downloadType)
if len(ret) == 0 {
panic("no return value specified for FileWillBeDownloaded")
}
var r0 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.FileInfo, string, model.FileDownloadType) string); ok {
r0 = rf(c, fileInfo, userID, downloadType)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// FileWillBeUploaded provides a mock function with given fields: c, info, file, output
func (_m *Hooks) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
ret := _m.Called(c, info, file, output)

View file

@ -28,3 +28,12 @@ func (f *FrontendService) OpenInteractiveDialog(dialog model.OpenDialogRequest)
func (f *FrontendService) PublishWebSocketEvent(event string, payload map[string]any, broadcast *model.WebsocketBroadcast) {
f.api.PublishWebSocketEvent(event, payload, broadcast)
}
// SendToastMessage sends a toast notification to a specific user or user session.
// The userID parameter specifies the user to send the toast to.
// If connectionID is set, the toast will only be sent to that specific connection.
//
// Minimum server version: 11.5
func (f *FrontendService) SendToastMessage(userID, connectionID, message string, options model.SendToastMessageOptions) error {
return normalizeAppErr(f.api.SendToastMessage(userID, connectionID, message, options))
}

View file

@ -3,14 +3,17 @@
/* eslint-disable max-lines */
import React from 'react';
import {batchActions} from 'redux-batched-actions';
import type {WebSocketMessage, WebSocketMessages} from '@mattermost/client';
import {WebSocketEvents} from '@mattermost/client';
import {AlertCircleOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
import type {ChannelBookmarkWithFileInfo, UpdateChannelBookmarkResponse} from '@mattermost/types/channel_bookmarks';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {Draft} from '@mattermost/types/drafts';
import type {Emoji} from '@mattermost/types/emojis';
import {FileDownloadTypes} from '@mattermost/types/files';
import type {Group, GroupMember} from '@mattermost/types/groups';
import type {OpenDialogRequest} from '@mattermost/types/integrations';
import type {Post, PostAcknowledgement} from '@mattermost/types/posts';
@ -25,6 +28,7 @@ import type {MMReduxAction} from 'mattermost-redux/action_types';
import {
ChannelTypes,
EmojiTypes,
FileTypes,
GroupTypes,
PostTypes,
TeamTypes,
@ -134,7 +138,7 @@ import {setGlobalItem} from 'actions/storage';
import {loadProfilesForDM, loadProfilesForGM, loadProfilesForSidebar} from 'actions/user_actions';
import {syncPostsInChannel} from 'actions/views/channel';
import {setGlobalDraft, transformServerDraft} from 'actions/views/drafts';
import {openModal} from 'actions/views/modals';
import {openModal, closeModal} from 'actions/views/modals';
import {closeRightHandSide} from 'actions/views/rhs';
import {resetWsErrorCount} from 'actions/views/system';
import {updateThreadLastOpened} from 'actions/views/threads';
@ -143,12 +147,14 @@ import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads';
import store from 'stores/redux_store';
import DialogRouter from 'components/dialog_router';
import InfoToast from 'components/info_toast/info_toast';
import RemovedFromChannelModal from 'components/removed_from_channel_modal';
import WebSocketClient from 'client/web_websocket_client';
import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins';
import {getHistory} from 'utils/browser_history';
import {ActionTypes, Constants, AnnouncementBarMessages, SocketEvents, UserStatuses, ModalIdentifiers, PageLoadContext} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import {getSiteURL} from 'utils/url';
import type {ActionFunc, ThunkActionFunc} from 'types/store';
@ -682,6 +688,12 @@ export function handleEvent(msg: WebSocketMessage) {
case WebSocketEvents.RecapUpdated:
dispatch(handleRecapUpdated(msg));
break;
case WebSocketEvents.FileDownloadRejected:
dispatch(handleFileDownloadRejected(msg));
break;
case WebSocketEvents.ShowToast:
dispatch(handleShowToast(msg));
break;
default:
}
@ -2046,3 +2058,104 @@ export function handleRecapUpdated(msg: WebSocketMessages.RecapUpdated): ThunkAc
doDispatch(getRecap(recapId));
};
}
export function handleFileDownloadRejected(msg: WebSocketMessages.FileDownloadRejected): ThunkActionFunc<void> {
return (dispatch, getState) => {
const {file_id: fileId, file_name: fileName, rejection_reason: rejectionReason, channel_id: channelId, post_id: postId, download_type: downloadType} = msg.data;
// Store the rejected file ID in Redux state
dispatch({
type: FileTypes.FILE_DOWNLOAD_REJECTED,
data: {
file_id: fileId,
file_name: fileName,
rejection_reason: rejectionReason,
channel_id: channelId,
post_id: postId,
download_type: downloadType,
},
});
// Handle different download types appropriately:
// - Thumbnail: Small preview in message list, loaded automatically, no modal, no toast
// - Preview: Can be either:
// a) Large image in channel list (SingleImageView) - loaded automatically, no modal, no toast
// b) Full-screen modal view - user clicked, modal open, close it WITH toast
// - File: User clicked download button, close modal WITH toast
// - Public: User requested public link, close modal WITH toast
if (downloadType === FileDownloadTypes.THUMBNAIL) {
// Thumbnails are loaded automatically in the background
// No modal to close, no toast to show
return;
}
if (downloadType === FileDownloadTypes.PREVIEW) {
// Check if the file preview modal is actually open
const state = getState();
const isModalOpen = state.views?.modals?.modalState?.[ModalIdentifiers.FILE_PREVIEW_MODAL]?.open;
if (!isModalOpen) {
// Preview was loaded in channel list (SingleImageView), not in modal
// This is an automatic background load, no toast needed
return;
}
// Modal is open, so user clicked to view the preview
// Close the modal and show toast to explain why
// Continue to close modal and show toast below
}
// Close the file preview modal for preview (when open), file, and public rejections
dispatch(closeModal(ModalIdentifiers.FILE_PREVIEW_MODAL));
// Show a toast notification to explain why the modal was closed
// Use normalized message format for all file types
const intl = getIntl();
const displayMessage = intl.formatMessage(
{id: 'file_download.rejected.file', defaultMessage: 'File access blocked: {reason}'},
{reason: rejectionReason},
);
// Show toast notification using the existing InfoToast system
dispatch(openModal({
modalId: ModalIdentifiers.INFO_TOAST,
dialogType: InfoToast,
dialogProps: {
content: {
icon: React.createElement(AlertCircleOutlineIcon, {size: 18}),
message: displayMessage,
},
position: 'bottom-center',
onExited: () => {
// Close the modal when the toast is dismissed
dispatch(closeModal(ModalIdentifiers.INFO_TOAST));
},
},
}));
};
}
function handleShowToast(msg: WebSocketMessages.ShowToast): ThunkActionFunc<void> {
return (doDispatch) => {
const {message, position} = msg.data;
if (message) {
const toastPosition = position as 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' | undefined;
doDispatch(openModal({
modalId: ModalIdentifiers.INFO_TOAST,
dialogType: InfoToast,
dialogProps: {
content: {
message,
icon: React.createElement(InformationOutlineIcon, {size: 18}),
},
position: toastPosition || 'bottom-right',
onExited: () => {
doDispatch(closeModal(ModalIdentifiers.INFO_TOAST));
},
},
}));
}
};
}

View file

@ -3,7 +3,7 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useDispatch, useSelector} from 'react-redux';
import {
DotsHorizontalIcon,
@ -13,9 +13,11 @@ import {
ArrowExpandIcon,
OpenInNewIcon,
BookOutlineIcon,
DownloadOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {ChannelBookmark, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import {getFile} from 'mattermost-redux/selectors/entities/files';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils';
@ -29,6 +31,8 @@ import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
import {copyToClipboard} from 'utils/utils';
import type {GlobalState} from 'types/store';
import BookmarkDeleteModal from './bookmark_delete_modal';
import ChannelBookmarksCreateModal from './channel_bookmarks_create_modal';
import {useCanGetPublicLink, useChannelBookmarkPermission} from './utils';
@ -40,6 +44,7 @@ const BookmarkItemDotMenu = ({
}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const fileInfo = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined);
const siteURL = getSiteURL();
const openInNewTab = bookmark.type === 'link' && bookmark.link_url && shouldOpenInNewTab(bookmark.link_url, siteURL);
@ -59,6 +64,7 @@ const BookmarkItemDotMenu = ({
const openLabel = formatMessage({id: 'channel_bookmarks.open', defaultMessage: 'Open'});
const copyLinkLabel = formatMessage({id: 'channel_bookmarks.copy', defaultMessage: 'Copy link'});
const copyFileLabel = formatMessage({id: 'channel_bookmarks.copyFilePublicLink', defaultMessage: 'Get a public link'});
const downloadLabel = formatMessage({id: 'channel_bookmarks.download', defaultMessage: 'Download'});
const deleteLabel = formatMessage({id: 'channel_bookmarks.delete', defaultMessage: 'Delete'});
const handleEdit = useCallback(() => {
@ -106,6 +112,12 @@ const BookmarkItemDotMenu = ({
}));
}, [bookmark.file_id, dispatch]);
const handleDownload = useCallback(() => {
if (fileInfo) {
window.open(getFileDownloadUrl(fileInfo.id), '_blank');
}
}, [fileInfo]);
return (
<Menu.Container
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
@ -158,6 +170,16 @@ const BookmarkItemDotMenu = ({
aria-label={copyFileLabel}
/>
)}
{bookmark.type === 'file' && fileInfo && (
<Menu.Item
key='channelBookmarksDownload'
id='channelBookmarksDownload'
onClick={handleDownload}
leadingElement={<DownloadOutlineIcon size={18}/>}
labels={<span>{downloadLabel}</span>}
aria-label={downloadLabel}
/>
)}
{canDelete && (
<Menu.Item
key='channelBookmarksDelete'

View file

@ -35,6 +35,31 @@ const CodePreview = ({
});
const [status, setStatus] = useState<'success' | 'loading' | 'fail'>('loading');
const [prevFileUrl, setPrevFileUrl] = useState<string | undefined>();
useEffect(() => {
if (fileUrl !== prevFileUrl) {
const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension);
if (!usedLanguage || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
setCodeInfo((prevCodeInfo) => {
return {...prevCodeInfo, code: '', lang: ''};
});
setStatus('fail');
} else {
setCodeInfo((prevCodeInfo) => {
return {...prevCodeInfo, code: '', lang: usedLanguage};
});
setStatus('loading');
}
setPrevFileUrl(fileUrl);
}
}, [fileInfo.extension, fileInfo.size, fileUrl, prevFileUrl]);
const shouldNotGetCode = !codeInfo.lang || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE;
useEffect(() => {
const usedLanguage = SyntaxHighlighting.getLanguageFromFileExtension(fileInfo.extension);
@ -42,46 +67,54 @@ const CodePreview = ({
if (!usedLanguage || fileInfo.size > Constants.CODE_PREVIEW_MAX_FILE_SIZE) {
setCodeInfo({code: '', lang: '', highlighted: ''});
setStatus('fail');
return;
}
setCodeInfo({code: '', lang: usedLanguage, highlighted: ''});
setStatus('loading');
const handleReceivedCode = async (data: string | Node) => {
let code = data as string;
const dataAsNode = data as Node;
const fetchCode = async () => {
if (dataAsNode.nodeName === '#document') {
code = new XMLSerializer().serializeToString(dataAsNode);
}
getContent?.(code);
setCodeInfo({
...codeInfo,
code,
highlighted: await SyntaxHighlighting.highlight(codeInfo.lang, code),
});
setStatus('success');
};
const handleReceivedError = () => {
setStatus('fail');
};
const getCode = async () => {
if (shouldNotGetCode) {
return;
}
try {
const response = await fetch(fileUrl);
let code = await response.text();
if (response.headers.get('content-type')?.includes('xml')) {
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(code, 'text/xml');
if (xmlDoc.nodeName === '#document') {
code = new XMLSerializer().serializeToString(xmlDoc);
}
} catch {
// If XML parsing fails, use the text as-is
}
const data = await fetch(fileUrl);
if (!data.ok) {
// Handle HTTP error responses (including 403 Forbidden from plugin rejection)
handleReceivedError();
return;
}
getContent?.(code);
const highlighted = await SyntaxHighlighting.highlight(usedLanguage, code);
setCodeInfo({
code,
lang: usedLanguage,
highlighted,
});
setStatus('success');
const text = await data.text();
handleReceivedCode(text);
} catch (e) {
setStatus('fail');
handleReceivedError();
}
};
fetchCode();
}, [fileUrl, fileInfo.extension, fileInfo.size, getContent]);
// Only fetch if status is loading and we have a language
if (status === 'loading' && codeInfo.lang && !shouldNotGetCode) {
getCode();
}
}, [codeInfo, fileUrl, prevFileUrl, getContent, shouldNotGetCode, status]);
if (status === 'loading') {
return (

View file

@ -60,6 +60,7 @@ describe('FileAttachment', () => {
enablePublicLink: false,
pluginMenuItems: [],
currentChannel: TestHelper.getChannelMock(),
isFileRejected: false,
handleFileDropdownOpened: jest.fn(() => null),
actions: {
openModal: jest.fn(),

View file

@ -86,6 +86,14 @@ export default function FileAttachment(props: Props) {
// So skip trying to load.
return;
}
// If file is rejected, don't try to load thumbnail - just mark as loaded
// so it shows the file icon instead
if (props.isFileRejected) {
setLoaded(true);
return;
}
const fileType = getFileType(fileInfo.extension);
if (!props.disableThumbnail) {
@ -124,6 +132,13 @@ export default function FileAttachment(props: Props) {
}
}, [props.fileInfo.extension, props.fileInfo.id, props.enableSVGs]);
// If file becomes rejected, mark as loaded so it shows the file icon
useEffect(() => {
if (props.isFileRejected) {
setLoaded(true);
}
}, [props.isFileRejected]);
const onAttachmentClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();

View file

@ -23,12 +23,14 @@ type Props = {
enableSVGs: boolean;
fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited;
disablePreview?: boolean;
isRejected?: boolean;
};
const FileThumbnail = ({
fileInfo,
enableSVGs,
disablePreview,
isRejected,
}: Props) => {
const {id, extension, has_preview_image: hasPreviewImage, width = 0, height = 0} = (fileInfo as FileInfo);
const mimeType = (fileInfo as FileInfo).mime_type || (fileInfo as FilePreviewInfo | FilePreviewInfoLimited).type;
@ -40,7 +42,8 @@ const FileThumbnail = ({
type = getFileTypeFromMime(mimeType);
}
if (id && !disablePreview) {
// If the file is rejected, always show the file icon instead of thumbnail
if (id && !disablePreview && !isRejected) {
if (type === FileTypes.IMAGE) {
let className = 'post-image';

View file

@ -3,15 +3,24 @@
import {connect} from 'react-redux';
import type {FileInfo} from '@mattermost/types/files';
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import type {GlobalState} from 'types/store';
import FileThumbnail from './file_thumbnail';
function mapStateToProps(state: GlobalState) {
type OwnProps = {
fileInfo: FileInfo | {id?: string};
};
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const fileId = (ownProps.fileInfo as FileInfo)?.id;
return {
enableSVGs: getConfig(state).EnableSVGs === 'true',
isRejected: fileId ? isFileRejected(state, fileId) : false,
};
}

View file

@ -6,7 +6,10 @@ import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {FileInfo} from '@mattermost/types/files';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {openModal} from 'actions/views/modals';
@ -20,6 +23,7 @@ import FileAttachment from './file_attachment';
export type OwnProps = {
preventDownload?: boolean;
fileInfo: FileInfo;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
@ -31,6 +35,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
enablePublicLink: config.EnablePublicLink === 'true',
pluginMenuItems: getFilesDropdownPluginMenuItems(state),
currentChannel: getCurrentChannel(state),
isFileRejected: isFileRejected(state, ownProps.fileInfo.id),
};
}

View file

@ -44,7 +44,9 @@ export default function FileAttachmentList(props: Props) {
return null;
}
if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived) {
// For single image files, use SingleImageView UNLESS the file is rejected
// If rejected, we want to show the file attachment card instead
if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived && !props.firstFileRejected) {
const fileType = getFileType(fileInfos[0].extension);
if (fileType === FileTypes.IMAGE || (fileType === FileTypes.SVG && enableSVGs)) {

View file

@ -13,6 +13,7 @@ import {PostTypes} from 'mattermost-redux/constants/posts';
import {
makeGetFilesForEditHistory,
makeGetFilesForPost,
isFileRejected,
} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@ -66,12 +67,16 @@ function makeMapStateToProps() {
fileCount = ownProps.post.filenames.length;
}
// Check if the first file is rejected (for single file view logic)
const firstFileRejected = fileInfos.length > 0 ? isFileRejected(state, fileInfos[0].id) : false;
return {
enableSVGs: getConfig(state).EnableSVGs === 'true',
fileInfos,
fileCount,
isEmbedVisible: isEmbedVisible(state, ownProps.post.id),
locale: getCurrentLocale(state),
firstFileRejected,
};
};
}

View file

@ -219,12 +219,13 @@ export default class FilePreviewModal extends React.PureComponent<Props, State>
handleImageLoaded = (index: number) => {
this.setState((prevState) => {
return {
const newState = {
loaded: {
...prevState.loaded,
[index]: true,
},
};
return newState;
});
};

View file

@ -3,7 +3,7 @@
exports[`components/InfoToast should match snapshot 1`] = `
<div>
<div
class="info-toast className toast-appear toast-appear-active"
class="info-toast info-toast--bottom-right className toast-appear toast-appear-active"
>
<svg
fill="currentColor"

View file

@ -1,8 +1,6 @@
.info-toast {
position: fixed;
z-index: 99;
right: 16px;
bottom: 16px;
display: grid;
width: fit-content;
min-height: 40px;
@ -34,6 +32,87 @@
font-size: 16px;
}
}
// Position variants
&--top-left {
top: 16px;
left: 16px;
&.toast-appear {
transform: translateY(-150px);
}
&.toast-appear-active {
transform: translateY(0);
}
}
&--top-center {
top: 16px;
left: 50%;
transform: translateX(-50%);
&.toast-appear {
transform: translate(-50%, -150px);
}
&.toast-appear-active {
transform: translate(-50%, 0);
}
}
&--top-right {
top: 16px;
right: 16px;
&.toast-appear {
transform: translateY(-150px);
}
&.toast-appear-active {
transform: translateY(0);
}
}
&--bottom-left {
bottom: 16px;
left: 16px;
&.toast-appear {
transform: translateY(150px);
}
&.toast-appear-active {
transform: translateY(0);
}
}
&--bottom-center {
bottom: 16px;
left: 50%;
transform: translateX(-50%);
&.toast-appear {
transform: translate(-50%, 150px);
}
&.toast-appear-active {
transform: translate(-50%, 0);
}
}
&--bottom-right {
right: 16px;
bottom: 16px;
&.toast-appear {
transform: translateY(150px);
}
&.toast-appear-active {
transform: translateY(0);
}
}
}
.info-toast__undo {
@ -55,11 +134,9 @@
// Animations
.toast-appear {
opacity: 0;
transform: translateY(150px);
}
.toast-appear-active {
opacity: 1;
transform: translateY(0);
transition: all 500ms ease;
}

View file

@ -8,6 +8,10 @@ import {CSSTransition} from 'react-transition-group';
import './info_toast.scss';
const VALID_POSITIONS = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'] as const;
export type ToastPosition = typeof VALID_POSITIONS[number];
const DEFAULT_POSITION: ToastPosition = 'bottom-right';
type Props = {
content: {
icon?: JSX.Element;
@ -15,11 +19,16 @@ type Props = {
undo?: () => void;
};
className?: string;
position?: ToastPosition;
onExited: () => void;
}
function InfoToast({content, onExited, className}: Props): JSX.Element {
function InfoToast({content, onExited, className, position = DEFAULT_POSITION}: Props): JSX.Element {
const {formatMessage} = useIntl();
// Validate position and fallback to default if invalid
const validatedPosition = VALID_POSITIONS.includes(position) ? position : DEFAULT_POSITION;
const closeToast = useCallback(() => {
onExited();
}, [onExited]);
@ -29,7 +38,7 @@ function InfoToast({content, onExited, className}: Props): JSX.Element {
onExited();
}, [content.undo, onExited]);
const toastContainerClassname = classNames('info-toast', className);
const toastContainerClassname = classNames('info-toast', `info-toast--${validatedPosition}`, className);
useEffect(() => {
const timer = setTimeout(() => {

View file

@ -6,21 +6,32 @@ import type {ConnectedProps} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {FileInfo} from '@mattermost/types/files';
import {getFilePublicLink} from 'mattermost-redux/actions/files';
import {isFileRejected} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {toggleEmbedVisibility} from 'actions/post_actions';
import {openModal} from 'actions/views/modals';
import {getIsRhsOpen} from 'selectors/rhs';
import SingleImageView from 'components/single_image_view/single_image_view';
import type {GlobalState} from 'types/store';
function mapStateToProps(state: GlobalState) {
type OwnProps = {
fileInfo: FileInfo;
};
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const isRhsOpen = getIsRhsOpen(state);
const config = getConfig(state);
return {
enablePublicLink: config.EnablePublicLink === 'true',
isFileRejected: isFileRejected(state, ownProps.fileInfo.id),
isRhsOpen,
};
}

View file

@ -5,10 +5,26 @@ import React from 'react';
import SingleImageView from 'components/single_image_view/single_image_view';
import {fireEvent, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {fireEvent, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
describe('components/SingleImageView', () => {
// Mock fetch to simulate successful thumbnail availability check
const mockFetch = jest.fn(() =>
Promise.resolve({
status: 200,
headers: new Headers(),
} as Response),
);
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
const baseProps = {
postId: 'original_post_id',
fileInfo: TestHelper.getFileInfoMock({id: 'file_info_id'}),
@ -20,13 +36,19 @@ describe('components/SingleImageView', () => {
getFilePublicLink: jest.fn(),
},
enablePublicLink: false,
isFileRejected: false,
};
test('should match snapshot', () => {
test('should match snapshot', async () => {
const {container} = renderWithContext(
<SingleImageView {...baseProps}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('img')).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
// Simulate loaded state by triggering image load
@ -40,7 +62,7 @@ describe('components/SingleImageView', () => {
expect(container).toMatchSnapshot();
});
test('should match snapshot, SVG image', () => {
test('should match snapshot, SVG image', async () => {
const fileInfo = TestHelper.getFileInfoMock({
id: 'svg_file_info_id',
name: 'name_svg',
@ -51,6 +73,11 @@ describe('components/SingleImageView', () => {
<SingleImageView {...props}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('img')).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
// Simulate loaded state by triggering image load
@ -68,6 +95,11 @@ describe('components/SingleImageView', () => {
<SingleImageView {...baseProps}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('img')).toBeInTheDocument();
});
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
@ -95,16 +127,26 @@ describe('components/SingleImageView', () => {
<SingleImageView {...props}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(screen.getByRole('button', {name: 'Toggle Embed Visibility'})).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', {name: 'Toggle Embed Visibility'}));
expect(props.actions.toggleEmbedVisibility).toHaveBeenCalledTimes(1);
expect(props.actions.toggleEmbedVisibility).toHaveBeenCalledWith('original_post_id');
});
test('should set loaded state on callback of onImageLoaded on SizeAwareImage component', () => {
test('should set loaded state on callback of onImageLoaded on SizeAwareImage component', async () => {
const {container} = renderWithContext(
<SingleImageView {...baseProps}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('.image-loaded')).toBeInTheDocument();
});
// Initially should not have image-fade-in class (loaded = false)
const imageLoadedDiv = container.querySelector('.image-loaded');
expect(imageLoadedDiv).not.toHaveClass('image-fade-in');
@ -122,18 +164,23 @@ describe('components/SingleImageView', () => {
expect(container).toMatchSnapshot();
});
test('should correctly pass prop down to surround small images with a container', () => {
test('should correctly pass prop down to surround small images with a container', async () => {
const {container} = renderWithContext(
<SingleImageView {...baseProps}/>,
);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('.file-preview__button')).toBeInTheDocument();
});
// The SizeAwareImage component should receive handleSmallImageContainer=true
// This is verified by checking that the component renders correctly
// The actual prop passing is internal, but we can verify the component structure
expect(container.querySelector('.file-preview__button')).toBeInTheDocument();
});
test('should not show filename when image is displayed', () => {
test('should not show filename when image is displayed', async () => {
const {container} = renderWithContext(
<SingleImageView
{...baseProps}
@ -141,10 +188,15 @@ describe('components/SingleImageView', () => {
/>,
);
// Wait for thumbnail availability check to complete (image-header--expanded indicates full render)
await waitFor(() => {
expect(container.querySelector('.image-header--expanded')).toBeInTheDocument();
});
expect(container.querySelector('.image-header')?.textContent).toHaveLength(0);
});
test('should show filename when image is collapsed', () => {
test('should show filename when image is collapsed', async () => {
const {container} = renderWithContext(
<SingleImageView
{...baseProps}
@ -152,11 +204,16 @@ describe('components/SingleImageView', () => {
/>,
);
// Wait for thumbnail availability check to complete (toggle button indicates full render)
await waitFor(() => {
expect(container.querySelector('.single-image-view__toggle')).toBeInTheDocument();
});
expect(container.querySelector('.image-header')?.textContent).toEqual(baseProps.fileInfo.name);
});
describe('permalink preview', () => {
test('should render with permalink styling if in permalink', () => {
test('should render with permalink styling if in permalink', async () => {
const props = {
...baseProps,
isInPermalink: true,
@ -164,6 +221,11 @@ describe('components/SingleImageView', () => {
const {container} = renderWithContext(<SingleImageView {...props}/>);
// Wait for thumbnail availability check to complete
await waitFor(() => {
expect(container.querySelector('.image-permalink')).toBeInTheDocument();
});
expect(container.querySelector('.image-permalink')).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

View file

@ -7,12 +7,13 @@ import type {KeyboardEvent, MouseEvent} from 'react';
import type {FileInfo} from '@mattermost/types/files';
import {getFilePreviewUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
import {Client4} from 'mattermost-redux/client';
import {getFilePreviewUrl, getFileUrl, getFileThumbnailUrl} from 'mattermost-redux/utils/file_utils';
import FilePreviewModal from 'components/file_preview_modal';
import SizeAwareImage from 'components/size_aware_image';
import {FileTypes, ModalIdentifiers} from 'utils/constants';
import {FileTypes, HttpHeaders, ModalIdentifiers} from 'utils/constants';
import {
getFileType,
} from 'utils/utils';
@ -38,6 +39,8 @@ type State = {
width: number;
height: number;
};
thumbnailCheckComplete: boolean;
thumbnailRejected: boolean;
}
export default class SingleImageView extends React.PureComponent<Props, State> {
@ -54,13 +57,54 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
width: props.fileInfo?.width || 0,
height: props.fileInfo?.height || 0,
},
thumbnailCheckComplete: false,
thumbnailRejected: false,
};
}
componentDidMount() {
this.mounted = true;
this.checkThumbnailAvailability();
}
checkThumbnailAvailability = () => {
// Probe the thumbnail endpoint to see if it's rejected by a plugin
// This allows plugins to control inline image display via thumbnail rejection when
// there's only one file in the post and we try to display it inline as a preview and
// not as a thumbnail.
const {fileInfo} = this.props;
if (!fileInfo || !fileInfo.has_preview_image) {
// No preview image, so we'll use the file directly - don't check thumbnail
this.setState({thumbnailCheckComplete: true, thumbnailRejected: false});
return;
}
const thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
// Use Client4.getOptions() to get properly authenticated request options
// This includes the Bearer token and all required headers
const options = Client4.getOptions({method: 'HEAD'});
fetch(thumbnailUrl, options).then((response) => {
if (this.mounted) {
// 403 Forbidden with X-Reject-Reason header = rejected by plugin
const rejected = response.status === 403 && response.headers.get(HttpHeaders.REJECT_REASON) !== null;
this.setState({
thumbnailCheckComplete: true,
thumbnailRejected: rejected,
});
}
}).catch(() => {
// On error, assume not rejected (fail open for compatibility)
if (this.mounted) {
this.setState({
thumbnailCheckComplete: true,
thumbnailRejected: false,
});
}
});
};
static getDerivedStateFromProps(props: Props, state: State) {
if ((props.fileInfo?.width !== state.dimensions.width) || props.fileInfo.height !== state.dimensions.height) {
return {
@ -112,12 +156,50 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
const {fileInfo, compactDisplay, isInPermalink} = this.props;
const {
loaded,
thumbnailCheckComplete,
thumbnailRejected,
} = this.state;
if (fileInfo === undefined) {
return <></>;
}
// If thumbnail check not complete yet, don't render the preview
// This prevents flashing the image before we know if it should be hidden
if (!thumbnailCheckComplete) {
return (
<div className={classNames('file-view--single')}>
<div className='file__image'>
<div className='image-header'>
<div className='image-name'>{fileInfo.name}</div>
</div>
</div>
</div>
);
}
// If thumbnail was rejected, treat this file as rejected
// Show it collapsed with file icon instead of inline preview
const effectivelyRejected = this.props.isFileRejected || thumbnailRejected;
if (effectivelyRejected) {
// Don't show inline preview - return minimal view
// User can still click to attempt opening in modal (which will be controlled by preview rejection)
return (
<div className={classNames('file-view--single')}>
<div className='file__image'>
<div className='image-header'>
<div
className='image-name'
onClick={this.handleImageClick}
>
{fileInfo.name}
</div>
</div>
</div>
</div>
);
}
const {has_preview_image: hasPreviewImage, id} = fileInfo;
const fileURL = getFileUrl(id);
const previewURL = hasPreviewImage ? getFilePreviewUrl(id) : fileURL;
@ -245,6 +327,7 @@ export default class SingleImageView extends React.PureComponent<Props, State> {
enablePublicLink={this.props.enablePublicLink}
getFilePublicLink={this.getFilePublicLink}
hideUtilities={this.props.disableActions}
isFileRejected={effectivelyRejected}
/>
</div>
</div>

View file

@ -93,6 +93,11 @@ export type Props = WrappedComponentProps & {
* Prevents display of utility buttons when image in a location that makes them inappropriate
*/
hideUtilities?: boolean;
/*
* Indicates whether the file has been rejected and should not show preview
*/
isFileRejected?: boolean;
}
type State = {
@ -212,6 +217,7 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
Reflect.deleteProperty(props, 'onClick');
Reflect.deleteProperty(props, 'hideUtilities');
Reflect.deleteProperty(props, 'getFilePublicLink');
Reflect.deleteProperty(props, 'isFileRejected');
Reflect.deleteProperty(props, 'intl');
let ariaLabelImage = intl.formatMessage({id: 'file_attachment.thumbnail', defaultMessage: 'file thumbnail'});
@ -407,7 +413,8 @@ export class SizeAwareImage extends React.PureComponent<Props, State> {
const height = (dimensions?.height ?? 0) * ratio;
const width = (dimensions?.width ?? 0) * ratio;
const miniPreview = getFileMiniPreviewUrl(fileInfo);
// Don't show mini preview (blurred thumbnail) if the file is rejected
const miniPreview = this.props.isFileRejected ? null : getFileMiniPreviewUrl(fileInfo);
if (miniPreview) {
fallback = (

View file

@ -11,6 +11,7 @@ import {sendEphemeralPost} from 'actions/global_actions';
import reduxStore from 'stores/redux_store';
import {Constants} from 'utils/constants';
import {getIntl} from 'utils/i18n';
import {isMac} from 'utils/user_agent';
import type {ParsedCommand} from './app_command_parser';
@ -114,6 +115,13 @@ export const displayError = (err: string, channelID: string, rootID?: string) =>
reduxStore.dispatch(sendEphemeralPost(err, channelID, rootID));
};
// Shim of mobile-version intl
export const intlShim = {
formatMessage: (config: {id: string; defaultMessage?: string}, values?: {[name: string]: any}) => {
return getIntl().formatMessage(config, values);
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const errorMessage = (intl: IntlShape, error: string, _command: string, _position: number): string => {
return intl.formatMessage({

View file

@ -16,10 +16,12 @@ import './file_card.scss';
type Props = {
file?: FileInfo;
enableSVGs: boolean;
isFileRejected?: boolean;
}
type FileProps = FileInfo & {
enableSVGs: boolean;
isFileRejected?: boolean;
}
type CardProps = {
@ -35,16 +37,19 @@ function File({
mime_type: mimeType,
extension,
enableSVGs,
isFileRejected,
}: FileProps) {
const imgSrc = useMemo(() => {
if (!hasPreviewImage) {
if (!hasPreviewImage || isFileRejected) {
return undefined;
}
// Don't show blurred preview for rejected files
if (miniPreview) {
return `data:${mimeType};base64,${miniPreview}`;
}
return getFileThumbnailUrl(id);
}, [id, miniPreview, mimeType, hasPreviewImage]);
}, [id, miniPreview, mimeType, hasPreviewImage, isFileRejected]);
const fileType = getFileType(extension);
@ -110,7 +115,7 @@ function Card({children, title, size}: CardProps) {
);
}
function FileCard({file, enableSVGs}: Props) {
function FileCard({file, enableSVGs, isFileRejected}: Props) {
if (!file) {
return null;
}
@ -122,6 +127,7 @@ function FileCard({file, enableSVGs}: Props) {
>
<File
enableSVGs={enableSVGs}
isFileRejected={isFileRejected}
{...file}
/>
</Card>

View file

@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import type {FileInfo} from '@mattermost/types/files';
import {getFile} from 'mattermost-redux/selectors/entities/files';
import {getFile, isFileRejected} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import type {GlobalState} from 'types/store';
@ -23,6 +23,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
file,
enableSVGs: config.EnableSVGs === 'true',
isFileRejected: isFileRejected(state, ownProps.id),
};
}

View file

@ -3714,6 +3714,7 @@
"channel_bookmarks.create.title_input.clear_emoji": "Remove emoji",
"channel_bookmarks.create.title_input.label": "Title",
"channel_bookmarks.delete": "Delete",
"channel_bookmarks.download": "Download",
"channel_bookmarks.edit": "Edit",
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
"channel_bookmarks.open": "Open",
@ -4478,6 +4479,7 @@
"feedback.other": "Other",
"FIFTY_TO_100": "51-100",
"file_attachment.thumbnail": "file thumbnail",
"file_download.rejected.file": "File access blocked: {reason}",
"file_info_preview.size": "Size ",
"file_info_preview.type": "File type ",
"file_preview_modal_info.shared_in": "Shared in ~{name}",

View file

@ -15,4 +15,5 @@ export default keyMirror({
RECEIVED_FILE_PUBLIC_LINK: null,
REMOVED_FILE: null,
FILE_DOWNLOAD_REJECTED: null,
});

View file

@ -229,9 +229,28 @@ function filePublicLink(state: {link: string} = {link: ''}, action: MMReduxActio
}
}
export function rejectedFiles(state: Set<string> = new Set(), action: MMReduxAction) {
switch (action.type) {
case FileTypes.FILE_DOWNLOAD_REJECTED: {
const {file_id: fileId} = action.data;
if (fileId) {
const nextState = new Set(state);
nextState.add(fileId);
return nextState;
}
return state;
}
case UserTypes.LOGOUT_SUCCESS:
return new Set();
default:
return state;
}
}
export default combineReducers({
files,
filesFromSearch,
fileIdsByPostId,
filePublicLink,
rejectedFiles,
});

View file

@ -25,6 +25,15 @@ export function getFilePublicLink(state: GlobalState) {
return state.entities.files.filePublicLink;
}
export function getRejectedFiles(state: GlobalState): Set<string> {
return state.entities.files.rejectedFiles || new Set();
}
export function isFileRejected(state: GlobalState, fileId: string): boolean {
const rejectedFiles = getRejectedFiles(state);
return rejectedFiles.has(fileId);
}
export function makeGetFileIdsForPost(): (state: GlobalState, postId: string) => string[] {
return createSelector(
'makeGetFileIdsForPost',

View file

@ -153,6 +153,7 @@ const state: GlobalState = {
files: {},
filesFromSearch: {},
fileIdsByPostId: {},
rejectedFiles: new Set(),
},
emojis: {
customEmoji: {},

View file

@ -930,6 +930,10 @@ export const FileTypes = {
LICENSE_EXTENSION: '.mattermost-license',
};
export const HttpHeaders = {
REJECT_REASON: 'X-Reject-Reason',
};
export const NotificationLevels = {
DEFAULT: 'default',
ALL: 'all',

View file

@ -91,4 +91,6 @@ export const enum WebSocketEvents {
ContentFlaggingReportValueUpdated = 'content_flagging_report_value_updated',
RecapUpdated = 'recap_updated',
PostTranslationUpdated = 'post_translation_updated',
FileDownloadRejected = 'file_download_rejected',
ShowToast = 'show_toast',
}

View file

@ -92,6 +92,9 @@ export type WebSocketMessage = (
Messages.RecapUpdated |
Messages.FileDownloadRejected |
Messages.ShowToast |
Messages.Plugin |
Messages.PluginStatusesChanged |
Messages.OpenDialog |

View file

@ -444,6 +444,20 @@ export type OpenDialog = BaseWebSocketMessage<WebSocketEvents.OpenDialog, {
dialog: JsonEncodedValue<OpenDialogRequest>;
}>;
export type FileDownloadRejected = BaseWebSocketMessage<WebSocketEvents.FileDownloadRejected, {
file_id: string;
file_name: string;
rejection_reason: string;
channel_id: string;
post_id: string;
download_type: string;
}>;
export type ShowToast = BaseWebSocketMessage<WebSocketEvents.ShowToast, {
message: string;
position?: string;
}>;
/**
* Unknown is used for WebSocket messages which don't come from Mattermost itself. It's primarily intended for use
* by plugins.

View file

@ -1,6 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* FileDownloadType represents the type of file download or access being performed.
*/
export type FileDownloadType = 'file' | 'thumbnail' | 'preview' | 'public';
/**
* FileDownloadTypes contains constants for the different types of file downloads.
*/
export const FileDownloadTypes = {
/** Full file download request */
FILE: 'file' as FileDownloadType,
/** Thumbnail image request */
THUMBNAIL: 'thumbnail' as FileDownloadType,
/** Preview image request */
PREVIEW: 'preview' as FileDownloadType,
/** Public link access (unauthenticated) */
PUBLIC: 'public' as FileDownloadType,
} as const;
export type FileInfo = {
id: string;
user_id: string;
@ -26,6 +49,7 @@ export type FilesState = {
filesFromSearch: Record<string, FileSearchResultItem>;
fileIdsByPostId: Record<string, string[]>;
filePublicLink?: {link: string};
rejectedFiles: Set<string>;
};
export type FileUploadResponse = {