Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1340 lines
51 KiB
Go
1340 lines
51 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.content_flagging.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.content_flagging.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().propertyService.CreatePropertyValues(propertyValues)
|
|
if err != nil {
|
|
return model.NewAppError("FlagPost", "app.content_flagging.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.content_flagging.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.content_flagging.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().propertyService.CreatePropertyValues(propertyValues)
|
|
if err != nil {
|
|
return model.NewAppError("setContentFlaggingPropertiesForThreadReplies", "app.content_flagging.set_thread_replies_properties.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ContentFlaggingGroupId() (string, *model.AppError) {
|
|
group, err := a.Srv().propertyService.GetPropertyGroup(model.ContentFlaggingGroupName)
|
|
if err != nil {
|
|
return "", model.NewAppError("getContentFlaggingGroupId", "app.content_flagging.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().propertyService.GetPropertyFieldByName(groupId, "", propertyFieldName)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.content_flagging.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().propertyService.SearchPropertyValues(groupId, searchOptions)
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.content_flagging.search_status_property.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if len(propertyValues) == 0 {
|
|
return nil, model.NewAppError("GetPostContentFlaggingPropertyValue", "app.content_flagging.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.content_flagging.can_flag_post.in_progress")
|
|
case model.ContentFlaggingStatusRetained:
|
|
reason = T("app.content_flagging.can_flag_post.retained")
|
|
case model.ContentFlaggingStatusRemoved:
|
|
reason = T("app.content_flagging.can_flag_post.removed")
|
|
default:
|
|
reason = T("app.content_flagging.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().propertyService.SearchPropertyFields(groupId, model.PropertyFieldSearchOpts{PerPage: CONTENT_FLAGGING_MAX_PROPERTY_FIELDS})
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetContentFlaggingMappedFields", "app.content_flagging.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 flagged 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().propertyService.CreatePropertyValue(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) {
|
|
return a.GetOrCreateSystemOwnedBot(rctx, model.ContentFlaggingBotUsername, i18n.T("app.system.content_review_bot.bot_displayname"))
|
|
}
|
|
|
|
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.content_flagging.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.content_flagging.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.Srv().propertyService.SearchPropertyValues(groupId, model.PropertyValueSearchOpts{TargetIDs: []string{postId}, PerPage: CONTENT_FLAGGING_MAX_PROPERTY_VALUES})
|
|
if err != nil {
|
|
return nil, model.NewAppError("GetPostContentFlaggingPropertyValues", "app.content_flagging.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.content_flagging.permanently_delete.marshal_comment.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
|
}
|
|
// Storing marshalled 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.content_flagging.error.post_not_in_progress", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
editHistories, appErr := a.GetEditHistoryForPost(flaggedPost.Id)
|
|
if appErr != nil {
|
|
if appErr.StatusCode != http.StatusNotFound {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to get edit history for flaggedPost", mlog.Err(appErr), mlog.String("post_id", flaggedPost.Id))
|
|
}
|
|
}
|
|
|
|
for _, editHistory := range editHistories {
|
|
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, editHistory.Id); filesDeleteAppErr != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for one of the edit history posts", mlog.Err(filesDeleteAppErr), mlog.String("post_id", editHistory.Id))
|
|
}
|
|
|
|
if deletePostAppErr := a.PermanentDeletePost(rctx, editHistory.Id, reviewerId); deletePostAppErr != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete one of the edit history posts", mlog.Err(deletePostAppErr), mlog.String("post_id", editHistory.Id))
|
|
}
|
|
}
|
|
|
|
if filesDeleteAppErr := a.PermanentDeleteFilesByPost(rctx, flaggedPost.Id); filesDeleteAppErr != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to permanently delete files for the flaggedPost", mlog.Err(filesDeleteAppErr), mlog.String("post_id", flaggedPost.Id))
|
|
}
|
|
|
|
if err := a.DeletePriorityForPost(flaggedPost.Id); err != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost priority for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
|
}
|
|
|
|
if err := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(flaggedPost.Id); err != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost acknowledgements for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
|
}
|
|
|
|
if err := a.Srv().Store().Post().DeleteAllPostRemindersForPost(flaggedPost.Id); err != nil {
|
|
rctx.Logger().Error("PermanentlyRemoveFlaggedPost: Failed to delete flaggedPost reminders for the flaggedPost", mlog.Err(err), mlog.String("post_id", flaggedPost.Id))
|
|
}
|
|
|
|
scrubPost(flaggedPost)
|
|
_, err := a.Srv().Store().Post().Overwrite(rctx, flaggedPost)
|
|
if err != nil {
|
|
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.permanently_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
contentReviewBot, appErr := a.getContentReviewBot(rctx)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
// If the post is not already deleted, delete it now.
|
|
// This handles the case when "Hide message from channel while it is being reviewed" setting is set to false when the post was flagged.
|
|
if flaggedPost.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, flaggedPost.Id, contentReviewBot.UserId)
|
|
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().propertyService.CreatePropertyValues(propertyValues)
|
|
if err != nil {
|
|
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRemoved))
|
|
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
|
|
if err != nil {
|
|
return model.NewAppError("PermanentlyRemoveFlaggedPost", "app.content_flagging.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) 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.content_flagging.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.content_flagging.missing_status_field.app_error", nil, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
contentFlaggingManagedField, ok := mappedFields[contentFlaggingPropertyManageByContentFlagging]
|
|
if !ok {
|
|
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.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.content_flagging.keep_post.undelete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
commentBytes, err := json.Marshal(actionRequest.Comment)
|
|
if err != nil {
|
|
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.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().propertyService.CreatePropertyValues(propertyValues)
|
|
if err != nil {
|
|
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.create_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
status.Value = json.RawMessage(fmt.Sprintf(`"%s"`, model.ContentFlaggingStatusRetained))
|
|
_, err = a.Srv().propertyService.UpdatePropertyValue(groupId, status)
|
|
if err != nil {
|
|
return model.NewAppError("KeepFlaggedPost", "app.content_flagging.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) {
|
|
post.Message = "*Content deleted as part of Content Flagging review process*"
|
|
post.MessageSource = post.Message
|
|
post.Hashtags = ""
|
|
post.Metadata = nil
|
|
post.FileIds = []string{}
|
|
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.content_flagging.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.content_flagging.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.content_flagging.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.content_flagging.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.content_flagging.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.content_flagging.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.content_flagging.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.content_flagging.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().propertyService.UpsertPropertyValue(assigneePropertyValue)
|
|
if err != nil {
|
|
return model.NewAppError("AssignFlaggedPostReviewer", "app.content_flagging.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().propertyService.UpdatePropertyValue(groupId, statusPropertyValue)
|
|
if err != nil {
|
|
return model.NewAppError("AssignFlaggedPostReviewer", "app.content_flagging.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 flagged 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 flagged 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.content_flagging.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.content_flagging.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, flaggedPost *model.Post, messageTemplate string, flaggedPostAuthorUserId string) (*model.Post, *model.AppError) {
|
|
channel, appErr := a.GetChannel(rctx, flaggedPost.ChannelId)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
contentReviewBot, appErr := a.getContentReviewBot(rctx)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
dmChannel, appErr := a.GetOrCreateDirectChannel(rctx, flaggedPostAuthorUserId, contentReviewBot.UserId)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
post := &model.Post{
|
|
Message: fmt.Sprintf(messageTemplate, flaggedPost.Id, channel.DisplayName),
|
|
UserId: contentReviewBot.UserId,
|
|
ChannelId: dmChannel.Id,
|
|
}
|
|
|
|
return a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{})
|
|
}
|
|
|
|
func (a *App) postMessageToReporter(rctx request.CTX, contentFlaggingGroupId string, flaggedPost *model.Post, messageTemplate string) (*model.Post, *model.AppError) {
|
|
userId, appErr := a.getReporterUserId(flaggedPost.Id, contentFlaggingGroupId)
|
|
if appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
return a.postContentReviewBotMessage(rctx, flaggedPost, messageTemplate, 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.content_flagging.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,
|
|
}
|
|
|
|
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().propertyService.SearchPropertyValues(contentFlaggingGroupId, searchOptions)
|
|
if err != nil {
|
|
return nil, model.NewAppError("getReviewerPostsForFlaggedPost", "app.content_flagging.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 flagged 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
|
|
}
|
|
|
|
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) {
|
|
template := "Your post having ID `%s` in the channel `%s` which was flagged for review has been permanently removed by a reviewer."
|
|
post, appErr := a.postContentReviewBotMessage(rctx, flaggedPost, template, 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) {
|
|
template := "The post having ID `%s` in the channel `%s` which you flagged for review has been permanently removed by a reviewer."
|
|
post, appErr := a.postMessageToReporter(rctx, contentFlaggingGroupId, flaggedPost, template)
|
|
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
|
|
}
|
|
|
|
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) {
|
|
template := "Your post having ID `%s` in the channel `%s` which was flagged for review has been restored by a reviewer."
|
|
post, appErr := a.postContentReviewBotMessage(rctx, flaggedPost, template, 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) {
|
|
template := "The post having ID `%s` in the channel `%s` which you flagged for review has been restored by a reviewer."
|
|
post, appErr := a.postMessageToReporter(rctx, contentFlaggingGroupId, flaggedPost, template)
|
|
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
|
|
}
|