From 1be8a68dd7f387bb24d0b2c80a048c84af93740a Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:10:39 +0100 Subject: [PATCH] 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> --- server/channels/api4/file.go | 49 +- server/channels/api4/file_test.go | 96 ++++ server/channels/app/file.go | 66 +++ server/channels/app/plugin_api.go | 4 + .../app/plugin_file_download_hook_test.go | 529 ++++++++++++++++++ server/channels/app/toast.go | 38 ++ server/channels/app/toast_test.go | 57 ++ .../store/retrylayer/retrylayer_test.go | 1 + server/i18n/en.json | 28 + server/public/model/client4.go | 1 + server/public/model/config.go | 11 +- server/public/model/file_info.go | 14 + server/public/model/plugin_toast.go | 12 + server/public/model/websocket_message.go | 2 + server/public/plugin/api.go | 8 + .../plugin/api_timer_layer_generated.go | 7 + server/public/plugin/client_rpc_generated.go | 68 +++ server/public/plugin/hooks.go | 15 + .../plugin/hooks_timer_layer_generated.go | 7 + server/public/plugin/plugintest/api.go | 20 + server/public/plugin/plugintest/hooks.go | 18 + server/public/pluginapi/frontend.go | 9 + .../channels/src/actions/websocket_actions.ts | 115 +++- .../channel_bookmarks/bookmark_dot_menu.tsx | 24 +- .../channels/src/components/code_preview.tsx | 95 +++- .../file_attachment/file_attachment.test.tsx | 1 + .../file_attachment/file_attachment.tsx | 15 + .../file_thumbnail/file_thumbnail.tsx | 5 +- .../file_attachment/file_thumbnail/index.ts | 11 +- .../src/components/file_attachment/index.ts | 5 + .../file_attachment_list.tsx | 4 +- .../components/file_attachment_list/index.ts | 5 + .../file_preview_modal/file_preview_modal.tsx | 3 +- .../__snapshots__/info_toast.test.tsx.snap | 2 +- .../src/components/info_toast/info_toast.scss | 85 ++- .../src/components/info_toast/info_toast.tsx | 13 +- .../src/components/single_image_view/index.ts | 13 +- .../single_image_view.test.tsx | 78 ++- .../single_image_view/single_image_view.tsx | 87 ++- .../src/components/size_aware_image.tsx | 9 +- .../app_command_parser_dependencies.ts | 8 + .../attachments/file_card/file_card.tsx | 12 +- .../attachments/file_card/index.ts | 3 +- webapp/channels/src/i18n/en.json | 2 + .../src/action_types/files.ts | 1 + .../src/reducers/entities/files.ts | 19 + .../src/selectors/entities/files.ts | 9 + .../src/store/initial_state.ts | 1 + webapp/channels/src/utils/constants.tsx | 4 + .../platform/client/src/websocket_events.ts | 2 + .../platform/client/src/websocket_message.ts | 3 + .../platform/client/src/websocket_messages.ts | 14 + webapp/platform/types/src/files.ts | 24 + 53 files changed, 1664 insertions(+), 68 deletions(-) create mode 100644 server/channels/app/plugin_file_download_hook_test.go create mode 100644 server/channels/app/toast.go create mode 100644 server/channels/app/toast_test.go create mode 100644 server/public/model/plugin_toast.go diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index 633a4112481..ef310afbc15 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -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 diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index d962cb2e52c..a85f2d57ebd 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -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) + }) +} diff --git a/server/channels/app/file.go b/server/channels/app/file.go index 906df1415a9..c92d91b2446 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -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 + } +} diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 5373c1e08a6..c424e8bf8e7 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -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) } diff --git a/server/channels/app/plugin_file_download_hook_test.go b/server/channels/app/plugin_file_download_hook_test.go new file mode 100644 index 00000000000..e6586f43f8b --- /dev/null +++ b/server/channels/app/plugin_file_download_hook_test.go @@ -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) + }) +} diff --git a/server/channels/app/toast.go b/server/channels/app/toast.go new file mode 100644 index 00000000000..901b030ca9e --- /dev/null +++ b/server/channels/app/toast.go @@ -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 +} diff --git a/server/channels/app/toast_test.go b/server/channels/app/toast_test.go new file mode 100644 index 00000000000..ca7d3a6f005 --- /dev/null +++ b/server/channels/app/toast_test.go @@ -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) + }) +} diff --git a/server/channels/store/retrylayer/retrylayer_test.go b/server/channels/store/retrylayer/retrylayer_test.go index 75ca361d617..34278618452 100644 --- a/server/channels/store/retrylayer/retrylayer_test.go +++ b/server/channels/store/retrylayer/retrylayer_test.go @@ -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 } diff --git a/server/i18n/en.json b/server/i18n/en.json index 9ae8a81f911..264cd0e0d2d 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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" diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 169a69ba886..96b4e312736 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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" diff --git a/server/public/model/config.go b/server/public/model/config.go index b72730b4fec..0a1f3d581a0 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -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" diff --git a/server/public/model/file_info.go b/server/public/model/file_info.go index d334246fab6..01b67824a61 100644 --- a/server/public/model/file_info.go +++ b/server/public/model/file_info.go @@ -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. diff --git a/server/public/model/plugin_toast.go b/server/public/model/plugin_toast.go new file mode 100644 index 00000000000..6e85debbf9f --- /dev/null +++ b/server/public/model/plugin_toast.go @@ -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"` +} diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index e3caf7f55de..9dcc53836c4 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -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" diff --git a/server/public/plugin/api.go b/server/public/plugin/api.go index 5f1c2eae17c..f2a6d1d2236 100644 --- a/server/public/plugin/api.go +++ b/server/public/plugin/api.go @@ -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. diff --git a/server/public/plugin/api_timer_layer_generated.go b/server/public/plugin/api_timer_layer_generated.go index d490d6d58f2..3a2a83192a9 100644 --- a/server/public/plugin/api_timer_layer_generated.go +++ b/server/public/plugin/api_timer_layer_generated.go @@ -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() diff --git a/server/public/plugin/client_rpc_generated.go b/server/public/plugin/client_rpc_generated.go index cc6bb63f0f3..6d3d6e71892 100644 --- a/server/public/plugin/client_rpc_generated.go +++ b/server/public/plugin/client_rpc_generated.go @@ -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 { } diff --git a/server/public/plugin/hooks.go b/server/public/plugin/hooks.go index 53e44dc4019..37cc7a3361b 100644 --- a/server/public/plugin/hooks.go +++ b/server/public/plugin/hooks.go @@ -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 diff --git a/server/public/plugin/hooks_timer_layer_generated.go b/server/public/plugin/hooks_timer_layer_generated.go index ded4d9e30f2..e0b25aa8647 100644 --- a/server/public/plugin/hooks_timer_layer_generated.go +++ b/server/public/plugin/hooks_timer_layer_generated.go @@ -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) diff --git a/server/public/plugin/plugintest/api.go b/server/public/plugin/plugintest/api.go index 40c4dcce84f..749a8b51376 100644 --- a/server/public/plugin/plugintest/api.go +++ b/server/public/plugin/plugintest/api.go @@ -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) diff --git a/server/public/plugin/plugintest/hooks.go b/server/public/plugin/plugintest/hooks.go index 97b42da8890..dc277852fca 100644 --- a/server/public/plugin/plugintest/hooks.go +++ b/server/public/plugin/plugintest/hooks.go @@ -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) diff --git a/server/public/pluginapi/frontend.go b/server/public/pluginapi/frontend.go index a0382318c35..ae7d68a516d 100644 --- a/server/public/pluginapi/frontend.go +++ b/server/public/pluginapi/frontend.go @@ -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)) +} diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index 76c08c8cfb6..c89f04f48f8 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -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 { + 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 { + 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)); + }, + }, + })); + } + }; +} diff --git a/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx b/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx index 382bd991bdc..aeb38d12957 100644 --- a/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx +++ b/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx @@ -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 ( )} + {bookmark.type === 'file' && fileInfo && ( + } + labels={{downloadLabel}} + aria-label={downloadLabel} + /> + )} {canDelete && ( ('loading'); + const [prevFileUrl, setPrevFileUrl] = useState(); + + 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 ( diff --git a/webapp/channels/src/components/file_attachment/file_attachment.test.tsx b/webapp/channels/src/components/file_attachment/file_attachment.test.tsx index 3fffcb2deee..149959906d4 100644 --- a/webapp/channels/src/components/file_attachment/file_attachment.test.tsx +++ b/webapp/channels/src/components/file_attachment/file_attachment.test.tsx @@ -60,6 +60,7 @@ describe('FileAttachment', () => { enablePublicLink: false, pluginMenuItems: [], currentChannel: TestHelper.getChannelMock(), + isFileRejected: false, handleFileDropdownOpened: jest.fn(() => null), actions: { openModal: jest.fn(), diff --git a/webapp/channels/src/components/file_attachment/file_attachment.tsx b/webapp/channels/src/components/file_attachment/file_attachment.tsx index 27a5a188a87..be2c58fc451 100644 --- a/webapp/channels/src/components/file_attachment/file_attachment.tsx +++ b/webapp/channels/src/components/file_attachment/file_attachment.tsx @@ -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) => { e.preventDefault(); e.stopPropagation(); diff --git a/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx b/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx index 24320586add..de290a0ef12 100644 --- a/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx +++ b/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx @@ -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'; diff --git a/webapp/channels/src/components/file_attachment/file_thumbnail/index.ts b/webapp/channels/src/components/file_attachment/file_thumbnail/index.ts index 79c338a402e..7f31eafe13a 100644 --- a/webapp/channels/src/components/file_attachment/file_thumbnail/index.ts +++ b/webapp/channels/src/components/file_attachment/file_thumbnail/index.ts @@ -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, }; } diff --git a/webapp/channels/src/components/file_attachment/index.ts b/webapp/channels/src/components/file_attachment/index.ts index 920edefc3c4..b2d5e911e45 100644 --- a/webapp/channels/src/components/file_attachment/index.ts +++ b/webapp/channels/src/components/file_attachment/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx b/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx index 62ef99b24c5..977f743e4ed 100644 --- a/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx +++ b/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx @@ -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)) { diff --git a/webapp/channels/src/components/file_attachment_list/index.ts b/webapp/channels/src/components/file_attachment_list/index.ts index 716c39dd76e..f9190755a11 100644 --- a/webapp/channels/src/components/file_attachment_list/index.ts +++ b/webapp/channels/src/components/file_attachment_list/index.ts @@ -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, }; }; } diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx index f4d398ab63c..6dbfe274e7c 100644 --- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx +++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx @@ -219,12 +219,13 @@ export default class FilePreviewModal extends React.PureComponent handleImageLoaded = (index: number) => { this.setState((prevState) => { - return { + const newState = { loaded: { ...prevState.loaded, [index]: true, }, }; + return newState; }); }; diff --git a/webapp/channels/src/components/info_toast/__snapshots__/info_toast.test.tsx.snap b/webapp/channels/src/components/info_toast/__snapshots__/info_toast.test.tsx.snap index 656c38d5d68..5a78a4dcbb7 100644 --- a/webapp/channels/src/components/info_toast/__snapshots__/info_toast.test.tsx.snap +++ b/webapp/channels/src/components/info_toast/__snapshots__/info_toast.test.tsx.snap @@ -3,7 +3,7 @@ exports[`components/InfoToast should match snapshot 1`] = `
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(() => { diff --git a/webapp/channels/src/components/single_image_view/index.ts b/webapp/channels/src/components/single_image_view/index.ts index 2920215e214..61385db6522 100644 --- a/webapp/channels/src/components/single_image_view/index.ts +++ b/webapp/channels/src/components/single_image_view/index.ts @@ -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, }; } diff --git a/webapp/channels/src/components/single_image_view/single_image_view.test.tsx b/webapp/channels/src/components/single_image_view/single_image_view.test.tsx index 50b9b08778d..143b97c8f1f 100644 --- a/webapp/channels/src/components/single_image_view/single_image_view.test.tsx +++ b/webapp/channels/src/components/single_image_view/single_image_view.test.tsx @@ -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( , ); + // 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', () => { , ); + // 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', () => { , ); + // 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', () => { , ); + // 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( , ); + // 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( , ); + // 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( { />, ); + // 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( { />, ); + // 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(); + // Wait for thumbnail availability check to complete + await waitFor(() => { + expect(container.querySelector('.image-permalink')).toBeInTheDocument(); + }); + expect(container.querySelector('.image-permalink')).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); diff --git a/webapp/channels/src/components/single_image_view/single_image_view.tsx b/webapp/channels/src/components/single_image_view/single_image_view.tsx index 63396af8c1a..99c2ef2ad45 100644 --- a/webapp/channels/src/components/single_image_view/single_image_view.tsx +++ b/webapp/channels/src/components/single_image_view/single_image_view.tsx @@ -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 { @@ -54,13 +57,54 @@ export default class SingleImageView extends React.PureComponent { 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 { 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 ( +
+
+
+
{fileInfo.name}
+
+
+
+ ); + } + + // 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 ( +
+
+
+
+ {fileInfo.name} +
+
+
+
+ ); + } + 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 { enablePublicLink={this.props.enablePublicLink} getFilePublicLink={this.getFilePublicLink} hideUtilities={this.props.disableActions} + isFileRejected={effectivelyRejected} />
diff --git a/webapp/channels/src/components/size_aware_image.tsx b/webapp/channels/src/components/size_aware_image.tsx index 1630e9f12d7..2c02a76d8ab 100644 --- a/webapp/channels/src/components/size_aware_image.tsx +++ b/webapp/channels/src/components/size_aware_image.tsx @@ -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 { 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 { 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 = ( diff --git a/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts b/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts index 2a24436dadc..ed56b64cb3d 100644 --- a/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts +++ b/webapp/channels/src/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts @@ -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({ diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/file_card.tsx b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/file_card.tsx index 50424966425..df300a463f0 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/file_card.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/file_card.tsx @@ -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) { > diff --git a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/index.ts b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/index.ts index 81110088383..7815944afa9 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/index.ts +++ b/webapp/channels/src/components/threading/global_threads/thread_item/attachments/file_card/index.ts @@ -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), }; } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ba688957867..1b0c57561f9 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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}", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/files.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/files.ts index d977e4b4c00..f6cb5a052c3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/files.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/files.ts @@ -15,4 +15,5 @@ export default keyMirror({ RECEIVED_FILE_PUBLIC_LINK: null, REMOVED_FILE: null, + FILE_DOWNLOAD_REJECTED: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts index b2270cef733..34bba3111b1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts @@ -229,9 +229,28 @@ function filePublicLink(state: {link: string} = {link: ''}, action: MMReduxActio } } +export function rejectedFiles(state: Set = 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, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/files.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/files.ts index 9f66eb72193..1a2e40a6093 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/files.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/files.ts @@ -25,6 +25,15 @@ export function getFilePublicLink(state: GlobalState) { return state.entities.files.filePublicLink; } +export function getRejectedFiles(state: GlobalState): Set { + 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', diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index fffd607eb5f..eae555f8938 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -153,6 +153,7 @@ const state: GlobalState = { files: {}, filesFromSearch: {}, fileIdsByPostId: {}, + rejectedFiles: new Set(), }, emojis: { customEmoji: {}, diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 8c867f532db..1c9d006e089 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -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', diff --git a/webapp/platform/client/src/websocket_events.ts b/webapp/platform/client/src/websocket_events.ts index 84c062298c5..17884bb38c6 100644 --- a/webapp/platform/client/src/websocket_events.ts +++ b/webapp/platform/client/src/websocket_events.ts @@ -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', } diff --git a/webapp/platform/client/src/websocket_message.ts b/webapp/platform/client/src/websocket_message.ts index c1adf4c5ec3..93961fa479f 100644 --- a/webapp/platform/client/src/websocket_message.ts +++ b/webapp/platform/client/src/websocket_message.ts @@ -92,6 +92,9 @@ export type WebSocketMessage = ( Messages.RecapUpdated | + Messages.FileDownloadRejected | + Messages.ShowToast | + Messages.Plugin | Messages.PluginStatusesChanged | Messages.OpenDialog | diff --git a/webapp/platform/client/src/websocket_messages.ts b/webapp/platform/client/src/websocket_messages.ts index 7e56e56903c..f8b178f67d0 100644 --- a/webapp/platform/client/src/websocket_messages.ts +++ b/webapp/platform/client/src/websocket_messages.ts @@ -444,6 +444,20 @@ export type OpenDialog = BaseWebSocketMessage; }>; +export type FileDownloadRejected = BaseWebSocketMessage; + +export type ShowToast = BaseWebSocketMessage; + /** * Unknown is used for WebSocket messages which don't come from Mattermost itself. It's primarily intended for use * by plugins. diff --git a/webapp/platform/types/src/files.ts b/webapp/platform/types/src/files.ts index 4ecaddeaf1b..adc997c4bad 100644 --- a/webapp/platform/types/src/files.ts +++ b/webapp/platform/types/src/files.ts @@ -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; fileIdsByPostId: Record; filePublicLink?: {link: string}; + rejectedFiles: Set; }; export type FileUploadResponse = {