Files
team/server/channels/app/content_flagging.go
22104d294e Rename Content Flagging to Data Spillage Handling (#35407)
* Rename Content Flagging to Data Spillage Handling

Update all user-facing text to use "Data Spillage Handling" and
"Quarantine for Review" terminology. Rename i18n keys that referenced
content flagging. Auto-patch bot display name on pre-existing servers.

* Fixed searchable stringgs

* Revert unintended package-lock.json changes

* Fix i18n extract check: correct typo and key ordering

Fix "posed" -> "posted" typo in keep/remove quarantine modal
defaultMessages. Move admin.contentFlagging.title to correct
alphabetical position in en.json.

* Fix webapp tests for Data Spillage Handling rename

Update test assertions to match renamed i18n strings:
notification settings, content reviewers, and additional
settings tests now expect the new quarantine terminology.

* Use translatable i18n strings for notification messages

Replace hardcoded "flagged for review" notification templates with
i18n.T() calls using "quarantined for review" terminology. Add six
new server i18n keys for author, reporter, and reviewer notifications.

* Fix server i18n key mismatches and update test assertions

Rename remaining app.content_flagging.* keys to app.data_spillage.*
in server/i18n/en.json to match Go code references. Fix the
quarantine_post_confirmation key name. Update test assertions to
match new "quarantined for review" terminology.

* Fix gofmt formatting in content_flagging.go

* Prevent nil bot on PatchBot failure in getContentReviewBot

Use a separate variable for PatchBot result so the original bot
is preserved if the display name update fails.

* Reorder server i18n keys after extract

Run mmgotool i18n extract to sort entries into correct
alphabetical order.

* Replace i18n.T() with fmt.Sprintf for notification messages and fix test assertions

Use direct string formatting for bot notification messages instead of
i18n translation keys, which were being removed by mmgotool i18n extract
due to indirect key references. Also update test expectations for renamed
error keys (content_flagging -> data_spillage).

* Update default quarantine reasons to DISC-aligned terminology

Replace generic content moderation reasons with defense/intelligence
sector terminology: Classification mismatch, Need-to-know violation,
PII exposure, OPSEC concern, CUI violation, Unauthorized disclosure,
and Other. Updated across model, API tests, webapp tests, and e2e tests.

* Adding a string missing from bad merge

* Update remaining flagged terminology and icon for data spillage rename

- Change post menu icon from flag-outline to alert-outline
- Update reviewer notification: "quarantined" -> "submitted" a message
- Update action notifications: "flagged message" -> "quarantined message"
- Update modal errors: "flagging" -> "quarantining" this message
- Update report title: "flagged" -> "submitted" a message for review
- Update e2e page object locator for renamed menu item

Made-with: Cursor

* Fix tests

* Fix quarantine icon alignment in post dot menu

Use AlertOutlineIcon React component with size={18} instead of raw
<i> tag to match the sizing of all other menu item icons.

Made-with: Cursor

* Fixed E2E tests

* Missing test fix

* Fix E2E tests

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-03-06 21:15:01 -05:00

1387 lines
53 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/pkg/errors"
)
const (
CONTENT_FLAGGING_MAX_PROPERTY_FIELDS = 100
CONTENT_FLAGGING_MAX_PROPERTY_VALUES = 100
POST_PROP_KEY_FLAGGED_POST_ID = "reported_post_id"
CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT = 50
)
func (a *App) ContentFlaggingEnabledForTeam(teamId string) (bool, *model.AppError) {
reviewerIDs, appErr := a.GetContentFlaggingConfigReviewerIDs()
if appErr != nil {
return false, appErr
}
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
commonReviewersEnabled := reviewerSettings.CommonReviewers != nil && *reviewerSettings.CommonReviewers
hasAdditionalReviewers := (reviewerSettings.TeamAdminsAsReviewers != nil && *reviewerSettings.TeamAdminsAsReviewers) ||
(reviewerSettings.SystemAdminsAsReviewers != nil && *reviewerSettings.SystemAdminsAsReviewers)
if commonReviewersEnabled {
if len(reviewerIDs.CommonReviewerIds) > 0 || hasAdditionalReviewers {
return true, nil
}
return false, nil
}
teamSettings, exist := (reviewerIDs.TeamReviewersSetting)[teamId]
if !exist {
return false, nil
}
enabledForTeam := teamSettings.Enabled != nil && *teamSettings.Enabled
if !enabledForTeam {
return false, nil
}
hasTeamReviewers := len(teamSettings.ReviewerIds) > 0
if hasTeamReviewers || hasAdditionalReviewers {
return true, nil
}
return false, nil
}
func (a *App) FlagPost(rctx request.CTX, post *model.Post, teamId, reportingUserId string, flagData model.FlagContentRequest) *model.AppError {
commentBytes, err := json.Marshal(flagData.Comment)
if err != nil {
return model.NewAppError("FlagPost", "app.data_spillage.flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
reasonJson, err := json.Marshal(flagData.Reason)
if err != nil {
return model.NewAppError("FlagPost", "app.data_spillage.flag_post.marshal_reason.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
reasonJsonValue := json.RawMessage(reasonJson)
commentRequired := a.Config().ContentFlaggingSettings.AdditionalSettings.ReporterCommentRequired
validReasons := a.Config().ContentFlaggingSettings.AdditionalSettings.Reasons
if appErr := flagData.IsValid(*commentRequired, *validReasons); appErr != nil {
return appErr
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
reportingUser, appErr := a.GetUser(reportingUserId)
if appErr != nil {
return appErr
}
appErr = a.canFlagPost(groupId, post.Id, reportingUser.Locale)
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[ContentFlaggingPropertyNameStatus].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusPending)),
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reportingUserId)),
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingReason].ID,
Value: reasonJsonValue,
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingComment].ID,
Value: commentJsonValue,
},
{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReportingTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
propertyValues = append(propertyValues, &model.PropertyValue{
TargetID: post.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyManageByContentFlagging].ID,
Value: json.RawMessage("true"),
})
}
_, err = a.Srv().propertyAccessService.CreatePropertyValues(anonymousCallerId, propertyValues)
if err != nil {
return model.NewAppError("FlagPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
appErr = a.setContentFlaggingPropertiesForThreadReplies(post, groupId, mappedFields[contentFlaggingPropertyManageByContentFlagging].ID)
if appErr != nil {
return appErr
}
}
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
_, appErr = a.DeletePost(rctx, post.Id, contentReviewBot.UserId)
if appErr != nil {
return appErr
}
}
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
if !ok {
return model.NewAppError("FlagPost", "app.data_spillage.missing_flagged_post_id_field.app_error", nil, "", http.StatusInternalServerError)
}
a.Srv().Go(func() {
appErr = a.createContentReviewPost(rctx, post.Id, teamId, reportingUserId, flagData.Reason, post.ChannelId, post.UserId, flaggedPostIdField.ID, groupId)
if appErr != nil {
rctx.Logger().Error("Failed to create content review post", mlog.Err(appErr), mlog.String("team_id", teamId), mlog.String("post_id", post.Id))
}
})
a.Srv().Go(func() {
if appErr := a.sendFlagPostNotification(rctx, post); appErr != nil {
rctx.Logger().Error("Failed to send flag post notification", mlog.Err(appErr), mlog.String("post_id", post.Id))
}
})
return a.sendContentFlaggingConfirmationMessage(rctx, reportingUserId, post.UserId, post.ChannelId)
}
func (a *App) setContentFlaggingPropertiesForThreadReplies(post *model.Post, contentFlaggingGroupId, contentFlaggingManagedFieldId string) *model.AppError {
if post.RootId != "" {
// Post is a reply, not a root post
return nil
}
replies, err := a.Srv().Store().Post().GetPostsByThread(post.Id, 0)
if err != nil {
return model.NewAppError("setContentFlaggingPropertiesForThreadReplies", "app.data_spillage.get_thread_replies.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
propertyValues := make([]*model.PropertyValue, 0, len(replies))
for _, reply := range replies {
propertyValues = append(propertyValues, &model.PropertyValue{
TargetID: reply.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: contentFlaggingGroupId,
FieldID: contentFlaggingManagedFieldId,
Value: json.RawMessage("true"),
})
}
_, err = a.Srv().propertyAccessService.CreatePropertyValues(anonymousCallerId, propertyValues)
if err != nil {
return model.NewAppError("setContentFlaggingPropertiesForThreadReplies", "app.data_spillage.set_thread_replies_properties.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) ContentFlaggingGroupId() (string, *model.AppError) {
group, err := a.Srv().propertyAccessService.GetPropertyGroup(model.ContentFlaggingGroupName)
if err != nil {
return "", model.NewAppError("getContentFlaggingGroupId", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError)
}
return group.ID, nil
}
func (a *App) GetPostContentFlaggingPropertyValue(postId, propertyFieldName string) (*model.PropertyValue, *model.AppError) {
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return nil, appErr
}
statusPropertyField, err := a.Srv().propertyAccessService.GetPropertyFieldByName(anonymousCallerId, groupId, "", propertyFieldName)
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.get_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
searchOptions := model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES, FieldID: statusPropertyField.ID}
propertyValues, err := a.Srv().propertyAccessService.SearchPropertyValues(anonymousCallerId, groupId, searchOptions)
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.search_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(propertyValues) == 0 {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.no_status_property.app_error", nil, "", http.StatusNotFound)
}
return propertyValues[0], nil
}
func (a *App) canFlagPost(groupId, postId, userLocal string) *model.AppError {
status, appErr := a.GetPostContentFlaggingPropertyValue(postId, ContentFlaggingPropertyNameStatus)
if appErr != nil {
if appErr.StatusCode == http.StatusNotFound {
return nil
}
return appErr
}
var reason string
T := i18n.GetUserTranslations(userLocal)
switch strings.Trim(string(status.Value), `"`) {
case model.ContentFlaggingStatusPending, model.ContentFlaggingStatusAssigned:
reason = T("app.data_spillage.can_flag_post.in_progress")
case model.ContentFlaggingStatusRetained:
reason = T("app.data_spillage.can_flag_post.retained")
case model.ContentFlaggingStatusRemoved:
reason = T("app.data_spillage.can_flag_post.removed")
default:
reason = T("app.data_spillage.can_flag_post.unknown")
}
return model.NewAppError("canFlagPost", reason, nil, "", http.StatusBadRequest)
}
func (a *App) GetContentFlaggingMappedFields(groupId string) (map[string]*model.PropertyField, *model.AppError) {
fields, err := a.Srv().propertyAccessService.SearchPropertyFields(anonymousCallerId, groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS})
if err != nil {
return nil, model.NewAppError("GetContentFlaggingMappedFields", "app.data_spillage.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
mappedFields := map[string]*model.PropertyField{}
for _, field := range fields {
mappedFields[field.Name] = field
}
return mappedFields, nil
}
func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, reportingUserId, reportingReason, flaggedPostChannelId, flaggedPostAuthorId, flaggedPostIdFieldId, contentFlaggingGroupId string) *model.AppError {
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
channels, appErr := a.getContentReviewChannels(rctx, teamId, contentReviewBot.UserId)
if appErr != nil {
return appErr
}
reportingUser, appErr := a.GetUser(reportingUserId)
if appErr != nil {
return appErr
}
flaggedPostChannel, appErr := a.GetChannel(rctx, flaggedPostChannelId)
if appErr != nil {
return appErr
}
flaggedPostTeam, appErr := a.GetTeam(flaggedPostChannel.TeamId)
if appErr != nil {
return appErr
}
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
if appErr != nil {
return appErr
}
message := fmt.Sprintf("@%s submitted a message for review.\n\nReason: %s\nChannel: ~%s\nTeam: %s\nPost Author: @%s\n\nOpen on a web browser or the Desktop app to view the full report and take action.",
reportingUser.Username,
reportingReason,
flaggedPostChannel.Name,
flaggedPostTeam.DisplayName,
flaggedPostAuthor.Username,
)
for _, channel := range channels {
post := &model.Post{
Message: message,
UserId: contentReviewBot.UserId,
Type: model.ContentFlaggingPostType,
ChannelId: channel.Id,
}
post.AddProp(POST_PROP_KEY_FLAGGED_POST_ID, flaggedPostId)
createdPost, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
rctx.Logger().Error("Failed to create content review post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId))
continue // Don't stop processing other channels if one fails
}
propertyValue := &model.PropertyValue{
TargetID: createdPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: contentFlaggingGroupId,
FieldID: flaggedPostIdFieldId,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, flaggedPostId)),
}
_, err := a.Srv().propertyAccessService.CreatePropertyValue(anonymousCallerId, propertyValue)
if err != nil {
rctx.Logger().Error("Failed to create content review post property value in one of the channels", mlog.Err(err), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId), mlog.String("post_id", createdPost.Id))
}
}
return nil
}
func (a *App) getContentReviewChannels(rctx request.CTX, teamId, contentReviewBotId string) ([]*model.Channel, *model.AppError) {
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
if appErr != nil {
return nil, appErr
}
var channels []*model.Channel
for _, userId := range reviewersUserIDs {
channel, appErr := a.GetOrCreateDirectChannel(rctx, userId, contentReviewBotId)
if appErr != nil {
// Don't stop processing other reviewers if one fails
rctx.Logger().Error("Failed to get or create direct channel for one of the reviewers and content review bot", mlog.Err(appErr), mlog.String("user_id", userId), mlog.String("bot_id", contentReviewBotId))
}
channels = append(channels, channel)
}
return channels, nil
}
func (a *App) getContentReviewBot(rctx request.CTX) (*model.Bot, *model.AppError) {
desiredDisplayName := i18n.T("app.system.data_spillage_bot.bot_displayname")
bot, appErr := a.GetOrCreateSystemOwnedBot(rctx, model.ContentFlaggingBotUsername, desiredDisplayName)
if appErr != nil {
return nil, appErr
}
if bot.DisplayName != desiredDisplayName {
newName := desiredDisplayName
patchedBot, patchErr := a.PatchBot(rctx, bot.UserId, &model.BotPatch{DisplayName: &newName})
if patchErr != nil {
rctx.Logger().Warn("Failed to update Data Spillage bot display name", mlog.Err(patchErr))
} else {
bot = patchedBot
}
}
return bot, nil
}
func (a *App) getReviewersForTeam(teamId string, includeAdditionalReviewers bool) ([]string, *model.AppError) {
reviewerIDs, appErr := a.GetContentFlaggingConfigReviewerIDs()
if appErr != nil {
return nil, appErr
}
reviewerUserIDMap := map[string]bool{}
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
if *reviewerSettings.CommonReviewers {
for _, userID := range reviewerIDs.CommonReviewerIds {
reviewerUserIDMap[userID] = true
}
} else {
// If common reviewers are not enabled, we still need to check if the team has specific reviewers
teamSettings, exist := reviewerIDs.TeamReviewersSetting[teamId]
if exist && *teamSettings.Enabled && teamSettings.ReviewerIds != nil {
for _, userID := range teamSettings.ReviewerIds {
reviewerUserIDMap[userID] = true
}
}
}
if includeAdditionalReviewers {
var additionalReviewers []*model.User
if *reviewerSettings.TeamAdminsAsReviewers {
teamAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, nil, []string{model.TeamAdminRoleId})
if appErr != nil {
return nil, appErr
}
additionalReviewers = append(additionalReviewers, teamAdminReviewers...)
}
if *reviewerSettings.SystemAdminsAsReviewers {
sysAdminReviewers, appErr := a.getAllUsersInTeamForRoles(teamId, []string{model.SystemAdminRoleId}, nil)
if appErr != nil {
return nil, appErr
}
additionalReviewers = append(additionalReviewers, sysAdminReviewers...)
}
for _, user := range additionalReviewers {
reviewerUserIDMap[user.Id] = true
}
}
reviewerUserIDs := make([]string, 0, len(reviewerUserIDMap))
for userID := range maps.Keys(reviewerUserIDMap) {
reviewerUserIDs = append(reviewerUserIDs, userID)
}
return reviewerUserIDs, nil
}
func (a *App) getAllUsersInTeamForRoles(teamId string, systemRoles, teamRoles []string) ([]*model.User, *model.AppError) {
var additionalReviewers []*model.User
options := &model.UserGetOptions{
InTeamId: teamId,
Page: 0,
PerPage: 100,
Active: true,
Roles: systemRoles,
TeamRoles: teamRoles,
}
fetchFunc := func(page int) ([]*model.User, error) {
options.Page = page
users, appErr := a.GetUsersInTeam(options)
// Checking for error this way instead of directly returning *model.AppError
// doesn't equate to error == nil (pointer vs non-pointer)
if appErr != nil {
return users, errors.New(appErr.Error())
}
return users, nil
}
additionalReviewers, err := utils.Pager(fetchFunc, options.PerPage)
if err != nil {
return nil, model.NewAppError("getAllUsersInTeamForRoles", "app.data_spillage.get_users_in_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return additionalReviewers, nil
}
func (a *App) sendContentFlaggingConfirmationMessage(rctx request.CTX, flaggingUserId, flaggedPostAuthorId, channelID string) *model.AppError {
flaggedPostAuthor, appErr := a.GetUser(flaggedPostAuthorId)
if appErr != nil {
return appErr
}
T := i18n.GetUserTranslations(flaggedPostAuthor.Locale)
post := &model.Post{
Message: T("app.data_spillage.flag_post_confirmation.message", map[string]any{"username": flaggedPostAuthor.Username}),
ChannelId: channelID,
}
a.SendEphemeralPost(rctx, flaggingUserId, post)
return nil
}
func (a *App) IsUserTeamContentReviewer(userId, teamId string) (bool, *model.AppError) {
// not fetching additional reviewers because if the user exist in common or team
// specific reviewers, they are definitely a reviewer, and it saves multiple database calls.
reviewers, appErr := a.getReviewersForTeam(teamId, false)
if appErr != nil {
return false, appErr
}
if slices.Contains(reviewers, userId) {
return true, nil
}
// if user is not in common or team specific reviewers, we need to check if they are
// an additional reviewer.
reviewers, appErr = a.getReviewersForTeam(teamId, true)
if appErr != nil {
return false, appErr
}
return slices.Contains(reviewers, userId), nil
}
func (a *App) GetPostContentFlaggingPropertyValues(postId string) ([]*model.PropertyValue, *model.AppError) {
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return nil, appErr
}
propertyValues, err := a.PropertyAccessService().SearchPropertyValues(anonymousCallerId, groupId, model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES})
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.data_spillage.search_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return propertyValues, nil
}
func (a *App) PermanentDeleteFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
// when a flagged post is removed, the following things need to be done
// 1. Hard delete corresponding file infos
// 2. Hard delete file infos associated to post's edit history
// 3. Hard delete post's edit history
// 4. Hard delete the files from file storage
// 5. Hard delete post's priority data
// 6. Hard delete post's post acknowledgements
// 7. Hard delete post reminders
// 8. Scrub the post's content - message, props
commentBytes, jsonErr := json.Marshal(actionRequest.Comment)
if jsonErr != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.data_spillage.permanently_delete.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
// Storing marshaled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
status, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, ContentFlaggingPropertyNameStatus)
if appErr != nil {
return appErr
}
statusValue := strings.Trim(string(status.Value), `"`)
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest)
}
appErr = a.PermanentDeletePostDataRetainStub(rctx, flaggedPost, reviewerId)
if appErr != nil {
return appErr
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
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,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
_, err := a.Srv().propertyAccessService.CreatePropertyValues(anonymousCallerId, propertyValues)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
_, err = a.Srv().propertyAccessService.UpdatePropertyValue(anonymousCallerId, groupId, status)
if err != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.data_spillage.permanently_delete.update_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.Srv().Go(func() {
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
return
}
propertyValues = append(propertyValues, status)
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
rctx.Logger().Error("Failed to publish report change after permanently removing flagged post", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
})
a.Srv().Go(func() {
a.sendFlaggedPostRemovalNotification(rctx, flaggedPost, reviewerId, actionRequest.Comment, groupId)
})
return nil
}
func (a *App) PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) *model.AppError {
// when a post is removed, the following things need to be done
// 1. Hard delete corresponding file infos - covered
// 2. Hard delete file infos associated to post's edit history - NA
// 3. Hard delete post's edit history - NA
// 4. Hard delete the files from file storage - covered
// 5. Hard delete post's priority data - missing
// 6. Hard delete post's post acknowledgements - missing
// 7. Hard delete post reminders - missing
// 8. Scrub the post's content - message, props - missing
editHistories, appErr := a.GetEditHistoryForPost(post.Id)
if appErr != nil {
if appErr.StatusCode != http.StatusNotFound {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get edit history for post", mlog.Err(appErr), mlog.String("post_id", post.Id))
}
}
for _, editHistory := range editHistories {
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, deleteByID); deletePostAppErr != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
}
}
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, post.Id); filesDeleteAppErr != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete files for the post", mlog.Err(filesDeleteAppErr), mlog.String("post_id", post.Id))
}
if err := a.DeletePriorityForPost(post.Id); err != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post priority for the post", mlog.Err(err), mlog.String("post_id", post.Id))
}
if err := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(post.Id); err != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post acknowledgements for the post", mlog.Err(err), mlog.String("post_id", post.Id))
}
if err := a.Srv().Store().Post().DeleteAllPostRemindersForPost(post.Id); err != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post reminders for the post", mlog.Err(err), mlog.String("post_id", post.Id))
}
if err := a.Srv().Store().Post().PermanentDeleteAssociatedData([]string{post.Id}); err != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete associated data for the post", mlog.Err(err), mlog.String("post_id", post.Id))
}
scrubPost(post)
_, err := a.Srv().Store().Post().Overwrite(rctx, post)
if err != nil {
rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to scrub post content", mlog.Err(err), mlog.String("post_id", post.Id))
}
// If the post is not already deleted, delete it now.
if post.DeleteAt == 0 {
// DeletePost is called to care of WebSocket events, cache invalidation, search index removal,
// persistent notification removal and other cleanup tasks that need to happen on post deletion.
_, appErr = a.DeletePost(rctx, post.Id, deleteByID)
if appErr != nil {
return appErr
}
}
return nil
}
func (a *App) KeepFlaggedPost(rctx request.CTX, actionRequest *model.FlagContentActionRequest, reviewerId string, flaggedPost *model.Post) *model.AppError {
// for keeping a flagged flaggedPost we need to-
// 1. Undelete the flaggedPost if it was deleted, that's it
status, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, ContentFlaggingPropertyNameStatus)
if appErr != nil {
return appErr
}
statusValue := strings.Trim(string(status.Value), `"`)
if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned {
return model.NewAppError("KeepFlaggedPost", "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest)
}
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
contentFlaggingManaged, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPost.Id, contentFlaggingPropertyManageByContentFlagging)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return appErr
}
postHiddenByContentFlagging := contentFlaggingManaged != nil && string(contentFlaggingManaged.Value) == "true"
if postHiddenByContentFlagging {
statusField, ok := mappedFields[ContentFlaggingPropertyNameStatus]
if !ok {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.missing_status_field.app_error", nil, "", http.StatusInternalServerError)
}
contentFlaggingManagedField, ok := mappedFields[contentFlaggingPropertyManageByContentFlagging]
if !ok {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.missing_manage_by_field.app_error", nil, "", http.StatusInternalServerError)
}
// Restore the post, its replies, and all associated files
if err := a.Srv().Store().Post().RestoreContentFlaggedPost(flaggedPost, statusField.ID, contentFlaggingManagedField.ID); err != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_post.undelete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
commentBytes, err := json.Marshal(actionRequest.Comment)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Storing marshalled content into RawMessage to ensure proper escaping of special characters and prevent
// generating unsafe JSON values
commentJsonValue := json.RawMessage(commentBytes)
propertyValues := []*model.PropertyValue{
{
TargetID: flaggedPost.Id,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
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,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameActionTime].ID,
Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())),
},
}
_, err = a.Srv().propertyAccessService.CreatePropertyValues(anonymousCallerId, propertyValues)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
_, err = a.Srv().propertyAccessService.UpdatePropertyValue(anonymousCallerId, groupId, status)
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_post.status_update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Also need to remove the content flagging managed field value from root post and its replies (if any)
a.Srv().Go(func() {
channel, getChannelErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if getChannelErr != nil {
rctx.Logger().Error("Failed to get channel for flagged post while publishing report change after permanently removing flagged post", mlog.Err(getChannelErr), mlog.String("post_id", flaggedPost.Id), mlog.String("channel_id", flaggedPost.ChannelId))
return
}
propertyValues = append(propertyValues, status)
if err := a.publishContentFlaggingReportUpdateEvent(flaggedPost.Id, channel.TeamId, propertyValues); err != nil {
rctx.Logger().Error("Failed to publish report change after permanently removing flagged flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
}
})
if postHiddenByContentFlagging {
message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", flaggedPost.ChannelId, "", nil, "")
appErr = a.publishWebsocketEventForPost(rctx, flaggedPost, message)
if appErr != nil {
rctx.Logger().Warn("Failed to publish websocket event for post edit while keeping flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
}
a.invalidateCacheForChannelPosts(flaggedPost.ChannelId)
}
a.Srv().Go(func() {
a.sendKeepFlaggedPostNotification(rctx, flaggedPost, reviewerId, actionRequest.Comment, groupId)
})
return nil
}
func scrubPost(post *model.Post) {
if post.Type == model.PostTypeBurnOnRead {
post.Message = "*Content deleted as part of burning the post*"
} else {
post.Message = "*Content deleted as part of Content Flagging review process*"
}
post.MessageSource = post.Message
post.Hashtags = ""
post.Metadata = nil
post.FileIds = []string{}
post.UpdateAt = model.GetMillis()
post.SetProps(make(map[string]any))
}
func (a *App) publishContentFlaggingReportUpdateEvent(targetId, teamId string, propertyValues []*model.PropertyValue) *model.AppError {
reviewersUserIDs, appErr := a.getReviewersForTeam(teamId, true)
if appErr != nil {
return appErr
}
bytes, err := json.Marshal(propertyValues)
if err != nil {
return model.NewAppError("publishContentFlaggingReportUpdateEvent", "app.data_spillage.marshal_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, userId := range reviewersUserIDs {
message := model.NewWebSocketEvent(model.WebsocketContentFlaggingReportValueUpdated, "", "", userId, nil, "")
message.Add("property_values", string(bytes))
message.Add("target_id", targetId)
a.Publish(message)
}
return nil
}
func (a *App) SaveContentFlaggingConfig(config model.ContentFlaggingSettingsRequest) *model.AppError {
err := a.Srv().Store().ContentFlagging().SaveReviewerSettings(config.ReviewerSettings.ReviewerIDsSettings)
if err != nil {
return model.NewAppError("SaveContentFlaggingConfig", "app.data_spillage.save_reviewer_settings.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.UpdateConfig(func(cfg *model.Config) {
cfg.ContentFlaggingSettings = model.ContentFlaggingSettings{}
cfg.ContentFlaggingSettings.EnableContentFlagging = config.EnableContentFlagging
cfg.ContentFlaggingSettings.NotificationSettings = config.NotificationSettings
cfg.ContentFlaggingSettings.AdditionalSettings = config.AdditionalSettings
cfg.ContentFlaggingSettings.ReviewerSettings = &model.ReviewerSettings{
CommonReviewers: config.ReviewerSettings.CommonReviewers,
SystemAdminsAsReviewers: config.ReviewerSettings.SystemAdminsAsReviewers,
TeamAdminsAsReviewers: config.ReviewerSettings.TeamAdminsAsReviewers,
}
})
a.clearContentFlaggingConfigCache()
return nil
}
func (a *App) clearContentFlaggingConfigCache() {
a.Srv().Store().ContentFlagging().ClearCaches()
if cluster := a.Cluster(); cluster != nil && *a.Config().ClusterSettings.Enable {
cluster.SendClusterMessage(&model.ClusterMessage{
Event: model.ClusterEventInvalidateCacheForContentFlagging,
SendType: model.ClusterSendReliable,
Data: nil,
})
}
}
func (a *App) GetContentFlaggingConfigReviewerIDs() (*model.ReviewerIDsSettings, *model.AppError) {
reviewerSettings, err := a.Srv().Store().ContentFlagging().GetReviewerSettings()
if err != nil {
return nil, model.NewAppError("GetContentFlaggingConfigReviewerIDs", "app.data_spillage.get_reviewer_settings.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return reviewerSettings, nil
}
func (a *App) SearchReviewers(rctx request.CTX, term string, teamId string) ([]*model.User, *model.AppError) {
reviewerSettings := a.Config().ContentFlaggingSettings.ReviewerSettings
reviewers := map[string]*model.User{}
if reviewerSettings.CommonReviewers != nil && *reviewerSettings.CommonReviewers {
commonReviewers, err := a.Srv().Store().User().SearchCommonContentFlaggingReviewers(term)
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.data_spillage.search_common_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range commonReviewers {
reviewers[user.Id] = user
}
} else {
teamReviewers, err := a.Srv().Store().User().SearchTeamContentFlaggingReviewers(teamId, term)
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.data_spillage.search_team_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range teamReviewers {
reviewers[user.Id] = user
}
}
if reviewerSettings.SystemAdminsAsReviewers != nil && *reviewerSettings.SystemAdminsAsReviewers {
systemAdminReviewers, err := a.Srv().Store().User().Search(rctx, teamId, term, &model.UserSearchOptions{
AllowInactive: false,
Role: model.SystemAdminRoleId,
AllowEmails: false,
AllowFullNames: true,
Limit: CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT,
})
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.data_spillage.search_sysadmin_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range systemAdminReviewers {
reviewers[user.Id] = user
}
}
if reviewerSettings.TeamAdminsAsReviewers != nil && *reviewerSettings.TeamAdminsAsReviewers {
teamAdminReviewers, err := a.Srv().Store().User().Search(rctx, teamId, term, &model.UserSearchOptions{
AllowInactive: false,
TeamRoles: []string{model.TeamAdminRoleId},
AllowEmails: false,
AllowFullNames: true,
Limit: CONTENT_FLAGGING_REVIEWER_SEARCH_INDIVIDUAL_LIMIT,
})
if err != nil {
return nil, model.NewAppError("SearchReviewers", "app.data_spillage.search_team_admin_reviewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, user := range teamAdminReviewers {
reviewers[user.Id] = user
}
}
reviewersList := make([]*model.User, 0, len(reviewers))
for _, user := range reviewers {
a.SanitizeProfile(user, false)
reviewersList = append(reviewersList, user)
}
return reviewersList, nil
}
func (a *App) AssignFlaggedPostReviewer(rctx request.CTX, flaggedPostId, flaggedPostTeamId, reviewerId, assigneeId string) *model.AppError {
statusPropertyValue, appErr := a.GetPostContentFlaggingPropertyValue(flaggedPostId, ContentFlaggingPropertyNameStatus)
if appErr != nil {
return appErr
}
status := strings.Trim(string(statusPropertyValue.Value), `"`)
groupId, appErr := a.ContentFlaggingGroupId()
if appErr != nil {
return appErr
}
mappedFields, appErr := a.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
return appErr
}
if _, ok := mappedFields[contentFlaggingPropertyNameReviewerUserID]; !ok {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.no_reviewer_field.app_error", nil, "", http.StatusInternalServerError)
}
assigneePropertyValue := &model.PropertyValue{
TargetID: flaggedPostId,
TargetType: model.PropertyValueTargetTypePost,
GroupID: groupId,
FieldID: mappedFields[contentFlaggingPropertyNameReviewerUserID].ID,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, reviewerId)),
}
assigneePropertyValue, err := a.Srv().propertyAccessService.UpsertPropertyValue(anonymousCallerId, assigneePropertyValue)
if err != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.upsert_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if status == model.ContentFlaggingStatusPending {
statusPropertyValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusAssigned))
statusPropertyValue, err = a.Srv().propertyAccessService.UpdatePropertyValue(anonymousCallerId, groupId, statusPropertyValue)
if err != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.update_status_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
a.Srv().Go(func() {
_, postErr := a.postAssignReviewerMessage(rctx, groupId, flaggedPostId, reviewerId, assigneeId)
if postErr != nil {
rctx.Logger().Error("Failed to post assign reviewer message", mlog.Err(postErr), mlog.String("flagged_post_id", flaggedPostId), mlog.String("reviewer_id", reviewerId), mlog.String("assignee_id", assigneeId))
}
})
a.Srv().Go(func() {
updateEventAppErr := a.publishContentFlaggingReportUpdateEvent(flaggedPostId, flaggedPostTeamId, []*model.PropertyValue{assigneePropertyValue, statusPropertyValue})
if updateEventAppErr != nil {
rctx.Logger().Error("Failed to publish report change after assigning reviewer", mlog.Err(updateEventAppErr), mlog.String("flagged_post_id", flaggedPostId), mlog.String("reviewer_id", reviewerId), mlog.String("assignee_id", assigneeId))
}
})
return nil
}
func (a *App) postAssignReviewerMessage(rctx request.CTX, contentFlaggingGroupId, flaggedPostId, reviewerId, assignedById string) ([]*model.Post, *model.AppError) {
notificationSettings := a.Config().ContentFlaggingSettings.NotificationSettings
if notificationSettings == nil {
return nil, nil
}
if !slices.Contains(notificationSettings.EventTargetMapping[model.EventAssigned], model.TargetReviewers) {
return nil, nil
}
reviewerUser, appErr := a.GetUser(reviewerId)
if appErr != nil {
return nil, appErr
}
var assignedByUser *model.User
if reviewerId == assignedById {
assignedByUser = reviewerUser
} else {
assignedByUser, appErr = a.GetUser(assignedById)
if appErr != nil {
return nil, appErr
}
}
message := fmt.Sprintf("@%s was assigned as a reviewer by @%s", reviewerUser.Username, assignedByUser.Username)
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) {
actorUser, appErr := a.GetUser(actorUserId)
if appErr != nil {
return nil, appErr
}
message := fmt.Sprintf("The quarantined message was removed by @%s", actorUser.Username)
if comment != "" {
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
}
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) {
actorUser, appErr := a.GetUser(actorUserId)
if appErr != nil {
return nil, appErr
}
message := fmt.Sprintf("The quarantined message was retained by @%s", actorUser.Username)
if comment != "" {
message = fmt.Sprintf("%s\n\nWith comment:\n\n> %s", message, comment)
}
return a.postReviewerMessage(rctx, message, contentFlaggingGroupId, flaggedPostId)
}
func (a *App) getReporterUserId(flaggedPostId, contentFlaggingGroupId string) (string, *model.AppError) {
mappedFields, appErr := a.GetContentFlaggingMappedFields(contentFlaggingGroupId)
if appErr != nil {
return "", appErr
}
reporterUserIdField, ok := mappedFields[contentFlaggingPropertyNameReportingUserID]
if !ok {
return "", model.NewAppError("getReporterUserId", "app.data_spillage.missing_reporting_user_id_field.app_error", nil, "", http.StatusInternalServerError)
}
propertyValues, appErr := a.GetPostContentFlaggingPropertyValues(flaggedPostId)
if appErr != nil {
return "", appErr
}
var reporterPropertyValue *model.PropertyValue
for _, pv := range propertyValues {
if pv.FieldID == reporterUserIdField.ID {
reporterPropertyValue = pv
break
}
}
if reporterPropertyValue == nil {
return "", model.NewAppError("getReporterUserId", "app.data_spillage.missing_reporting_user_id_property_value.app_error", nil, "", http.StatusInternalServerError)
}
reporterUserId := strings.Trim(string(reporterPropertyValue.Value), `"`)
return reporterUserId, nil
}
func (a *App) postContentReviewBotMessage(rctx request.CTX, message string, recipientUserId string) (*model.Post, *model.AppError) {
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return nil, appErr
}
dmChannel, appErr := a.GetOrCreateDirectChannel(rctx, recipientUserId, contentReviewBot.UserId)
if appErr != nil {
return nil, appErr
}
post := &model.Post{
Message: message,
UserId: contentReviewBot.UserId,
ChannelId: dmChannel.Id,
}
// We can ignore the membership since the post itself is does not have a permalink
createdPost, _, appErr := a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{})
if appErr != nil {
return nil, appErr
}
return createdPost, nil
}
func (a *App) postMessageToReporter(rctx request.CTX, contentFlaggingGroupId string, flaggedPost *model.Post, message string) (*model.Post, *model.AppError) {
userId, appErr := a.getReporterUserId(flaggedPost.Id, contentFlaggingGroupId)
if appErr != nil {
return nil, appErr
}
return a.postContentReviewBotMessage(rctx, message, userId)
}
func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGroupId, flaggedPostId string) ([]*model.Post, *model.AppError) {
mappedFields, appErr := a.GetContentFlaggingMappedFields(contentFlaggingGroupId)
if appErr != nil {
return nil, appErr
}
flaggedPostIdField, ok := mappedFields[contentFlaggingPropertyNameFlaggedPostId]
if !ok {
return nil, model.NewAppError("postReviewerMessage", "app.data_spillage.missing_flagged_post_id_field.app_error", nil, "", http.StatusInternalServerError)
}
postIds, appErr := a.getReviewerPostsForFlaggedPost(contentFlaggingGroupId, flaggedPostId, flaggedPostIdField.ID)
if appErr != nil {
return nil, appErr
}
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return nil, appErr
}
createdPosts := make([]*model.Post, 0, len(postIds))
for _, postId := range postIds {
reviewerPost, appErr := a.GetSinglePost(rctx, postId, false)
if appErr != nil {
rctx.Logger().Error("Failed to get reviewer post while posting assign reviewer message", mlog.Err(appErr), mlog.String("post_id", postId))
continue
}
channel, appErr := a.GetChannel(rctx, reviewerPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for reviewer post while posting assign reviewer message", mlog.Err(appErr), mlog.String("post_id", postId), mlog.String("channel_id", reviewerPost.ChannelId))
continue
}
post := &model.Post{
Message: message,
UserId: contentReviewBot.UserId,
ChannelId: reviewerPost.ChannelId,
RootId: postId,
}
// We can ignore the membership since the post itself is does not have a permalink
createdPost, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{})
if appErr != nil {
rctx.Logger().Error("Failed to create assign reviewer post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("post_id", postId))
continue
}
createdPosts = append(createdPosts, createdPost)
}
return createdPosts, nil
}
func (a *App) getReviewerPostsForFlaggedPost(contentFlaggingGroupId, flaggedPostId, flaggedPostIdFieldId string) ([]string, *model.AppError) {
searchOptions := model.PropertyValueSearchOpts{
TargetType: model.PropertyValueTargetTypePost,
Value: json.RawMessage(fmt.Sprintf(`"%s"`, flaggedPostId)),
FieldID: flaggedPostIdFieldId,
PerPage: 100,
Cursor: model.PropertyValueSearchCursor{},
}
var propertyValues []*model.PropertyValue
for {
batch, err := a.Srv().propertyAccessService.SearchPropertyValues(anonymousCallerId, contentFlaggingGroupId, searchOptions)
if err != nil {
return nil, model.NewAppError("getReviewerPostsForFlaggedPost", "app.data_spillage.search_reviewer_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
propertyValues = append(propertyValues, batch...)
if len(batch) < searchOptions.PerPage {
break
}
searchOptions.Cursor.PropertyValueID = propertyValues[len(propertyValues)-1].ID
searchOptions.Cursor.CreateAt = propertyValues[len(propertyValues)-1].CreateAt
}
reviewerPostIds := make([]string, 0, len(propertyValues))
for _, pv := range propertyValues {
reviewerPostIds = append(reviewerPostIds, pv.TargetID)
}
return reviewerPostIds, nil
}
func (a *App) sendFlagPostNotification(rctx request.CTX, flaggedPost *model.Post) *model.AppError {
notificationSettings := a.Config().ContentFlaggingSettings.NotificationSettings
flagPostNotifications := notificationSettings.EventTargetMapping[model.EventFlagged]
if flagPostNotifications == nil {
return nil
}
if !slices.Contains(flagPostNotifications, model.TargetAuthor) {
return nil
}
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if appErr != nil {
return appErr
}
contentReviewBot, appErr := a.getContentReviewBot(rctx)
if appErr != nil {
return appErr
}
dmChannel, appErr := a.GetOrCreateDirectChannel(rctx, flaggedPost.UserId, contentReviewBot.UserId)
if appErr != nil {
return appErr
}
post := &model.Post{
Message: fmt.Sprintf("Your post having ID `%s` in the channel `%s` has been quarantined for review.", flaggedPost.Id, channel.DisplayName),
UserId: contentReviewBot.UserId,
ChannelId: dmChannel.Id,
}
_, _, appErr = a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{})
return appErr
}
// sendFlaggedPostRemovalNotification handles the notifications when flagged post is removed for all audiences - reviewers, author, and reporter as per configuration
func (a *App) sendFlaggedPostRemovalNotification(rctx request.CTX, flaggedPost *model.Post, actorUserId, comment, contentFlaggingGroupId string) []*model.Post {
notificationSettings := a.Config().ContentFlaggingSettings.NotificationSettings
deletePostNotifications := notificationSettings.EventTargetMapping[model.EventContentRemoved]
if deletePostNotifications == nil {
return nil
}
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for notification", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
return nil
}
var createdPosts []*model.Post
if slices.Contains(deletePostNotifications, model.TargetReviewers) {
posts, appErr := a.postDeletePostReviewerMessage(rctx, flaggedPost.Id, actorUserId, comment, contentFlaggingGroupId)
if appErr != nil {
rctx.Logger().Error("Failed to post delete post reviewer message after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = posts
}
}
if slices.Contains(deletePostNotifications, model.TargetAuthor) {
msg := fmt.Sprintf("Your post having ID `%s` in the channel `%s` which was quarantined for review has been permanently removed by a reviewer.", flaggedPost.Id, channel.DisplayName)
post, appErr := a.postContentReviewBotMessage(rctx, msg, flaggedPost.UserId)
if appErr != nil {
rctx.Logger().Error("Failed to post delete post author message after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = append(createdPosts, post)
}
}
if slices.Contains(deletePostNotifications, model.TargetReporter) {
msg := fmt.Sprintf("The post having ID `%s` in the channel `%s` which you quarantined for review has been permanently removed by a reviewer.", flaggedPost.Id, channel.DisplayName)
post, appErr := a.postMessageToReporter(rctx, contentFlaggingGroupId, flaggedPost, msg)
if appErr != nil {
rctx.Logger().Error("Failed to post delete post reporter message after permanently removing flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = append(createdPosts, post)
}
}
return createdPosts
}
// sendKeepFlaggedPostNotification handles the notifications when flagged post is retained for all audiences - reviewers, author, and reporter as per configuration
func (a *App) sendKeepFlaggedPostNotification(rctx request.CTX, flaggedPost *model.Post, actorUserId, comment, contentFlaggingGroupId string) []*model.Post {
notificationSettings := a.Config().ContentFlaggingSettings.NotificationSettings
keepPostNotifications := notificationSettings.EventTargetMapping[model.EventContentDismissed]
if keepPostNotifications == nil {
return nil
}
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
if appErr != nil {
rctx.Logger().Error("Failed to get channel for notification", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
return nil
}
var createdPosts []*model.Post
if slices.Contains(keepPostNotifications, model.TargetReviewers) {
posts, appErr := a.postKeepPostReviewerMessage(rctx, flaggedPost.Id, actorUserId, comment, contentFlaggingGroupId)
if appErr != nil {
rctx.Logger().Error("Failed to post retain post reviewer message after restoring flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = posts
}
}
if slices.Contains(keepPostNotifications, model.TargetAuthor) {
msg := fmt.Sprintf("Your post having ID `%s` in the channel `%s` which was quarantined for review has been restored by a reviewer.", flaggedPost.Id, channel.DisplayName)
post, appErr := a.postContentReviewBotMessage(rctx, msg, flaggedPost.UserId)
if appErr != nil {
rctx.Logger().Error("Failed to post retain post author message after restoring flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = append(createdPosts, post)
}
}
if slices.Contains(keepPostNotifications, model.TargetReporter) {
msg := fmt.Sprintf("The post having ID `%s` in the channel `%s` which you quarantined for review has been restored by a reviewer.", flaggedPost.Id, channel.DisplayName)
post, appErr := a.postMessageToReporter(rctx, contentFlaggingGroupId, flaggedPost, msg)
if appErr != nil {
rctx.Logger().Error("Failed to post retain post reporter message after restoring flagged post", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
} else {
createdPosts = append(createdPosts, post)
}
}
return createdPosts
}