From e8406345a5b716979ba182e2df27492d11f5459a Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:52:07 +0100 Subject: [PATCH] Content flagging file downloads (#34480) * Server change donw * webapp changes * Disabled file actions * lint fixes * Removed leftover comment * CI * Added tests * lint fixes --------- Co-authored-by: Mattermost Build --- server/channels/api4/channel.go | 2 +- server/channels/api4/file.go | 74 +++++++++-- server/channels/api4/file_test.go | 121 ++++++++++++++---- server/channels/api4/team.go | 2 +- .../channels/store/retrylayer/retrylayer.go | 4 +- .../channels/store/timerlayer/timerlayer.go | 4 +- server/i18n/en.json | 8 ++ server/public/model/client4.go | 17 ++- server/public/model/content_flagging.go | 2 + .../file_attachment/file_attachment.tsx | 2 + .../file_attachment/filename_overlay.tsx | 5 +- .../file_attachment_list.tsx | 1 + .../components/file_attachment_list/index.ts | 1 + .../data_spillage_report.tsx | 6 + .../post_message_preview.tsx | 6 +- .../properties_card_view.tsx | 1 + .../post_preview_property_renderer.tsx | 2 + .../mattermost-redux/src/utils/file_utils.ts | 17 ++- 18 files changed, 227 insertions(+), 48 deletions(-) diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index ffcb2de0c08..eb99ccc9368 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -630,7 +630,7 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) { } isContentReviewer := false - asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get("as_content_reviewer")) + asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get(model.AsContentReviewerParam)) if asContentReviewer { requireContentFlaggingEnabled(c) if c.Err != nil { diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index 2fad178bd90..ed4fdd4909f 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -513,31 +513,77 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) model.AddEventParameterToAuditRec(auditRec, "force_download", forceDownload) - info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId) - if err != nil { - c.Err = err - setInaccessibleFileHeader(w, err) + fileInfos, storeErr := c.App.Srv().Store().FileInfo().GetByIds([]string{c.Params.FileId}, true, true) + if storeErr != nil { + c.Err = model.NewAppError("getFile", "api.file.get_file_info.app_error", nil, "", http.StatusInternalServerError) + setInaccessibleFileHeader(w, c.Err) + return + } else if len(fileInfos) == 0 { + c.Err = model.NewAppError("getFile", "api.file.get_file_info.app_error", nil, "", http.StatusNotFound) + setInaccessibleFileHeader(w, c.Err) return } - model.AddEventParameterAuditableToAuditRec(auditRec, "file", info) - channel, err := c.App.GetChannel(c.AppContext, info.ChannelId) + fileInfo := fileInfos[0] + + channel, err := c.App.GetChannel(c.AppContext, fileInfo.ChannelId) if err != nil { c.Err = err return } - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) - if info.CreatorId == model.BookmarkFileOwner { - if !perm { + + isContentReviewer := false + asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get(model.AsContentReviewerParam)) + if asContentReviewer { + requireContentFlaggingEnabled(c) + if c.Err != nil { + return + } + + flaggedPostId := r.URL.Query().Get("flagged_post_id") + requireFlaggedPost(c, flaggedPostId) + if c.Err != nil { + return + } + + if flaggedPostId != fileInfo.PostId { + c.Err = model.NewAppError("getFile", "api.file.get_file.invalid_flagged_post.app_error", nil, "file_id="+fileInfo.Id+", flagged_post_id="+flaggedPostId, http.StatusBadRequest) + return + } + + requireTeamContentReviewer(c, c.AppContext.Session().UserId, channel.TeamId) + if c.Err != nil { + return + } + + isContentReviewer = true + } + + // at this point we may have fetched a deleted file info and + // if the user is not a content reviewer, the request should fail as + // fetching deleted file info is only allowed for content reviewers of the specific post + if fileInfo.DeleteAt != 0 && !isContentReviewer { + c.Err = model.NewAppError("getFile", "app.file_info.get.app_error", nil, "", http.StatusNotFound) + setInaccessibleFileHeader(w, c.Err) + return + } + + model.AddEventParameterAuditableToAuditRec(auditRec, "file", fileInfo) + + if !isContentReviewer { + perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if fileInfo.CreatorId == model.BookmarkFileOwner { + if !perm { + c.SetPermissionError(model.PermissionReadChannelContent) + return + } + } else if fileInfo.CreatorId != c.AppContext.Session().UserId && !perm { c.SetPermissionError(model.PermissionReadChannelContent) return } - } else if info.CreatorId != c.AppContext.Session().UserId && !perm { - c.SetPermissionError(model.PermissionReadChannelContent) - return } - fileReader, err := c.App.FileReader(info.Path) + fileReader, err := c.App.FileReader(fileInfo.Path) if err != nil { c.Err = err c.Err.StatusCode = http.StatusNotFound @@ -547,7 +593,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.Success() - web.WriteFileResponse(info.Name, info.MimeType, info.Size, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r) + web.WriteFileResponse(fileInfo.Name, fileInfo.MimeType, fileInfo.Size, time.Unix(0, fileInfo.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r) } func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index 30ed9e2bddf..d962cb2e52c 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -790,35 +790,112 @@ func TestGetFile(t *testing.T) { t.Skip("skipping because no file driver is enabled") } - sent, err := testutils.ReadTestFile("test.png") - require.NoError(t, err) + t.Run("base case", func(t *testing.T) { + sent, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) - fileResp, _, err := client.UploadFile(context.Background(), sent, channel.Id, "test.png") - require.NoError(t, err) + fileResp, _, err := client.UploadFile(context.Background(), sent, channel.Id, "test.png") + require.NoError(t, err) - fileId := fileResp.FileInfos[0].Id + fileId := fileResp.FileInfos[0].Id - data, _, err := client.GetFile(context.Background(), fileId) - require.NoError(t, err) - require.NotEqual(t, 0, len(data), "should not be empty") + data, _, err := client.GetFile(context.Background(), fileId) + require.NoError(t, err) + require.NotEqual(t, 0, len(data), "should not be empty") - for i := range data { - require.Equal(t, sent[i], data[i], "received file didn't match sent one") - } + for i := range data { + require.Equal(t, sent[i], data[i], "received file didn't match sent one") + } - _, resp, err := client.GetFile(context.Background(), "junk") - require.Error(t, err) - CheckBadRequestStatus(t, resp) + _, resp, err := client.GetFile(context.Background(), "junk") + require.Error(t, err) + CheckBadRequestStatus(t, resp) - _, resp, err = client.GetFile(context.Background(), model.NewId()) - require.Error(t, err) - CheckNotFoundStatus(t, resp) + _, resp, err = client.GetFile(context.Background(), model.NewId()) + require.Error(t, err) + CheckNotFoundStatus(t, resp) - _, err = client.Logout(context.Background()) - require.NoError(t, err) - _, resp, err = client.GetFile(context.Background(), fileId) - require.Error(t, err) - CheckUnauthorizedStatus(t, resp) + _, err = client.Logout(context.Background()) + require.NoError(t, err) + _, resp, err = client.GetFile(context.Background(), fileId) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + }) + + t.Run("content reviewer should be able to get file of channel and team they are not a member of", func(t *testing.T) { + th.LoginBasic(t) + ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + require.True(t, ok, "failed to set license") + + defer func() { + appErr := th.App.Srv().RemoveLicense() + require.Nil(t, appErr) + }() + + newChannel := th.CreatePrivateChannel(t) + + sent, err := testutils.ReadTestFile("test.png") + require.NoError(t, err) + + fileResp, _, err := client.UploadFile(context.Background(), sent, channel.Id, "test.png") + require.NoError(t, err) + + post := th.CreatePostWithFilesWithClient(t, client, newChannel, fileResp.FileInfos[0]) + + reviewer := th.CreateUser(t) + response, err := th.SystemAdminClient.SaveContentFlaggingSettings(context.Background(), &model.ContentFlaggingSettingsRequest{ + ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ + EnableContentFlagging: model.NewPointer(true), + }, + ReviewerSettings: &model.ReviewSettingsRequest{ + ReviewerSettings: model.ReviewerSettings{ + CommonReviewers: model.NewPointer(true), + }, + ReviewerIDsSettings: model.ReviewerIDsSettings{ + CommonReviewerIds: []string{reviewer.Id}, + }, + }, + }) + require.NoError(t, err) + CheckOKStatus(t, response) + + response, err = client.FlagPostForContentReview(context.Background(), post.Id, &model.FlagContentRequest{ + Reason: "Sensitive data", + Comment: "This is sensitive content", + }) + require.NoError(t, err) + CheckOKStatus(t, response) + + reviewerClient := th.CreateClient() + _, response, err = reviewerClient.Login(context.Background(), reviewer.Email, "Pa$$word11") + require.NoError(t, err) + CheckOKStatus(t, response) + + _, response, err = reviewerClient.GetFileAsContentReviewer(context.Background(), fileResp.FileInfos[0].Id, post.Id) + require.NoError(t, err) + CheckOKStatus(t, response) + + // Try again after removing the user from content reviewers + response, err = th.SystemAdminClient.SaveContentFlaggingSettings(context.Background(), &model.ContentFlaggingSettingsRequest{ + ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{ + EnableContentFlagging: model.NewPointer(true), + }, + ReviewerSettings: &model.ReviewSettingsRequest{ + ReviewerSettings: model.ReviewerSettings{ + CommonReviewers: model.NewPointer(true), + }, + ReviewerIDsSettings: model.ReviewerIDsSettings{ + CommonReviewerIds: []string{th.BasicUser.Id}, + }, + }, + }) + require.NoError(t, err) + CheckOKStatus(t, response) + + _, response, err = reviewerClient.GetFileAsContentReviewer(context.Background(), fileResp.FileInfos[0].Id, post.Id) + require.Error(t, err) + CheckForbiddenStatus(t, response) + }) } func TestGetFileAsSystemAdmin(t *testing.T) { diff --git a/server/channels/api4/team.go b/server/channels/api4/team.go index 62c2ddaa147..6e9fa0741ba 100644 --- a/server/channels/api4/team.go +++ b/server/channels/api4/team.go @@ -151,7 +151,7 @@ func getTeam(c *Context, w http.ResponseWriter, r *http.Request) { } isContentReviewer := false - asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get("as_content_reviewer")) + asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get(model.AsContentReviewerParam)) if asContentReviewer { requireContentFlaggingEnabled(c) if c.Err != nil { diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 989fa98b8c2..087914aa656 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -8536,11 +8536,11 @@ func (s *RetryLayerPostStore) RefreshPostStats() error { } -func (s *RetryLayerPostStore) RestoreContentFlaggedPost(post *model.Post, deletedBy string, statusFieldId string) error { +func (s *RetryLayerPostStore) RestoreContentFlaggedPost(post *model.Post, statusFieldId string, contentFlaggingManagedFieldId string) error { tries := 0 for { - err := s.PostStore.RestoreContentFlaggedPost(post, deletedBy, statusFieldId) + err := s.PostStore.RestoreContentFlaggedPost(post, statusFieldId, contentFlaggingManagedFieldId) if err == nil { return nil } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index b962c2f0acb..008313b4264 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -6814,10 +6814,10 @@ func (s *TimerLayerPostStore) RefreshPostStats() error { return err } -func (s *TimerLayerPostStore) RestoreContentFlaggedPost(post *model.Post, deletedBy string, statusFieldId string) error { +func (s *TimerLayerPostStore) RestoreContentFlaggedPost(post *model.Post, statusFieldId string, contentFlaggingManagedFieldId string) error { start := time.Now() - err := s.PostStore.RestoreContentFlaggedPost(post, deletedBy, statusFieldId) + err := s.PostStore.RestoreContentFlaggedPost(post, statusFieldId, contentFlaggingManagedFieldId) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/server/i18n/en.json b/server/i18n/en.json index f928b572471..29c9fad658b 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2152,10 +2152,18 @@ "id": "api.file.file_size.app_error", "translation": "Unable to get the file size." }, + { + "id": "api.file.get_file.invalid_flagged_post.app_error", + "translation": "Mismatched flagged post ID specified." + }, { "id": "api.file.get_file.public_invalid.app_error", "translation": "The public link does not appear to be valid." }, + { + "id": "api.file.get_file_info.app_error", + "translation": "Failed to get file info." + }, { "id": "api.file.get_file_preview.no_preview.app_error", "translation": "File doesn't have a preview image." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 72b4482fe51..df47093805e 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -1960,7 +1960,7 @@ func (c *Client4) GetTeam(ctx context.Context, teamId, etag string) (*Team, *Res // GetTeamAsContentReviewer returns a team based on the provided team id string, fetching it as a Content Reviewer for a flagged post. func (c *Client4) GetTeamAsContentReviewer(ctx context.Context, teamId, etag, flaggedPostId string) (*Team, *Response, error) { values := url.Values{} - values.Set("as_content_reviewer", c.boolString(true)) + values.Set(AsContentReviewerParam, c.boolString(true)) values.Set("flagged_post_id", flaggedPostId) route := c.teamRoute(teamId) + "?" + values.Encode() @@ -2644,7 +2644,7 @@ func (c *Client4) GetChannel(ctx context.Context, channelId, etag string) (*Chan // GetChannelAsContentReviewer returns a channel based on the provided channel id string, fetching it as a Content Reviewer for a flagged post. func (c *Client4) GetChannelAsContentReviewer(ctx context.Context, channelId, etag, flaggedPostId string) (*Channel, *Response, error) { values := url.Values{} - values.Set("as_content_reviewer", c.boolString(true)) + values.Set(AsContentReviewerParam, c.boolString(true)) values.Set("flagged_post_id", flaggedPostId) route := c.channelRoute(channelId) + "?" + values.Encode() @@ -3800,6 +3800,19 @@ func (c *Client4) GetFile(ctx context.Context, fileId string) ([]byte, *Response return ReadBytesFromResponse(r) } +func (c *Client4) GetFileAsContentReviewer(ctx context.Context, fileId, flaggedPostId string) ([]byte, *Response, error) { + values := url.Values{} + values.Set(AsContentReviewerParam, c.boolString(true)) + values.Set("flagged_post_id", flaggedPostId) + + r, err := c.DoAPIGet(ctx, c.fileRoute(fileId)+"?"+values.Encode(), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return ReadBytesFromResponse(r) +} + // DownloadFile gets the bytes for a file by id, optionally adding headers to force the browser to download it. func (c *Client4) DownloadFile(ctx context.Context, fileId string, download bool) ([]byte, *Response, error) { values := url.Values{} diff --git a/server/public/model/content_flagging.go b/server/public/model/content_flagging.go index 79da2741cf9..e93a0b8f379 100644 --- a/server/public/model/content_flagging.go +++ b/server/public/model/content_flagging.go @@ -15,6 +15,8 @@ const ( ContentFlaggingBotUsername = "content-review" commentMaxRunes = 1000 + + AsContentReviewerParam = "as_content_reviewer" ) const ( diff --git a/webapp/channels/src/components/file_attachment/file_attachment.tsx b/webapp/channels/src/components/file_attachment/file_attachment.tsx index b0e43f67541..27a5a188a87 100644 --- a/webapp/channels/src/components/file_attachment/file_attachment.tsx +++ b/webapp/channels/src/components/file_attachment/file_attachment.tsx @@ -55,6 +55,7 @@ type Props = PropsFromRedux & { handleFileDropdownOpened?: (open: boolean) => void; disableThumbnail?: boolean; disableActions?: boolean; + overrideGenerateFileDownloadUrl?: (fileId: string) => string; }; export default function FileAttachment(props: Props) { @@ -345,6 +346,7 @@ export default function FileAttachment(props: Props) { canDownload={props.canDownloadFiles} handleImageClick={onAttachmentClick} iconClass={'post-image__download'} + overrideGenerateFileDownloadUrl={props.overrideGenerateFileDownloadUrl} > diff --git a/webapp/channels/src/components/file_attachment/filename_overlay.tsx b/webapp/channels/src/components/file_attachment/filename_overlay.tsx index c89efbea1cf..1ccf10b2778 100644 --- a/webapp/channels/src/components/file_attachment/filename_overlay.tsx +++ b/webapp/channels/src/components/file_attachment/filename_overlay.tsx @@ -46,6 +46,8 @@ type Props = { * Optional class like for icon */ iconClass?: string; + + overrideGenerateFileDownloadUrl?: (fileId: string) => string; } export default class FilenameOverlay extends React.PureComponent { @@ -57,6 +59,7 @@ export default class FilenameOverlay extends React.PureComponent { fileInfo, handleImageClick, iconClass, + overrideGenerateFileDownloadUrl, } = this.props; const fileName = fileInfo.name; @@ -86,7 +89,7 @@ export default class FilenameOverlay extends React.PureComponent { title={defineMessage({id: 'view_image_popover.download', defaultMessage: 'Download'})} > , ); } diff --git a/webapp/channels/src/components/file_attachment_list/index.ts b/webapp/channels/src/components/file_attachment_list/index.ts index a170e4db223..20b97345a94 100644 --- a/webapp/channels/src/components/file_attachment_list/index.ts +++ b/webapp/channels/src/components/file_attachment_list/index.ts @@ -32,6 +32,7 @@ export type OwnProps = { disableDownload?: boolean; disableActions?: boolean; usePostAsSource?: boolean; + overrideGenerateFileDownloadUrl?: (fileId: string) => string; } function makeMapStateToProps() { diff --git a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx index dca01921a72..4f8809e7e70 100644 --- a/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx +++ b/webapp/channels/src/components/post_view/data_spillage_report/data_spillage_report.tsx @@ -11,6 +11,7 @@ import type {Post} from '@mattermost/types/posts'; import type {NameMappedPropertyFields, PropertyValue} from '@mattermost/types/properties'; import {Client4} from 'mattermost-redux/client'; +import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils'; import AtMention from 'components/at_mention'; import {useContentFlaggingFields, usePostContentFlaggingValues} from 'components/common/hooks/useContentFlaggingFields'; @@ -144,6 +145,7 @@ export function DataSpillageReport({post, isRHS}: Props) { fetchDeletedPost: true, getChannel: getChannel(reportedPostId), getTeam: getTeam(reportedPostId), + generateFileDownloadUrl: generateFileDownloadUrl(reportedPostId), }, reporting_comment: { placeholder: formatMessage({id: 'data_spillage_report_post.reporting_comment.placeholder', defaultMessage: 'No comment'}), @@ -244,3 +246,7 @@ function getTeam(flaggedPostId: string) { return Client4.getTeam(teamId, true, flaggedPostId); }; } + +function generateFileDownloadUrl(flaggedPostId: string) { + return (fileId: string) => getFileDownloadUrl(fileId, true, flaggedPostId); +} diff --git a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx index 66c946fef29..d33b45c0309 100644 --- a/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx +++ b/webapp/channels/src/components/post_view/post_message_preview/post_message_preview.tsx @@ -35,13 +35,15 @@ export type Props = OwnProps & { compactDisplay: boolean; isPostPriorityEnabled: boolean; handleFileDropdownOpened?: (open: boolean) => void; + overrideGenerateFileDownloadUrl?: (fileId: string) => string; + disableActions?: boolean; actions: { toggleEmbedVisibility: (id: string) => void; }; }; const PostMessagePreview = (props: Props) => { - const {currentTeamUrl, channelDisplayName, user, previewPost, metadata, isEmbedVisible, compactDisplay, preventClickAction, previewFooterMessage, handleFileDropdownOpened, isPostPriorityEnabled} = props; + const {currentTeamUrl, channelDisplayName, user, previewPost, metadata, isEmbedVisible, compactDisplay, preventClickAction, previewFooterMessage, handleFileDropdownOpened, isPostPriorityEnabled, overrideGenerateFileDownloadUrl, disableActions} = props; const toggleEmbedVisibility = () => { if (previewPost) { @@ -63,6 +65,8 @@ const PostMessagePreview = (props: Props) => { isInPermalink={true} handleFileDropdownOpened={handleFileDropdownOpened} usePostAsSource={props.usePostAsSource} + overrideGenerateFileDownloadUrl={overrideGenerateFileDownloadUrl} + disableActions={disableActions} /> ); } diff --git a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx index a67210fe3ef..497fe0a8970 100644 --- a/webapp/channels/src/components/properties_card_view/properties_card_view.tsx +++ b/webapp/channels/src/components/properties_card_view/properties_card_view.tsx @@ -23,6 +23,7 @@ export type PostPreviewFieldMetadata = { fetchDeletedPost?: boolean; getChannel?: (channelId: string) => Promise; getTeam?: (teamId: string) => Promise; + generateFileDownloadUrl?: (fileId: string) => string; }; export type UserPropertyMetadata = { diff --git a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx index c733d78ee54..17f39772ceb 100644 --- a/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx +++ b/webapp/channels/src/components/properties_card_view/propertyValueRenderer/post_preview_property_renderer/post_preview_property_renderer.tsx @@ -61,6 +61,8 @@ export default function PostPreviewPropertyRenderer({value, metadata}: Props) { preventClickAction={true} previewFooterMessage={postPreviewFooterMessage} usePostAsSource={true} + overrideGenerateFileDownloadUrl={metadata?.generateFileDownloadUrl} + disableActions={true} /> ); diff --git a/webapp/channels/src/packages/mattermost-redux/src/utils/file_utils.ts b/webapp/channels/src/packages/mattermost-redux/src/utils/file_utils.ts index 06aaa95699d..d4dc36ed059 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/utils/file_utils.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/utils/file_utils.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {buildQueryString} from '@mattermost/client/lib/helpers'; import type {FileInfo} from '@mattermost/types/files'; import {Client4} from 'mattermost-redux/client'; @@ -55,8 +56,20 @@ export function getFileUrl(fileId: string): string { return Client4.getFileRoute(fileId); } -export function getFileDownloadUrl(fileId: string): string { - return `${Client4.getFileRoute(fileId)}?download=1`; +export function getFileDownloadUrl(fileId: string, asContentReviewer?: boolean, flaggedPostId?: string): string { + const queryParamsArgs: Record = {}; + queryParamsArgs.download = 1; + + if (asContentReviewer) { + queryParamsArgs.as_content_reviewer = true; + } + + if (flaggedPostId) { + queryParamsArgs.flagged_post_id = flaggedPostId; + } + + const queryParams = buildQueryString(queryParamsArgs); + return `${Client4.getFileRoute(fileId)}${queryParams}`; } export function getFileThumbnailUrl(fileId: string): string {