mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-08 20:18:09 -05:00
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:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user