mattermost/server/channels/app/content_flagging.go
Miguel de la Cruz 58dd9e1bb4
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
Add property system app layer architecture (#35157)
* Refactor property system with app layer routing and access control separation

Establish the app layer as the primary entry point for property operations
with intelligent routing based on group type. This architecture separates
access-controlled operations (CPA groups) from standard operations,
improving performance and code clarity.

Architecture Changes:
- App layer now routes operations based on group type:
  - CPA groups -> PropertyAccessService (enforces access control)
  - Non-CPA groups -> PropertyService (direct, no access control)
- PropertyAccessService simplified to handle only CPA operations
- Eliminated redundant group type checks throughout the codebase

* Move access control routing into PropertyService

This change makes the PropertyService the main entrypoint for property
related operations, and adds a routing mechanism to decide if extra
behaviors or checks should run for each operation, in this case, the
property access service logic.

To add specific payloads that pluggable checks and operations may
need, we use the request context. When the request comes from the API,
the endpoints are in charge of adding the caller ID to the payload,
and in the case of the plugin API, on receiving a request, the server
automatically tags the context with the plugin ID so the property
service can react accordingly.

Finally, the new design enforces all these checks migrating the actual
property logic to internal, non-exposed methods, so any caller from
the App layer needs to go through the service checks that decide if
pluggable logic is needed, avoiding any possibility of a bypass.

* Fix i18n

* Fix bad error string

* Added nil guards to property methods

* Add check for multiple group IDs on value operations

* Add nil guard to the plugin checker

* Fix build error

* Update value tests

* Fix linter

* Adds early return when content flaggin a thread with no replies

* Fix mocks

* Clean the state of plugin property tests before each run

* Do not wrap appErr on API response and fix i18n

* Fix create property field test

* Remove the need to cache cpaGroupID as part of the property service

* Split the property.go file into multiple

* Not found group doesn't bypass access control check

* Unexport SetPluginCheckerForTests

* Rename plugin context getter to be more PSA specific

---------

Co-authored-by: Miguel de la Cruz <miguel@ctrlz.es>
2026-03-26 07:54:50 +00:00

1391 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 model.NewAppError("FlagPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(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"),
})
}
_, appErr = a.CreatePropertyValues(rctx, propertyValues)
if appErr != nil {
return model.NewAppError("FlagPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if *a.Config().ContentFlaggingSettings.AdditionalSettings.HideFlaggedContent {
appErr = a.setContentFlaggingPropertiesForThreadReplies(rctx, 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(rctx request.CTX, 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)
}
if len(replies) == 0 {
return nil
}
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"),
})
}
_, appErr := a.CreatePropertyValues(rctx, propertyValues)
if appErr != nil {
return model.NewAppError("setContentFlaggingPropertiesForThreadReplies", "app.data_spillage.set_thread_replies_properties.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
return nil
}
func (a *App) ContentFlaggingGroupId() (string, *model.AppError) {
group, appErr := a.GetPropertyGroup(nil, model.ContentFlaggingGroupName)
if appErr != nil {
return "", model.NewAppError("getContentFlaggingGroupId", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
return group.ID, nil
}
func (a *App) GetPostContentFlaggingPropertyValue(postId, propertyFieldName string) (*model.PropertyValue, *model.AppError) {
groupId, err := a.ContentFlaggingGroupId()
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
statusPropertyField, appErr := a.GetPropertyFieldByName(nil, groupId, "", propertyFieldName)
if appErr != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.get_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
searchOptions := model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES, FieldID: statusPropertyField.ID}
propertyValues, appErr := a.SearchPropertyValues(nil, groupId, searchOptions)
if appErr != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.data_spillage.search_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
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, appErr := a.SearchPropertyFields(nil, groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS})
if appErr != nil {
return nil, model.NewAppError("GetContentFlaggingMappedFields", "app.data_spillage.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
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)),
}
_, appErr = a.CreatePropertyValue(nil, propertyValue)
if appErr != nil {
rctx.Logger().Error("Failed to create content review post property value in one of the channels", mlog.Err(appErr), 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, err := a.ContentFlaggingGroupId()
if err != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
propertyValues, appErr := a.SearchPropertyValues(nil, groupId, model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES})
if appErr != nil {
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.data_spillage.search_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
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, err := a.ContentFlaggingGroupId()
if err != nil {
return model.NewAppError("PermanentDeleteFlaggedPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
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())),
},
}
_, appErr = a.CreatePropertyValues(rctx, propertyValues)
if appErr != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
_, appErr = a.UpdatePropertyValue(rctx, groupId, status)
if appErr != nil {
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.data_spillage.permanently_delete.update_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
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, err := a.ContentFlaggingGroupId()
if err != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
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 rErr := a.Srv().Store().Post().RestoreContentFlaggedPost(flaggedPost, statusField.ID, contentFlaggingManagedField.ID); rErr != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_post.undelete.app_error", nil, "", http.StatusInternalServerError).Wrap(rErr)
}
}
commentBytes, marshalErr := json.Marshal(actionRequest.Comment)
if marshalErr != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_flag_post.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(marshalErr)
}
// 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())),
},
}
_, appErr = a.CreatePropertyValues(nil, propertyValues)
if appErr != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
_, appErr = a.UpdatePropertyValue(nil, groupId, status)
if appErr != nil {
return model.NewAppError("KeepFlaggedPost", "app.data_spillage.keep_post.status_update.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
// 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, err := a.ContentFlaggingGroupId()
if err != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.get_group.error", nil, "", http.StatusInternalServerError).Wrap(err)
}
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, appErr = a.UpsertPropertyValue(nil, assigneePropertyValue)
if appErr != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.upsert_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if status == model.ContentFlaggingStatusPending {
statusPropertyValue.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusAssigned))
statusPropertyValue, appErr = a.UpdatePropertyValue(nil, groupId, statusPropertyValue)
if appErr != nil {
return model.NewAppError("AssignFlaggedPostReviewer", "app.data_spillage.assign_reviewer.update_status_property_value.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
}
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, appErr := a.SearchPropertyValues(nil, contentFlaggingGroupId, searchOptions)
if appErr != nil {
return nil, model.NewAppError("getReviewerPostsForFlaggedPost", "app.data_spillage.search_reviewer_posts.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
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
}