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 <build@mattermost.com>
This commit is contained in:
Harshil Sharma 2025-11-19 09:52:07 +01:00 committed by GitHub
parent 59b3b5797d
commit e8406345a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 227 additions and 48 deletions

View file

@ -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 {

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}

View file

@ -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 {

View file

@ -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."

View file

@ -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{}

View file

@ -15,6 +15,8 @@ const (
ContentFlaggingBotUsername = "content-review"
commentMaxRunes = 1000
AsContentReviewerParam = "as_content_reviewer"
)
const (

View file

@ -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}
>
<i className='icon icon-download-outline'/>
</FilenameOverlay>

View file

@ -46,6 +46,8 @@ type Props = {
* Optional class like for icon
*/
iconClass?: string;
overrideGenerateFileDownloadUrl?: (fileId: string) => string;
}
export default class FilenameOverlay extends React.PureComponent<Props> {
@ -57,6 +59,7 @@ export default class FilenameOverlay extends React.PureComponent<Props> {
fileInfo,
handleImageClick,
iconClass,
overrideGenerateFileDownloadUrl,
} = this.props;
const fileName = fileInfo.name;
@ -86,7 +89,7 @@ export default class FilenameOverlay extends React.PureComponent<Props> {
title={defineMessage({id: 'view_image_popover.download', defaultMessage: 'Download'})}
>
<ExternalLink
href={getFileDownloadUrl(fileInfo.id)}
href={(overrideGenerateFileDownloadUrl || getFileDownloadUrl)(fileInfo.id)}
aria-label={localizeMessage({id: 'view_image_popover.download', defaultMessage: 'Download'}).toLowerCase()}
className='btn btn-icon btn-sm'
download={fileName}

View file

@ -82,6 +82,7 @@ export default function FileAttachmentList(props: Props) {
disableActions={props.disableActions}
disableThumbnail={isDeleted}
disablePreview={isDeleted}
overrideGenerateFileDownloadUrl={props.overrideGenerateFileDownloadUrl}
/>,
);
}

View file

@ -32,6 +32,7 @@ export type OwnProps = {
disableDownload?: boolean;
disableActions?: boolean;
usePostAsSource?: boolean;
overrideGenerateFileDownloadUrl?: (fileId: string) => string;
}
function makeMapStateToProps() {

View file

@ -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);
}

View file

@ -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}
/>
);
}

View file

@ -23,6 +23,7 @@ export type PostPreviewFieldMetadata = {
fetchDeletedPost?: boolean;
getChannel?: (channelId: string) => Promise<Channel>;
getTeam?: (teamId: string) => Promise<Team>;
generateFileDownloadUrl?: (fileId: string) => string;
};
export type UserPropertyMetadata = {

View file

@ -61,6 +61,8 @@ export default function PostPreviewPropertyRenderer({value, metadata}: Props) {
preventClickAction={true}
previewFooterMessage={postPreviewFooterMessage}
usePostAsSource={true}
overrideGenerateFileDownloadUrl={metadata?.generateFileDownloadUrl}
disableActions={true}
/>
</div>
);

View file

@ -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<string, any> = {};
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 {