Data spillage report generation (#36339)

* Added base fr report generation

* WIP

* Refactoring and cleanup

* lint fixes, added new tests

* test fix

* Several improvements

* Addressed some security enhancements

* Created zip writer entery later

* Improved a test to check for file content

* Improved error handling

* Made a geneeric function

* accepting comment in report API

* Removed an unnecessary check

* Made a geneeric function

* Made the comment body not required and updated API docs
This commit is contained in:
Harshil Sharma
2026-05-08 09:27:13 -04:00
committed by GitHub
parent e7c517bc98
commit 56089922e3
27 changed files with 1459 additions and 84 deletions
+50
View File
@@ -391,3 +391,53 @@
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
/api/v4/content_flagging/post/{post_id}/report:
post:
summary: Generate and download a flagged post report
description: |
Generates a ZIP archive containing the flagged post, its edit history, content review details, post metadata, and any associated file attachments, then streams the archive back to the caller as a download. All other content reviewers of the post's team are notified that a report has been generated.
The user must be a content reviewer of the team to which the post belongs to, and the post must be flagged.
An enterprise advanced license is required.
tags:
- Content Flagging
parameters:
- in: path
name: post_id
required: true
schema:
type: string
description: The ID of the flagged post to generate the report for
operationId: GenerateCFPostReport
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
comment:
type: string
description: Optional comment from the reviewer to be included in the generated report.
responses:
'200':
description: Report generated successfully. The response body is a ZIP archive containing the flagged post report.
headers:
Content-Disposition:
schema:
type: string
description: Specifies the suggested filename for the downloaded archive (e.g. `attachment; filename="flagged-post-{post_id}-{timestamp}.zip"`).
content:
application/zip:
schema:
type: string
format: binary
'400':
description: Bad request - Invalid post ID.
'403':
description: Forbidden - User does not have permission to access this post, or is not a reviewer of the post's team.
'404':
description: Post not found or post is not flagged.
'500':
description: Internal server error.
'501':
description: Feature is disabled either via config or an Enterprise Advanced license is not available.
+1
View File
@@ -28,6 +28,7 @@ func (api *API) InitContentFlagging() {
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(getFlaggedPost))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(contentFlaggingRequired(removeFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(contentFlaggingRequired(keepFlaggedPost))).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/report", api.APISessionRequired(contentFlaggingRequired(generateFlaggedPostReport))).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(contentFlaggingRequired(searchReviewers))).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(contentFlaggingRequired(assignFlaggedPostReviewer))).Methods(http.MethodPost)
@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func generateFlaggedPostReport(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
var actionRequest model.FlagContentActionRequest
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil && !errors.Is(err, io.EOF) {
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGenerateFlaggedPostReport, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "flaggedPostId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
model.AddEventParameterToAuditRec(auditRec, "comment", actionRequest.Comment)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
// This validates that the post is flagged
requireFlaggedPost(c, postId)
if c.Err != nil {
return
}
reportPath, appErr := c.App.GenerateFlaggedPostReport(c.AppContext, postId, userId, actionRequest.Comment)
if appErr != nil {
c.Err = appErr
return
}
defer func() {
if err := os.Remove(reportPath); err != nil && !os.IsNotExist(err) {
c.Logger.Warn("Failed to remove flagged post report temp file", mlog.String("path", reportPath), mlog.Err(err))
}
}()
f, err := os.Open(reportPath)
if err != nil {
c.Err = model.NewAppError("generateFlaggedPostReport", "api.data_spillage.report.open.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
c.Err = model.NewAppError("generateFlaggedPostReport", "api.data_spillage.report.stat.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// Notify all team reviewers that a report has been generated. Best-effort:
// must run before http.ServeContent (which writes the response and may block).
c.App.NotifyReviewersOfFlaggedPostReportGeneration(c.AppContext, postId, userId)
filename := fmt.Sprintf("flagged-post-%s-%d.zip", postId, model.GetMillis())
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
http.ServeContent(w, r, filename, stat.ModTime(), f)
auditRec.Success()
}
@@ -0,0 +1,165 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"archive/zip"
"bytes"
"context"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestGenerateFlaggedPostReport(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense(t)
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 400 when post ID is invalid", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), "invalid", &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
appErr := setNonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{})
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Empty(t, report)
})
t.Run("Should successfully generate report for a flagged post", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
entries := map[string]bool{}
for _, f := range zr.File {
entries[f.Name] = true
}
require.Contains(t, entries, "report_metadata.yaml")
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
})
t.Run("Should successfully generate report when user is a team reviewer", func(t *testing.T) {
appErr := setBasicTeamReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
})
t.Run("Should include file attachments in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post, fileInfo := uploadFileAndCreatePost(t, th, client)
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundAttachment bool
for _, f := range zr.File {
if f.Name == "post/attachments/"+fileInfo.Id+"_"+fileInfo.Name {
foundAttachment = true
break
}
}
require.True(t, foundAttachment, "attachment for the flagged post should be present in the report archive")
})
t.Run("Should include edit history entries in the generated report", func(t *testing.T) {
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t)
post.Message = "Updated message to create edit history"
_, _, err := client.UpdatePost(context.Background(), post.Id, post)
require.NoError(t, err)
editHistory, appErr := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editId := editHistory[0].Id
flagPostViaAPI(t, client, post.Id)
report, resp, err := client.GenerateFlaggedPostReport(context.Background(), post.Id, &model.FlagContentActionRequest{Comment: "investigation note"})
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, report)
zr, err := zip.NewReader(bytes.NewReader(report), int64(len(report)))
require.NoError(t, err)
var foundEdit bool
for _, f := range zr.File {
if f.Name == "edit_history/"+editId+"/post.yaml" {
foundEdit = true
break
}
}
require.True(t, foundEdit, "edit history entry should be present in the report archive")
})
}
+1 -1
View File
@@ -528,7 +528,7 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "force_download", forceDownload)
fileInfos, storeErr := c.App.Srv().Store().FileInfo().GetByIds([]string{c.Params.FileId}, true, true)
fileInfos, storeErr := c.App.Srv().Store().FileInfo().GetByIds([]string{c.Params.FileId}, true, true, false)
if storeErr != nil {
c.Err = model.NewAppError("getFile", "api.file.get_file_info.app_error", nil, "", http.StatusInternalServerError)
setInaccessibleFileHeader(w, c.Err)
+28 -14
View File
@@ -611,6 +611,11 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
return appErr
}
existingComment, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, contentFlaggingPropertyNameActorComment)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
@@ -619,13 +624,6 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
@@ -634,6 +632,15 @@ func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
if existingComment == nil {
propertyValues = append(propertyValues, &model.PropertyValue{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
})
}
_, appErr = a.CreatePropertyValues(rctx, propertyValues)
if appErr != nil {
@@ -911,6 +918,11 @@ func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
existingComment, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, contentFlaggingPropertyNameActorComment)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
@@ -919,13 +931,6 @@ func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContent
FieldID: mappedFields[contentFlaggingPropertyNameActorUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
},
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
@@ -934,6 +939,15 @@ func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContent
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
if existingComment == nil {
propertyValues = append(propertyValues, &model.PropertyValue{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: commentJsonValue,
})
}
_, appErr = a.CreatePropertyValues(nil, propertyValues)
if appErr != nil {
@@ -0,0 +1,462 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
"github.com/goccy/go-yaml"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
flaggedPostReportPostDir = "post"
flaggedPostReportEditHistoryDir = "edit_history"
flaggedPostReportAttachmentsDir = "attachments"
flaggedPostReportPostYAMLFile = "post.yaml"
flaggedPostReportContentReviewFile = "content_review.yaml"
flaggedPostReportMetadataFile = "report_metadata.yaml"
flaggedPostReportTempPattern = "mm-flag-report-*.zip"
)
// GenerateFlaggedPostReport builds a ZIP archive of a flagged post's data into a
// temporary file and returns the file path. The caller is responsible for
// removing the file when the response has been served.
func (a *App) GenerateFlaggedPostReport(rctx request.CTX, postID, generatedByUserID, comment string) (string, *model.AppError) {
if appErr := a.ensureActorCommentForReport(rctx, postID, comment); appErr != nil {
return "", appErr
}
tmp, err := os.CreateTemp("", flaggedPostReportTempPattern)
if err != nil {
return "", model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.tempfile.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
zw := zip.NewWriter(tmp)
if appErr := a.writeFlaggedPostReport(rctx, zw, postID, generatedByUserID); appErr != nil {
_ = zw.Close()
cleanup()
return "", appErr
}
if err := zw.Close(); err != nil {
cleanup()
return "", model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_close.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return "", model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.sync.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.close.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return tmpPath, nil
}
func (a *App) writeFlaggedPostReport(rctx request.CTX, zw *zip.Writer, postID, generatedByUserID string) *model.AppError {
rc, appErr := a.loadFlaggedPostReportContext(rctx, postID)
if appErr != nil {
return appErr
}
// Track FileInfo.Id seen anywhere in the archive so each unique attachment is
// included exactly once across the base post and all edit history entries.
seenFiles := map[string]bool{}
if appErr := a.writeBasePostSection(rctx, zw, rc, seenFiles); appErr != nil {
return appErr
}
if appErr := a.writeEditHistorySection(rctx, zw, rc, seenFiles); appErr != nil {
return appErr
}
if appErr := a.writeContentReviewEntry(rctx, zw, rc.Post); appErr != nil {
return appErr
}
if appErr := a.writeReportMetadataEntry(zw, generatedByUserID); appErr != nil {
return appErr
}
return nil
}
func (a *App) loadFlaggedPostReportContext(rctx request.CTX, postID string) (*model.FlaggedPostReportContext, *model.AppError) {
post, appErr := a.GetSinglePost(rctx, postID, true)
if appErr != nil {
return nil, appErr
}
channel, appErr := a.GetChannel(rctx, post.ChannelId)
if appErr != nil {
return nil, appErr
}
var team *model.Team
if channel.TeamId != "" {
team, appErr = a.GetTeam(channel.TeamId)
if appErr != nil {
return nil, appErr
}
}
author, appErr := a.GetUser(post.UserId)
if appErr != nil {
return nil, appErr
}
// GetEditHistoryForPost returns a 404 AppError when the post has no edit
// history rows. That is the normal case for an unedited post, so treat it
// as an empty history rather than failing the whole report.
editHistory, appErr := a.GetEditHistoryForPost(postID)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return nil, appErr
}
return &model.FlaggedPostReportContext{
Post: post,
Channel: channel,
Team: team,
Author: author,
EditHistory: editHistory,
}, nil
}
func (a *App) writeBasePostSection(rctx request.CTX, zw *zip.Writer, rc *model.FlaggedPostReportContext, seen map[string]bool) *model.AppError {
editOrder := make([]string, 0, len(rc.EditHistory))
for _, e := range rc.EditHistory {
editOrder = append(editOrder, e.Id)
}
yamlPayload := buildPostYAML(rc.Post, rc.Channel, rc.Team, rc.Author, editOrder)
postYAMLPath := path.Join(flaggedPostReportPostDir, flaggedPostReportPostYAMLFile)
if err := writeYAMLEntry(zw, postYAMLPath, yamlPayload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_post_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
baseFiles, _, appErr := a.GetFileInfosForPost(rctx, rc.Post, false, true)
if appErr != nil {
return appErr
}
attachmentsDir := path.Join(flaggedPostReportPostDir, flaggedPostReportAttachmentsDir)
return a.writeAttachments(rctx, zw, attachmentsDir, baseFiles, seen)
}
func (a *App) writeEditHistorySection(rctx request.CTX, zw *zip.Writer, rc *model.FlaggedPostReportContext, seen map[string]bool) *model.AppError {
for _, edit := range rc.EditHistory {
yamlPayload := buildPostYAML(edit, rc.Channel, rc.Team, rc.Author, nil)
entryPath := path.Join(flaggedPostReportEditHistoryDir, edit.Id, flaggedPostReportPostYAMLFile)
if err := writeYAMLEntry(zw, entryPath, yamlPayload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_edit_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// FileInfos for an edit-history Post are populated on Post.Metadata.Files
// by populateEditHistoryFileMetadata. See app.GetEditHistoryForPost.
var editFiles []*model.FileInfo
if edit.Metadata != nil {
editFiles = edit.Metadata.Files
}
dir := path.Join(flaggedPostReportEditHistoryDir, edit.Id, flaggedPostReportAttachmentsDir)
if appErr := a.writeAttachments(rctx, zw, dir, editFiles, seen); appErr != nil {
return appErr
}
}
return nil
}
func (a *App) writeContentReviewEntry(rctx request.CTX, zw *zip.Writer, post *model.Post) *model.AppError {
payload, appErr := a.buildContentReviewYAML(rctx, post)
if appErr != nil {
return appErr
}
if err := writeYAMLEntry(zw, flaggedPostReportContentReviewFile, payload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_review_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// ensureActorCommentForReport persists the report-generator's comment as the
// actor_comment property when the post does not yet have one. If a value is
// already present (set by a prior keep/remove or report-generation), it is
// preserved so the existing reviewer note is never overwritten.
func (a *App) ensureActorCommentForReport(rctx request.CTX, postID, comment string) *model.AppError {
existing, appErr := a.GetPostContentFlaggingPropertyValue(postID, contentFlaggingPropertyNameActorComment)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
if existing != nil {
return nil
}
if comment == "" {
return nil
}
groupID, gErr := a.ContentFlaggingGroupId()
if gErr != nil {
return gErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupID)
if appErr != nil {
return appErr
}
commentBytes, jsonErr := json.Marshal(comment)
if jsonErr != nil {
return model.NewAppError("ensureActorCommentForReport", "app.data_spillage.report.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
propertyValues := []*model.PropertyValue{
{
TargetID: postID,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupID,
FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID,
Value: json.RawMessage(commentBytes),
},
}
if _, appErr := a.CreatePropertyValues(rctx, propertyValues); appErr != nil {
return model.NewAppError("ensureActorCommentForReport", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
return nil
}
func (a *App) writeReportMetadataEntry(zw *zip.Writer, generatedByUserID string) *model.AppError {
generator, appErr := a.GetUser(generatedByUserID)
if appErr != nil {
return appErr
}
payload := model.FlaggedPostReportMetadata{
GeneratedByUserID: generator.Id,
GeneratedByUsername: generator.Username,
Timestamp: model.GetMillis(),
ReportVersion: model.FlaggedPostReportVersion,
}
if err := writeYAMLEntry(zw, flaggedPostReportMetadataFile, payload); err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.write_metadata_yaml.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func buildPostYAML(post *model.Post, channel *model.Channel, team *model.Team, author *model.User, editHistoryOrder []string) model.FlaggedPostReportPost {
out := model.FlaggedPostReportPost{
Post: post,
ChannelDisplayName: channel.DisplayName,
EditHistoryOrder: editHistoryOrder,
}
if author != nil {
out.AuthorName = author.Username
out.AuthorEmail = author.Email
}
if team != nil {
out.TeamID = team.Id
out.TeamDisplayName = team.DisplayName
}
if post.RootId == "" {
replyCount := post.ReplyCount
out.ReplyCountPtr = &replyCount
}
return out
}
func (a *App) buildContentReviewYAML(rctx request.CTX, post *model.Post) (model.FlaggedPostReportContentReview, *model.AppError) {
out := model.FlaggedPostReportContentReview{}
values, appErr := a.GetPostContentFlaggingPropertyValues(post.Id)
if appErr != nil {
return out, appErr
}
groupID, gErr := a.ContentFlaggingGroupId()
if gErr != nil {
return out, gErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupID)
if appErr != nil {
return out, appErr
}
// Index field ID -> field name so we can resolve property values by name.
fieldIDToName := make(map[string]string, len(mappedFields))
for name, f := range mappedFields {
fieldIDToName[f.ID] = name
}
byName := make(map[string]json.RawMessage, len(values))
for _, v := range values {
name, ok := fieldIDToName[v.FieldID]
if !ok {
continue
}
byName[name] = v.Value
}
out.ReporterUserID = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingUserID)
out.ReporterReason = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingReason)
out.ReporterComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameReportingComment)
out.ReportTimestamp = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameReportingTime)
contentFlaggingManaged, appErr := a.GetPostContentFlaggingPropertyValue(post.Id, contentFlaggingPropertyManageByContentFlagging)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return out, appErr
}
postHiddenByContentFlagging := contentFlaggingManaged != nil && string(contentFlaggingManaged.Value) == "true"
out.Hidden = postHiddenByContentFlagging
if reporterID := out.ReporterUserID; reporterID != "" {
if u, uErr := a.GetUser(reporterID); uErr == nil {
out.ReporterUsername = u.Username
} else {
rctx.Logger().Warn("Failed to fetch reporter user for flagged post report", mlog.String("user_id", reporterID), mlog.Err(uErr))
}
}
// Reviewer details: prefer the actor (the one who took the keep/remove action)
// when present, otherwise fall back to the assigned reviewer.
reviewerID := decodePropertyString(rctx, byName, contentFlaggingPropertyNameReviewerUserID)
out.ReviewerUserID = reviewerID
out.ReviewerComment = decodePropertyString(rctx, byName, contentFlaggingPropertyNameActorComment)
out.ActionTime = decodePropertyInt64(rctx, byName, contentFlaggingPropertyNameActionTime)
if reviewerID != "" {
if u, uErr := a.GetUser(reviewerID); uErr == nil {
out.ReviewerUsername = u.Username
} else {
rctx.Logger().Warn("Failed to fetch reviewer user for flagged post report", mlog.String("user_id", reviewerID), mlog.Err(uErr))
}
}
return out, nil
}
func (a *App) writeAttachments(rctx request.CTX, zw *zip.Writer, dirPrefix string, files []*model.FileInfo, seen map[string]bool) *model.AppError {
for _, fi := range files {
if fi == nil || seen[fi.Id] {
continue
}
seen[fi.Id] = true
reader, appErr := a.FileReader(fi.Path)
if appErr != nil {
return appErr
}
entryName := path.Join(dirPrefix, attachmentEntryName(fi))
w, err := zw.Create(entryName)
if err != nil {
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_create.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if _, err := io.Copy(w, reader); err != nil {
_ = reader.Close()
return model.NewAppError("GenerateFlaggedPostReport", "app.data_spillage.report.zip_copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
_ = reader.Close()
}
return nil
}
// attachmentEntryName returns a zip-safe entry name for a FileInfo. We prefix the
// FileInfo.Id to guarantee uniqueness in case two attachments share the same Name,
// and strip path separators from the user-supplied Name to prevent path traversal.
func attachmentEntryName(fi *model.FileInfo) string {
name := strings.ReplaceAll(fi.Name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.TrimSpace(name)
if name == "" {
name = "attachment"
if fi.Extension != "" {
name += "." + fi.Extension
}
}
return fi.Id + "_" + name
}
func writeYAMLEntry(zw *zip.Writer, name string, payload any) error {
w, err := zw.Create(name)
if err != nil {
return err
}
b, err := yaml.Marshal(payload)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
// decodePropertyString returns the value for fieldName decoded as a JSON string.
// Property values are stored as json.RawMessage (e.g. `"hello \"world\""`); a
// naive Trim of quotes leaves backslash-escapes in place, so we round-trip
// through json.Unmarshal to get the cleartext value.
func decodePropertyString(rctx request.CTX, byName map[string]json.RawMessage, fieldName string) string {
raw, ok := byName[fieldName]
if !ok || len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
rctx.Logger().Warn("Failed to decode content flagging property string value", mlog.String("field", fieldName), mlog.Err(err))
return ""
}
return s
}
// decodePropertyInt64 returns the value for fieldName decoded as a JSON number.
// Some content flagging timestamps are stored as raw JSON numbers (e.g. `12345`).
func decodePropertyInt64(rctx request.CTX, byName map[string]json.RawMessage, fieldName string) int64 {
raw, ok := byName[fieldName]
if !ok || len(raw) == 0 {
return 0
}
var n int64
if err := json.Unmarshal(raw, &n); err != nil {
rctx.Logger().Warn("Failed to decode content flagging property int value", mlog.String("field", fieldName), mlog.Err(err))
return 0
}
return n
}
// NotifyReviewersOfFlaggedPostReportGeneration posts a notification reply on each
// reviewer's content review thread to record that a report was generated.
// Best-effort: errors are logged, never returned.
func (a *App) NotifyReviewersOfFlaggedPostReportGeneration(rctx request.CTX, flaggedPostID, generatedByUserID string) {
groupID, err := a.ContentFlaggingGroupId()
if err != nil {
rctx.Logger().Warn("Failed to get content flagging group id for report generation notification", mlog.Err(err))
return
}
generator, appErr := a.GetUser(generatedByUserID)
if appErr != nil {
rctx.Logger().Warn("Failed to fetch generating user for report generation notification", mlog.Err(appErr))
return
}
message := fmt.Sprintf("@%s generated a report for the quarantined message.", generator.Username)
if _, appErr := a.postReviewerMessage(rctx, message, groupID, flaggedPostID, nil, ""); appErr != nil {
rctx.Logger().Warn("Failed to post report generation notification to reviewers", mlog.String("flagged_post_id", flaggedPostID), mlog.Err(appErr))
}
}
@@ -0,0 +1,387 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"os"
"testing"
"github.com/goccy/go-yaml"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// readReportZip opens the ZIP archive at path and returns a map of entry name
// to contents. The temp file produced by GenerateFlaggedPostReport is also
// removed from disk after reading so individual tests don't have to clean up.
func readReportZip(t *testing.T, path string) map[string][]byte {
t.Helper()
defer func() {
_ = os.Remove(path)
}()
zr, err := zip.OpenReader(path)
require.NoError(t, err)
defer zr.Close()
out := map[string][]byte{}
for _, f := range zr.File {
rc, err := f.Open()
require.NoError(t, err)
b, err := io.ReadAll(rc)
require.NoError(t, err)
_ = rc.Close()
out[f.Name] = b
}
return out
}
func TestGenerateFlaggedPostReport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("returns error when post does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, model.NewId(), th.BasicUser.Id, "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
t.Run("returns error when generator user does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, model.NewId(), "")
require.NotNil(t, appErr)
require.Empty(t, path)
})
t.Run("produces a zip with the expected entries for a basic flagged post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
require.NotEmpty(t, path)
_, statErr := os.Stat(path)
require.NoError(t, statErr, "report temp file should exist on disk until caller removes it")
entries := readReportZip(t, path)
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
require.Contains(t, entries, "report_metadata.yaml")
})
t.Run("post.yaml contains channel, team, and author details", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var payload map[string]any
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &payload))
require.Equal(t, post.Id, payload["id"])
require.Equal(t, post.UserId, payload["author_id"])
require.Equal(t, th.BasicUser.Username, payload["author_name"])
require.Equal(t, th.BasicChannel.DisplayName, payload["channel_display_name"])
require.Equal(t, th.BasicTeam.Id, payload["team_id"])
require.Equal(t, th.BasicTeam.DisplayName, payload["team_display_name"])
})
t.Run("report_metadata.yaml records the generating user and a non-zero timestamp", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var meta model.FlaggedPostReportMetadata
require.NoError(t, yaml.Unmarshal(entries["report_metadata.yaml"], &meta))
require.Equal(t, th.BasicUser.Id, meta.GeneratedByUserID)
require.Equal(t, th.BasicUser.Username, meta.GeneratedByUsername)
require.Equal(t, model.FlaggedPostReportVersion, meta.ReportVersion)
require.Greater(t, meta.Timestamp, int64(0))
})
t.Run("content_review.yaml records reporter details", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
var review model.FlaggedPostReportContentReview
require.NoError(t, yaml.Unmarshal(entries["content_review.yaml"], &review))
// setupFlaggedPost flags as BasicUser2 with reason "spam".
require.Equal(t, th.BasicUser2.Id, review.ReporterUserID)
require.Equal(t, th.BasicUser2.Username, review.ReporterUsername)
require.Equal(t, "spam", review.ReporterReason)
require.Equal(t, "This is spam content", review.ReporterComment)
require.Greater(t, review.ReportTimestamp, int64(0))
})
t.Run("includes file attachments for the base post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t, th.BasicChannel)
attachmentBody := []byte("hello attachment body")
fileInfo := &model.FileInfo{
Id: model.NewId(),
PostId: post.Id,
CreatorId: post.UserId,
Path: "test/" + model.NewId() + "/attachment.txt",
Name: "attachment.txt",
Size: int64(len(attachmentBody)),
}
_, appErr = th.App.WriteFile(bytes.NewReader(attachmentBody), fileInfo.Path)
require.Nil(t, appErr)
t.Cleanup(func() { _ = th.App.RemoveFile(fileInfo.Path) })
_, err := th.App.Srv().Store().FileInfo().Save(th.Context, fileInfo)
require.NoError(t, err)
// Persist FileIds directly via the store. UpdatePost would route through
// AttachToPost, which is a no-op here because the FileInfo row already has
// PostId set, leaving post.FileIds empty in the DB.
post.FileIds = []string{fileInfo.Id}
_, err = th.App.Srv().Store().Post().Overwrite(th.Context, post)
require.NoError(t, err)
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
entryName := "post/attachments/" + fileInfo.Id + "_" + fileInfo.Name
require.Contains(t, entries, entryName)
require.Equal(t, attachmentBody, entries[entryName])
require.Contains(t, entries, "post/post.yaml")
require.Contains(t, entries, "content_review.yaml")
require.Contains(t, entries, "report_metadata.yaml")
})
t.Run("includes edit history entries when the post has been edited", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := th.CreatePost(t, th.BasicChannel)
edited := post.Clone()
edited.Message = "Edited message"
edited.EditAt = model.GetMillis()
_, _, appErr = th.App.UpdatePost(th.Context, edited, &model.UpdatePostOptions{})
require.Nil(t, appErr)
editHistory, appErr := th.App.GetEditHistoryForPost(post.Id)
require.Nil(t, appErr)
require.NotEmpty(t, editHistory)
editID := editHistory[0].Id
flagData := model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
appErr = th.App.FlagPost(th.Context, post, th.BasicTeam.Id, th.BasicUser2.Id, flagData)
require.Nil(t, appErr)
path, appErr := th.App.GenerateFlaggedPostReport(th.Context, post.Id, th.BasicUser.Id, "")
require.Nil(t, appErr)
entries := readReportZip(t, path)
require.Contains(t, entries, "edit_history/"+editID+"/post.yaml")
// The base post.yaml should also list the edit history order.
var basePayload map[string]any
require.NoError(t, yaml.Unmarshal(entries["post/post.yaml"], &basePayload))
order, ok := basePayload["edit_history_order"].([]any)
require.True(t, ok, "edit_history_order should be present on the base post payload")
require.Contains(t, order, editID)
})
}
func TestBuildPostYAML(t *testing.T) {
channel := &model.Channel{Id: "channel-id", DisplayName: "Channel Name"}
team := &model.Team{Id: "team-id", DisplayName: "Team Name"}
author := &model.User{Id: "author-id", Username: "alice", Email: "alice@example.com"}
t.Run("populates author, channel, and team fields", func(t *testing.T) {
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id, Message: "hi"}
got := buildPostYAML(post, channel, team, author, nil)
require.Equal(t, "alice", got.AuthorName)
require.Equal(t, "alice@example.com", got.AuthorEmail)
require.Equal(t, "Channel Name", got.ChannelDisplayName)
require.Equal(t, "team-id", got.TeamID)
require.Equal(t, "Team Name", got.TeamDisplayName)
})
t.Run("omits team fields for DM/GM posts", func(t *testing.T) {
post := &model.Post{Id: "post-id", UserId: author.Id, ChannelId: channel.Id}
got := buildPostYAML(post, channel, nil, author, nil)
require.Empty(t, got.TeamID)
require.Empty(t, got.TeamDisplayName)
})
t.Run("omits author fields when author is nil", func(t *testing.T) {
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
got := buildPostYAML(post, channel, team, nil, nil)
require.Empty(t, got.AuthorName)
require.Empty(t, got.AuthorEmail)
})
t.Run("populates reply count only for root posts", func(t *testing.T) {
root := &model.Post{Id: "root", ChannelId: channel.Id, ReplyCount: 3}
got := buildPostYAML(root, channel, team, author, nil)
require.NotNil(t, got.ReplyCountPtr)
require.Equal(t, int64(3), *got.ReplyCountPtr)
reply := &model.Post{Id: "reply", ChannelId: channel.Id, RootId: "root", ReplyCount: 0}
got = buildPostYAML(reply, channel, team, author, nil)
require.Nil(t, got.ReplyCountPtr)
})
t.Run("preserves edit history order verbatim", func(t *testing.T) {
post := &model.Post{Id: "post-id", ChannelId: channel.Id}
order := []string{"edit-3", "edit-2", "edit-1"}
got := buildPostYAML(post, channel, team, author, order)
require.Equal(t, order, got.EditHistoryOrder)
})
}
func TestAttachmentEntryName(t *testing.T) {
t.Run("prefixes the file id and keeps the original name", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "report.pdf"}
require.Equal(t, "abc123_report.pdf", attachmentEntryName(fi))
})
t.Run("strips forward and back slashes to prevent path traversal", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "../../etc/passwd"}
require.Equal(t, "abc123_.._.._etc_passwd", attachmentEntryName(fi))
fi = &model.FileInfo{Id: "abc123", Name: `..\..\windows\system32`}
require.Equal(t, "abc123_.._.._windows_system32", attachmentEntryName(fi))
})
t.Run("falls back to a synthesised name when the original is empty", func(t *testing.T) {
fi := &model.FileInfo{Id: "abc123", Name: "", Extension: "pdf"}
require.Equal(t, "abc123_attachment.pdf", attachmentEntryName(fi))
fi = &model.FileInfo{Id: "abc123", Name: " ", Extension: ""}
require.Equal(t, "abc123_attachment", attachmentEntryName(fi))
})
}
func TestDecodePropertyString(t *testing.T) {
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
t.Run("returns empty string when key is missing", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{}, "missing")
require.Empty(t, got)
})
t.Run("returns empty string for empty raw value", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
require.Empty(t, got)
})
t.Run("decodes a JSON-encoded string", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"hello \"world\""`)}, "k")
require.Equal(t, `hello "world"`, got)
})
t.Run("returns empty string when the raw value is not valid JSON", func(t *testing.T) {
got := decodePropertyString(rctx, map[string]json.RawMessage{"k": json.RawMessage("not-json")}, "k")
require.Empty(t, got)
})
}
func TestDecodePropertyInt64(t *testing.T) {
rctx := request.EmptyContext(mlog.CreateConsoleTestLogger(t))
t.Run("returns zero when key is missing", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{}, "missing")
require.Equal(t, int64(0), got)
})
t.Run("returns zero for empty raw value", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": []byte{}}, "k")
require.Equal(t, int64(0), got)
})
t.Run("decodes a JSON-encoded number", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage("12345")}, "k")
require.Equal(t, int64(12345), got)
})
t.Run("returns zero when the raw value is not a JSON number", func(t *testing.T) {
got := decodePropertyInt64(rctx, map[string]json.RawMessage{"k": json.RawMessage(`"12345"`)}, "k")
require.Equal(t, int64(0), got)
})
}
func TestNotifyReviewersOfFlaggedPostReportGeneration(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
t.Run("does not panic when called for a non-flagged post", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
// Use a random post id to exercise the best-effort error path. The function
// should swallow any errors and return without raising.
require.NotPanics(t, func() {
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, model.NewId(), th.BasicUser.Id)
})
})
t.Run("does not panic when generator user does not exist", func(t *testing.T) {
appErr := setBaseConfig(th)
require.Nil(t, appErr)
post := setupFlaggedPost(t, th)
require.NotPanics(t, func() {
th.App.NotifyReviewersOfFlaggedPostReportGeneration(th.Context, post.Id, model.NewId())
})
})
}
+2 -2
View File
@@ -2530,7 +2530,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) {
require.Equal(t, `"`+model.ContentFlaggingStatusRemoved+`"`, string(statusValue.Value))
// Verify file infos were also deleted
files, err := th.App.Srv().Store().FileInfo().GetByIds([]string{fileInfo1.Id, fileInfo2.Id}, true, false)
files, err := th.App.Srv().Store().FileInfo().GetByIds([]string{fileInfo1.Id, fileInfo2.Id}, true, false, false)
require.NoError(t, err)
require.Empty(t, files)
})
@@ -3094,7 +3094,7 @@ func TestKeepFlaggedPost(t *testing.T) {
require.Equal(t, `"`+model.ContentFlaggingStatusRetained+`"`, string(statusValue.Value))
// Verify file infos are still present (not deleted)
files, err := th.App.Srv().Store().FileInfo().GetByIds([]string{fileInfo1.Id, fileInfo2.Id}, false, false)
files, err := th.App.Srv().Store().FileInfo().GetByIds([]string{fileInfo1.Id, fileInfo2.Id}, false, false, false)
require.NoError(t, err)
require.Len(t, files, 2, "File attachments should be preserved when keeping flagged post")
})
+1 -1
View File
@@ -126,7 +126,7 @@ func (a *App) getFileInfosForDraft(rctx request.CTX, draft *model.Draft) ([]*mod
return nil, nil
}
allFileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds, false, true)
allFileInfos, err := a.Srv().Store().FileInfo().GetByIds(draft.FileIds, false, true, false)
if err != nil {
return nil, model.NewAppError("GetFileInfosForDraft", "app.draft.get_for_draft.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
+64 -31
View File
@@ -2253,50 +2253,53 @@ func (a *App) FilterPostsByChannelPermissions(rctx request.CTX, postList *model.
}
func (a *App) GetFileInfosForPostWithMigration(rctx request.CTX, postID string, includeDeleted bool) ([]*model.FileInfo, *model.AppError) {
pchan := make(chan store.StoreResult[*model.Post], 1)
go func() {
post, err := a.Srv().Store().Post().GetSingle(rctx, postID, includeDeleted)
pchan <- store.StoreResult[*model.Post]{Data: post, NErr: err}
close(pchan)
}()
infos, firstInaccessibleFileTime, err := a.GetFileInfosForPost(rctx, postID, false, includeDeleted)
post, err := a.Srv().Store().Post().GetSingle(rctx, postID, includeDeleted)
if err != nil {
return nil, err
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if len(infos) == 0 && firstInaccessibleFileTime == 0 {
// No FileInfos were returned so check if they need to be created for this post
result := <-pchan
if result.NErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(result.NErr, &nfErr):
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(result.NErr)
default:
return nil, model.NewAppError("GetFileInfosForPostWithMigration", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(result.NErr)
}
}
post := result.Data
infos, firstInaccessibleFileTime, appErr := a.GetFileInfosForPost(rctx, post, false, includeDeleted)
if appErr != nil {
return nil, appErr
}
if len(post.Filenames) > 0 {
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, false)
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, true)
// The post has Filenames that need to be replaced with FileInfos
infos = a.MigrateFilenamesToFileInfos(rctx, post)
}
if len(infos) == 0 && firstInaccessibleFileTime == 0 && len(post.Filenames) > 0 {
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, false)
a.Srv().Store().FileInfo().InvalidateFileInfosForPostCache(postID, true)
// The post has Filenames that need to be replaced with FileInfos
infos = a.MigrateFilenamesToFileInfos(rctx, post)
}
return infos, nil
}
// GetFileInfosForPost also returns firstInaccessibleFileTime based on cloud plan's limit.
func (a *App) GetFileInfosForPost(rctx request.CTX, postID string, fromMaster bool, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) {
fileInfos, err := a.Srv().Store().FileInfo().GetForPost(postID, fromMaster, includeDeleted, true)
func (a *App) GetFileInfosForPost(rctx request.CTX, post *model.Post, fromMaster bool, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) {
fileIDs := post.FileIds
if fromMaster {
masterPost, err := a.Srv().Store().Post().GetSingle(sqlstore.RequestContextWithMaster(rctx), post.Id, includeDeleted)
if err != nil {
return nil, 0, model.NewAppError("GetFileInfosForPost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
fileIDs = masterPost.FileIds
}
fileInfos, err := a.Srv().Store().FileInfo().GetByIds(fileIDs, includeDeleted, true, fromMaster)
if err != nil {
return nil, 0, model.NewAppError("GetFileInfosForPost", "app.file_info.get_for_post.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// GetByIds does not preserve the order of post.FileIds (the SQL store sorts
// by CreateAt DESC and the local cache layer interleaves cache hits), so
// reorder to match the post's stored attachment order.
fileInfos = orderFileInfosByID(post.FileIds, fileInfos)
firstInaccessibleFileTime, appErr := a.removeInaccessibleContentFromFilesSlice(fileInfos)
if appErr != nil {
return nil, 0, appErr
@@ -2307,6 +2310,34 @@ func (a *App) GetFileInfosForPost(rctx request.CTX, postID string, fromMaster bo
return fileInfos, firstInaccessibleFileTime, nil
}
func orderFileInfosByID(ids []string, infos []*model.FileInfo) []*model.FileInfo {
if len(ids) == 0 || len(infos) < 2 {
// No sorting needed for just one file info
return infos
}
byID := make(map[string]*model.FileInfo, len(infos))
for _, info := range infos {
byID[info.Id] = info
}
ordered := make([]*model.FileInfo, 0, len(infos))
for _, id := range ids {
if info, ok := byID[id]; ok {
ordered = append(ordered, info)
delete(byID, id)
}
}
for _, info := range infos {
if _, ok := byID[info.Id]; ok {
ordered = append(ordered, info)
}
}
return ordered
}
func (a *App) PostWithProxyAddedToImageURLs(post *model.Post) *model.Post {
if f := a.ImageProxyAdder(); f != nil {
return post.WithRewrittenImageURLs(f)
@@ -2669,11 +2700,13 @@ func (a *App) GetEditHistoryForPost(postID string) ([]*model.Post, *model.AppErr
func (a *App) populateEditHistoryFileMetadata(editHistoryPosts []*model.Post) *model.AppError {
for _, post := range editHistoryPosts {
fileInfos, err := a.Srv().Store().FileInfo().GetByIds(post.FileIds, true, true)
fileInfos, err := a.Srv().Store().FileInfo().GetByIds(post.FileIds, true, true, false)
if err != nil {
return model.NewAppError("app.populateEditHistoryFileMetadata", "app.file_info.get_by_ids.app_error", map[string]any{"post_id": post.Id}, "", http.StatusInternalServerError).Wrap(err)
}
fileInfos = orderFileInfosByID(post.FileIds, fileInfos)
if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
+1 -1
View File
@@ -519,7 +519,7 @@ func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMas
return nil, 0, nil
}
return a.GetFileInfosForPost(rctx, post.Id, fromMaster, includeDeleted)
return a.GetFileInfosForPost(rctx, post, fromMaster, includeDeleted)
}
func (a *App) getEmojisAndReactionsForPost(rctx request.CTX, post *model.Post) ([]*model.Emoji, []*model.Reaction, *model.AppError) {
+2 -2
View File
@@ -313,7 +313,7 @@ func TestAttachFilesToPost(t *testing.T) {
assert.Contains(t, attachedFiles, info1.Id)
assert.Contains(t, attachedFiles, info2.Id)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post.Id, false, false)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post, false, false)
assert.Nil(t, appErr)
assert.Len(t, infos, 2)
})
@@ -344,7 +344,7 @@ func TestAttachFilesToPost(t *testing.T) {
assert.Len(t, attachedFiles, 1)
assert.Contains(t, attachedFiles, info2.Id)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post.Id, false, false)
infos, _, appErr := th.App.GetFileInfosForPost(th.Context, post, false, false)
assert.Nil(t, appErr)
assert.Len(t, infos, 1)
assert.Equal(t, info2.Id, infos[0].Id)
@@ -51,9 +51,13 @@ func (s LocalCacheFileInfoStore) GetForPost(postId string, readFromMaster, inclu
return fileInfos, nil
}
func (s LocalCacheFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error) {
func (s LocalCacheFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache, readFromMaster bool) ([]*model.FileInfo, error) {
if readFromMaster {
return s.FileInfoStore.GetByIds(ids, includeDeleted, false, readFromMaster)
}
if !allowFromCache {
return s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache)
return s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache, readFromMaster)
}
var fileIdsToFetch []string
@@ -71,7 +75,7 @@ func (s LocalCacheFileInfoStore) GetByIds(ids []string, includeDeleted, allowFro
}
if len(fileIdsToFetch) > 0 {
fetchedFileInfos, err := s.FileInfoStore.GetByIds(fileIdsToFetch, includeDeleted, false)
fetchedFileInfos, err := s.FileInfoStore.GetByIds(fileIdsToFetch, includeDeleted, false, readFromMaster)
if err != nil {
return nil, err
}
@@ -20,7 +20,7 @@ func TestFileInfoStore(t *testing.T) {
}
func TestFileInfoStoreCache(t *testing.T) {
fakeFileInfo := model.FileInfo{PostId: "123"}
fakeFileInfo := model.FileInfo{Id: "123", PostId: "123"}
logger := mlog.CreateConsoleTestLogger(t)
t.Run("first call not cached, second cached and returning same data", func(t *testing.T) {
@@ -69,12 +69,14 @@ func TestFileInfoStoreCache(t *testing.T) {
cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider, logger)
require.NoError(t, err)
fileInfos, err := cachedStore.FileInfo().GetByIds([]string{"123"}, true, true)
fileInfos, err := cachedStore.FileInfo().GetByIds([]string{"123"}, true, true, false)
require.NoError(t, err)
assert.Equal(t, fileInfos, []*model.FileInfo{&fakeFileInfo})
assert.Equal(t, []*model.FileInfo{&fakeFileInfo}, fileInfos)
mockStore.FileInfo().(*mocks.FileInfoStore).AssertNumberOfCalls(t, "GetByIds", 1)
fileInfosCached, err := cachedStore.FileInfo().GetByIds([]string{"123"}, true, true, false)
require.NoError(t, err)
assert.Equal(t, []*model.FileInfo{&fakeFileInfo}, fileInfosCached)
mockStore.FileInfo().(*mocks.FileInfoStore).AssertNumberOfCalls(t, "GetByIds", 1)
assert.Equal(t, fileInfos, []*model.FileInfo{&fakeFileInfo})
cachedStore.FileInfo().GetForPost("123", true, true, true)
mockStore.FileInfo().(*mocks.FileInfoStore).AssertNumberOfCalls(t, "GetForPost", 1)
})
}
@@ -69,11 +69,11 @@ func getMockStore(t *testing.T) *mocks.Store {
mockSchemesStore.On("PermanentDeleteAll").Return(nil)
mockStore.On("Scheme").Return(&mockSchemesStore)
fakeFileInfo := model.FileInfo{PostId: "123"}
fakeFileInfo := model.FileInfo{Id: "123", PostId: "123"}
mockFileInfoStore := mocks.FileInfoStore{}
mockFileInfoStore.On("GetForPost", "123", true, true, false).Return([]*model.FileInfo{&fakeFileInfo}, nil)
mockFileInfoStore.On("GetForPost", "123", true, true, true).Return([]*model.FileInfo{&fakeFileInfo}, nil)
mockFileInfoStore.On("GetByIds", []string{"123"}, true, false).Return([]*model.FileInfo{&fakeFileInfo}, nil)
mockFileInfoStore.On("GetByIds", []string{"123"}, true, false, false).Return([]*model.FileInfo{&fakeFileInfo}, nil)
mockStore.On("FileInfo").Return(&mockFileInfoStore)
fakeWebhook := model.IncomingWebhook{Id: "123"}
@@ -5136,11 +5136,11 @@ func (s *RetryLayerFileInfoStore) Get(id string) (*model.FileInfo, error) {
}
func (s *RetryLayerFileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool) ([]*model.FileInfo, error) {
func (s *RetryLayerFileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool, readFromMaster bool) ([]*model.FileInfo, error) {
tries := 0
for {
result, err := s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache)
result, err := s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache, readFromMaster)
if err == nil {
return result, nil
}
@@ -200,7 +200,7 @@ func (s SearchFileInfoStore) Search(rctx request.CTX, paramsList []*model.Search
// Get the files
filesList := model.NewFileInfoList()
if len(fileIds) > 0 {
files, nErr := s.FileInfoStore.GetByIds(fileIds, false, true)
files, nErr := s.FileInfoStore.GetByIds(fileIds, false, true, false)
if nErr != nil {
return nil, nErr
}
@@ -132,7 +132,13 @@ func (fs SqlFileInfoStore) Save(rctx request.CTX, info *model.FileInfo) (*model.
return info, nil
}
func (fs SqlFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error) {
func (fs SqlFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache, readFromMaster bool) ([]*model.FileInfo, error) {
db := fs.GetReplica()
if readFromMaster {
db = fs.GetMaster()
}
query := fs.getQueryBuilder().
Select(fs.queryFields...).
From("FileInfo").
@@ -149,7 +155,7 @@ func (fs SqlFileInfoStore) GetByIds(ids []string, includeDeleted, allowFromCache
}
items := []fileInfoWithChannelID{}
if err := fs.GetReplica().Select(&items, queryString, args...); err != nil {
if err := db.Select(&items, queryString, args...); err != nil {
return nil, errors.Wrap(err, "failed to find FileInfos")
}
if len(items) == 0 {
+1 -1
View File
@@ -756,7 +756,7 @@ type FileInfoStore interface {
Upsert(rctx request.CTX, info *model.FileInfo) (*model.FileInfo, error)
Get(id string) (*model.FileInfo, error)
GetFromMaster(id string) (*model.FileInfo, error)
GetByIds(ids []string, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error)
GetByIds(ids []string, includeDeleted, allowFromCache, readFromMaster bool) ([]*model.FileInfo, error)
GetByPath(path string) (*model.FileInfo, error)
GetForPost(postID string, readFromMaster, includeDeleted, allowFromCache bool) ([]*model.FileInfo, error)
GetForUser(userID string) ([]*model.FileInfo, error)
@@ -984,7 +984,7 @@ func testGetByIds(t *testing.T, rctx request.CTX, ss store.Store) {
ss.FileInfo().PermanentDelete(rctx, info.Id)
}()
fileInfos, err := ss.FileInfo().GetByIds([]string{info.Id}, false, true)
fileInfos, err := ss.FileInfo().GetByIds([]string{info.Id}, false, true, false)
require.NoError(t, err)
require.Len(t, fileInfos, 1)
require.Equal(t, info.Id, fileInfos[0].Id)
@@ -1013,7 +1013,7 @@ func testGetByIds(t *testing.T, rctx request.CTX, ss store.Store) {
ss.FileInfo().PermanentDelete(rctx, info2.Id)
}()
fileInfos, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, false, true)
fileInfos, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, false, true, false)
require.NoError(t, err)
require.Len(t, fileInfos, 2)
require.Equal(t, info1.Id, fileInfos[1].Id)
@@ -1051,7 +1051,7 @@ func testGetByIds(t *testing.T, rctx request.CTX, ss store.Store) {
_, err = ss.FileInfo().DeleteForPost(rctx, postId)
require.NoError(t, err)
fileInfosIncludingDeleted, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, true, true)
fileInfosIncludingDeleted, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, true, true, false)
require.NoError(t, err)
require.Len(t, fileInfosIncludingDeleted, 2)
require.Equal(t, info2.Id, fileInfosIncludingDeleted[0].Id)
@@ -1060,7 +1060,7 @@ func testGetByIds(t *testing.T, rctx request.CTX, ss store.Store) {
require.Greater(t, fileInfosIncludingDeleted[1].DeleteAt, int64(0))
// verifying that the file infos are not returned when IncludeDeleted is false
fileInfosExcludingDeleted, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, false, true)
fileInfosExcludingDeleted, err := ss.FileInfo().GetByIds([]string{info1.Id, info2.Id}, false, true, false)
require.NoError(t, err)
require.Len(t, fileInfosExcludingDeleted, 0)
})
@@ -142,9 +142,9 @@ func (_m *FileInfoStore) Get(id string) (*model.FileInfo, error) {
return r0, r1
}
// GetByIds provides a mock function with given fields: ids, includeDeleted, allowFromCache
func (_m *FileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool) ([]*model.FileInfo, error) {
ret := _m.Called(ids, includeDeleted, allowFromCache)
// GetByIds provides a mock function with given fields: ids, includeDeleted, allowFromCache, readFromMaster
func (_m *FileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool, readFromMaster bool) ([]*model.FileInfo, error) {
ret := _m.Called(ids, includeDeleted, allowFromCache, readFromMaster)
if len(ret) == 0 {
panic("no return value specified for GetByIds")
@@ -152,19 +152,19 @@ func (_m *FileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCa
var r0 []*model.FileInfo
var r1 error
if rf, ok := ret.Get(0).(func([]string, bool, bool) ([]*model.FileInfo, error)); ok {
return rf(ids, includeDeleted, allowFromCache)
if rf, ok := ret.Get(0).(func([]string, bool, bool, bool) ([]*model.FileInfo, error)); ok {
return rf(ids, includeDeleted, allowFromCache, readFromMaster)
}
if rf, ok := ret.Get(0).(func([]string, bool, bool) []*model.FileInfo); ok {
r0 = rf(ids, includeDeleted, allowFromCache)
if rf, ok := ret.Get(0).(func([]string, bool, bool, bool) []*model.FileInfo); ok {
r0 = rf(ids, includeDeleted, allowFromCache, readFromMaster)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.FileInfo)
}
}
if rf, ok := ret.Get(1).(func([]string, bool, bool) error); ok {
r1 = rf(ids, includeDeleted, allowFromCache)
if rf, ok := ret.Get(1).(func([]string, bool, bool, bool) error); ok {
r1 = rf(ids, includeDeleted, allowFromCache, readFromMaster)
} else {
r1 = ret.Error(1)
}
@@ -4223,10 +4223,10 @@ func (s *TimerLayerFileInfoStore) Get(id string) (*model.FileInfo, error) {
return result, err
}
func (s *TimerLayerFileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool) ([]*model.FileInfo, error) {
func (s *TimerLayerFileInfoStore) GetByIds(ids []string, includeDeleted bool, allowFromCache bool, readFromMaster bool) ([]*model.FileInfo, error) {
start := time.Now()
result, err := s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache)
result, err := s.FileInfoStore.GetByIds(ids, includeDeleted, allowFromCache, readFromMaster)
elapsed := float64(time.Since(start)) / float64(time.Second)
if s.Root.Metrics != nil {
+52
View File
@@ -2101,6 +2101,14 @@
"id": "api.data_spillage.error.user_not_reviewer",
"translation": "The user is not a content reviewer."
},
{
"id": "api.data_spillage.report.open.app_error",
"translation": "Failed to open the generated flagged post report file."
},
{
"id": "api.data_spillage.report.stat.app_error",
"translation": "Failed to read the flagged post report file information."
},
{
"id": "api.draft.create_draft.can_not_draft_to_deleted.error",
"translation": "Can not save draft to deleted channel"
@@ -6054,6 +6062,10 @@
"id": "app.data_spillage.report.cleared",
"translation": "Cleared"
},
{
"id": "app.data_spillage.report.close.app_error",
"translation": "Failed to close the flagged post report file."
},
{
"id": "app.data_spillage.report.column.detail",
"translation": "Detail"
@@ -6124,6 +6136,10 @@
"id": "app.data_spillage.report.incomplete_warning",
"translation": "Post deletion incomplete. Review the Error Log and escalate to a System Administrator for manual remediation."
},
{
"id": "app.data_spillage.report.marshal_comment.app_error",
"translation": "An error occurred while marshaling the comment for the flagged post report."
},
{
"id": "app.data_spillage.report.post_id",
"translation": "Post ID:"
@@ -6196,6 +6212,14 @@
"id": "app.data_spillage.report.summary",
"translation": "Summary"
},
{
"id": "app.data_spillage.report.sync.app_error",
"translation": "Failed to sync the flagged post report file to disk."
},
{
"id": "app.data_spillage.report.tempfile.app_error",
"translation": "Failed to create a temporary file for the flagged post report."
},
{
"id": "app.data_spillage.report.title",
"translation": "Post Deletion Report"
@@ -6204,6 +6228,34 @@
"id": "app.data_spillage.report.total_steps",
"translation": "Total Steps:"
},
{
"id": "app.data_spillage.report.write_edit_yaml.app_error",
"translation": "Failed to write edit history entry to the flagged post report."
},
{
"id": "app.data_spillage.report.write_metadata_yaml.app_error",
"translation": "Failed to write metadata to the flagged post report."
},
{
"id": "app.data_spillage.report.write_post_yaml.app_error",
"translation": "Failed to write post details to the flagged post report."
},
{
"id": "app.data_spillage.report.write_review_yaml.app_error",
"translation": "Failed to write content review details to the flagged post report."
},
{
"id": "app.data_spillage.report.zip_close.app_error",
"translation": "Failed to close the flagged post report archive."
},
{
"id": "app.data_spillage.report.zip_copy.app_error",
"translation": "Failed to copy attachment contents into the flagged post report archive."
},
{
"id": "app.data_spillage.report.zip_create.app_error",
"translation": "Failed to create an entry in the flagged post report archive."
},
{
"id": "app.data_spillage.save_reviewer_settings.app_error",
"translation": "Failed to save content reviewer settings to the database."
+1
View File
@@ -505,4 +505,5 @@ const (
AuditEventKeepFlaggedPost = "keepFlaggedPost" // keep flagged post
AuditEventUpdateContentFlaggingConfig = "updateContentFlaggingConfig" // update content flagging configuration
AuditEventSetReviewer = "setFlaggedPostReviewer" // assign reviewer for flagged post
AuditEventGenerateFlaggedPostReport = "generateFlaggedPostReport" // generate flagged post data report
)
+11
View File
@@ -3928,6 +3928,17 @@ func (c *Client4) KeepFlaggedPost(ctx context.Context, postId string, actionRequ
return BuildResponse(r), nil
}
// GenerateFlaggedPostReport generates and downloads a ZIP archive containing the
// flagged post report for the given post.
func (c *Client4) GenerateFlaggedPostReport(ctx context.Context, postId string, actionRequest *FlagContentActionRequest) ([]byte, *Response, error) {
r, err := c.doAPIPostJSON(ctx, c.contentFlaggingRoute().Join("post", postId, "report"), actionRequest)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return ReadBytesFromResponse(r)
}
// SearchFiles returns any posts with matching terms string.
func (c *Client4) SearchFiles(ctx context.Context, teamId string, terms string, isOrSearch bool) (*FileInfoList, *Response, error) {
params := SearchParameter{
@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
const FlaggedPostReportVersion = "1.0"
type FlaggedPostReportContext struct {
Post *Post
Channel *Channel
Team *Team
Author *User
EditHistory []*Post
}
// FlaggedPostReportPost is the on-disk shape for post.yaml. It
// embeds *Post to reuse common fields; the wire format is fixed by the
// MarshalYAML method below so the report layout does not depend on Post's
// own field tags.
type FlaggedPostReportPost struct {
*Post
AuthorName string
AuthorEmail string
ChannelDisplayName string
TeamID string
TeamDisplayName string
ReplyCountPtr *int64
EditHistoryOrder []string
}
func (f FlaggedPostReportPost) MarshalYAML() (any, error) {
out := map[string]any{
"author_name": f.AuthorName,
"author_email": f.AuthorEmail,
"channel_display_name": f.ChannelDisplayName,
"team_id": f.TeamID,
"team_display_name": f.TeamDisplayName,
}
if f.Post != nil {
out["id"] = f.Post.Id
out["author_id"] = f.Post.UserId
out["message"] = f.Post.Message
out["channel_id"] = f.Post.ChannelId
out["create_at"] = f.Post.CreateAt
out["update_at"] = f.Post.UpdateAt
out["is_pinned"] = f.Post.IsPinned
out["root_id"] = f.Post.RootId
if props := f.Post.GetProps(); len(props) > 0 {
out["props"] = props
}
if f.Post.Metadata != nil {
out["metadata"] = f.Post.Metadata
}
}
if f.ReplyCountPtr != nil {
out["reply_count"] = *f.ReplyCountPtr
}
if len(f.EditHistoryOrder) > 0 {
out["edit_history_order"] = f.EditHistoryOrder
}
return out, nil
}
// FlaggedPostReportContentReview is the on-disk shape for content_review.yaml.
type FlaggedPostReportContentReview struct {
ReporterUserID string `yaml:"reporter_user_id"`
ReporterUsername string `yaml:"reporter_username"`
ReporterReason string `yaml:"reporter_reason"`
ReporterComment string `yaml:"reporter_comment"`
ReportTimestamp int64 `yaml:"report_timestamp"`
Hidden bool `yaml:"hidden"`
ReviewerUserID string `yaml:"reviewer_user_id,omitempty"`
ReviewerUsername string `yaml:"reviewer_username,omitempty"`
ReviewerComment string `yaml:"reviewer_comment,omitempty"`
ActionTime int64 `yaml:"action_time,omitempty"`
}
// FlaggedPostReportMetadata is the on-disk shape for report_metadata.yaml.
type FlaggedPostReportMetadata struct {
GeneratedByUserID string `yaml:"generated_by_user_id"`
GeneratedByUsername string `yaml:"generated_by_username"`
Timestamp int64 `yaml:"timestamp"`
ReportVersion string `yaml:"report_version"`
}