// 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" "time" "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/mattermost/mattermost/server/v8/channels/store" "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(rctx, reportingUserId) if appErr != nil { return appErr } appErr = a.canFlagPost(rctx, groupId, post.Id, reportingUser.Locale) if appErr != nil { return appErr } mappedFields, appErr := a.GetContentFlaggingMappedFields(rctx, 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(rctx request.CTX, 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) } // Content flagging fields carry no object type, so the empty object type is // their exact scope. statusPropertyField, appErr := a.GetPropertyFieldByNameForObjectType(rctx, 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(rctx, 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(rctx request.CTX, groupId, postId, userLocal string) *model.AppError { status, appErr := a.GetPostContentFlaggingPropertyValue(rctx, 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) CheckFlaggedPostActionable(rctx request.CTX, where, postId string) (*model.PropertyValue, *model.AppError) { status, appErr := a.GetPostContentFlaggingPropertyValue(rctx, postId, ContentFlaggingPropertyNameStatus) if appErr != nil { return nil, appErr } statusValue := strings.Trim(string(status.Value), `"`) if statusValue != model.ContentFlaggingStatusPending && statusValue != model.ContentFlaggingStatusAssigned { return nil, model.NewAppError(where, "api.data_spillage.error.post_not_in_progress", nil, "", http.StatusBadRequest) } return status, nil } func (a *App) GetContentFlaggingMappedFields(rctx request.CTX, groupId string) (map[string]*model.PropertyField, *model.AppError) { fields, appErr := a.SearchPropertyFields(rctx, 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(rctx, 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(rctx, 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(rctx, 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(rctx, 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(rctx request.CTX, 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(rctx, 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.CheckFlaggedPostActionable(rctx, "PermanentlyRemoveFlaggedPost", flaggedPost.Id) 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) } deletionReport, appErr := a.PermanentDeletePostDataRetainStub(rctx, flaggedPost, reviewerId) // Send the deletion report even if there is an error as there can be partial deletion of data // which must be reported to the reviewers. if deletionReport != nil { a.Srv().Go(func() { a.sendDeletionReportToReviewers(rctx, flaggedPost.Id, deletionReport, groupId) }) } if appErr != nil { return appErr } mappedFields, appErr := a.GetContentFlaggingMappedFields(rctx, groupId) if appErr != nil { return appErr } existingComment, appErr := a.GetPostContentFlaggingPropertyValue(rctx, flaggedPost.Id, contentFlaggingPropertyNameActorComment) if appErr != nil && appErr.StatusCode != http.StatusNotFound { 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[contentFlaggingPropertyNameActionTime].ID, Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())), }, } if existingComment == nil { propertyValues = append(propertyValues, &model.PropertyValue{ TargetID: flaggedPost.Id, TargetType: model.PropertyValueTargetTypePost, GroupID: groupId, FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID, Value: commentJsonValue, }) } _, appErr = a.CreatePropertyValues(rctx, propertyValues) if appErr != nil { 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) sendDeletionReportToReviewers(rctx request.CTX, flaggedPostId string, report *model.PostDeletionReport, contentFlaggingGroupId string) { reportFileName := fmt.Sprintf("deletion_report_%s.md", flaggedPostId) _, appErr := a.postReviewerMessage(rctx, "", contentFlaggingGroupId, flaggedPostId, report, reportFileName, "") if appErr != nil { rctx.Logger().Error("Failed to send deletion report to reviewers", mlog.Err(appErr), mlog.String("post_id", flaggedPostId)) } } func (a *App) PermanentDeletePostDataRetainStub(rctx request.CTX, post *model.Post, deleteByID string) (*model.PostDeletionReport, *model.AppError) { report := &model.PostDeletionReport{ PostID: post.Id, Timestamp: time.Now().UTC(), } a.deleteFiles(rctx, post.Id, report) a.deleteEditHistories(rctx, post.Id, deleteByID, report) var nfErr *store.ErrNotFound // Handling persistent notification persistentNotification, err := a.Srv().Store().PostPersistentNotification().GetSingle(post.Id) if err != nil && !errors.As(err, &nfErr) { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get persistent notification for the post", mlog.Err(err), mlog.String("post_id", post.Id)) } if (err == nil && persistentNotification == nil) || errors.As(err, &nfErr) { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil) } else { if deleteErr := a.Srv().Store().PostPersistentNotification().Delete([]string{post.Id}); deleteErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete persistent notifications for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepFailed, "", []string{deleteErr.Error()}) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.persistent_notifications"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil) } } // Handling post acknowledgements acknowledgements, appErr := a.GetAcknowledgementsForPost(post.Id) if appErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post acknowledgements for the post", mlog.Err(appErr), mlog.String("post_id", post.Id)) } if appErr == nil && len(acknowledgements) == 0 { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil) } else { if deleteErr := a.Srv().Store().PostAcknowledgement().DeleteAllForPost(post.Id); deleteErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post acknowledgements for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepFailed, "", []string{deleteErr.Error()}) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.acknowledgements"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil) } } // Handling post priority postPriorityData, appErr := a.GetPriorityForPost(post.Id) if appErr != nil { // we can still attempt a deletion even if retrieval failed rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post priority for the post", mlog.Err(appErr), mlog.String("post_id", post.Id)) } if appErr == nil && postPriorityData == nil { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil) } else { if deleteErr := a.DeletePriorityForPost(post.Id); deleteErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post priority for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepFailed, "", []string{deleteErr.Error()}) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.priority_data"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil) } } reminders, err := a.Srv().Store().Post().GetPostRemindersForPost(post.Id) if err != nil && !errors.As(err, &nfErr) { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get post reminders for the post", mlog.Err(err), mlog.String("post_id", post.Id)) } if (err == nil && len(reminders) == 0) || errors.As(err, &nfErr) { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil) } else { if deleteErr := a.Srv().Store().Post().DeleteAllPostRemindersForPost(post.Id); deleteErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete post reminders for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepFailed, "", []string{deleteErr.Error()}) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.reminders"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.deleted"), nil) } } if deleteErr := a.Srv().Store().Post().PermanentDeleteAssociatedData([]string{post.Id}); deleteErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete associated data for the post", mlog.Err(deleteErr), mlog.String("post_id", post.Id)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.thread_data"), model.StepFailed, "", []string{deleteErr.Error()}) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.thread_data"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.thread_data_deleted"), nil) } postStepErrors := []string{} postStepFailed := false 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)) postStepErrors = append(postStepErrors, fmt.Sprintf("Failed to scrub post content: %s", err.Error())) postStepFailed = true } // If the post is not already deleted, delete it now. var deletePostErr *model.AppError if post.DeleteAt == 0 { // DeletePost is called to care of WebSocket events, cache invalidation, search index removal, // and other cleanup tasks that need to happen on post deletion. _, deletePostErr = a.DeletePost(rctx, post.Id, deleteByID) if deletePostErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to delete the post after scrubbing content", mlog.Err(deletePostErr), mlog.String("post_id", post.Id)) postStepErrors = append(postStepErrors, deletePostErr.Error()) postStepFailed = true } } if postStepFailed { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.post_itself"), model.StepFailed, "", postStepErrors) } else { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.post_itself"), model.StepSuccess, i18n.TranslationId("app.data_spillage.report.detail.post_scrubbed_deleted"), nil) } return report, deletePostErr } func (a *App) deleteEditHistories(rctx request.CTX, postId, deleteByID string, report *model.PostDeletionReport) { editHistories, appErr := a.getEditHistoryForPostUnrestricted(rctx, postId) if appErr != nil && appErr.StatusCode != http.StatusNotFound { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to get edit history for post", mlog.Err(appErr), mlog.String("post_id", postId)) report.AddStep(i18n.TranslationId("app.data_spillage.report.step.edit_histories"), model.StepFailed, i18n.TranslationId("app.data_spillage.report.detail.failed_retrieve_edit_history"), []string{appErr.Error()}) return } if len(editHistories) == 0 { report.AddStep(i18n.TranslationId("app.data_spillage.report.step.edit_histories"), model.StepNotApplicable, i18n.TranslationId("app.data_spillage.report.detail.no_data_found"), nil) return } step := model.DeletionStepResult{ Name: i18n.TranslationId("app.data_spillage.report.step.edit_histories"), SubSteps: make([]model.DeletionSubStep, 0, len(editHistories)), } allSuccess := true anySuccess := false for _, editHistory := range editHistories { subStep := model.DeletionSubStep{Name: editHistory.Id} 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)) subStep.Status = model.StepFailed subStep.Errors = []string{deletePostAppErr.Error()} allSuccess = false } else { subStep.Status = model.StepSuccess anySuccess = true } step.SubSteps = append(step.SubSteps, subStep) } if allSuccess { step.Status = model.StepSuccess } else if anySuccess { step.Status = model.StepPartial } else { step.Status = model.StepFailed } cleared := model.CountSubStepSuccesses(step.SubSteps) total := len(step.SubSteps) step.Detail = i18n.TranslationId("app.data_spillage.report.detail.revisions_cleared") step.DetailParams = map[string]any{"Count": cleared, "Total": total} report.Steps = append(report.Steps, step) } func (a *App) deleteFiles(rctx request.CTX, postId string, report *model.PostDeletionReport) { appErr := a.PermanentDeleteFilesByPost(rctx, postId, report) if appErr != nil { rctx.Logger().Error("PermanentDeletePostDataRetainStub: Failed to permanently delete files for the post", mlog.Err(appErr), mlog.String("post_id", postId)) } } 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.CheckFlaggedPostActionable(rctx, "KeepFlaggedPost", flaggedPost.Id) if appErr != nil { return appErr } 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(rctx, groupId) if appErr != nil { return appErr } contentFlaggingManaged, appErr := a.GetPostContentFlaggingPropertyValue(rctx, 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) } // Undelete the value for broadcasting in WebSocket events flaggedPost.DeleteAt = 0 } 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) existingComment, appErr := a.GetPostContentFlaggingPropertyValue(rctx, flaggedPost.Id, contentFlaggingPropertyNameActorComment) if appErr != nil && appErr.StatusCode != http.StatusNotFound { 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[contentFlaggingPropertyNameActionTime].ID, Value: json.RawMessage(fmt.Sprintf("%d", model.GetMillis())), }, } if existingComment == nil { propertyValues = append(propertyValues, &model.PropertyValue{ TargetID: flaggedPost.Id, TargetType: model.PropertyValueTargetTypePost, GroupID: groupId, FieldID: mappedFields[contentFlaggingPropertyNameActorComment].ID, Value: commentJsonValue, }) } _, appErr = a.CreatePropertyValues(rctx, propertyValues) if appErr != nil { 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(rctx, 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, "") a.markPostDeliveryForBroadcast(rctx, message, flaggedPost) 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(rctx, 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(rctx, 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(rctx, 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(rctx, 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(rctx, reviewerId) if appErr != nil { return nil, appErr } var assignedByUser *model.User if reviewerId == assignedById { assignedByUser = reviewerUser } else { assignedByUser, appErr = a.GetUser(rctx, 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, nil, "", "") } func (a *App) postDeletePostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) { actorUser, appErr := a.GetUser(rctx, 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, nil, "", "") } func (a *App) postKeepPostReviewerMessage(rctx request.CTX, flaggedPostId, actorUserId, comment, contentFlaggingGroupId string) ([]*model.Post, *model.AppError) { actorUser, appErr := a.GetUser(rctx, 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, nil, "", "") } func (a *App) getReporterUserId(rctx request.CTX, flaggedPostId, contentFlaggingGroupId string) (string, *model.AppError) { mappedFields, appErr := a.GetContentFlaggingMappedFields(rctx, 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(rctx, 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(rctx, flaggedPost.Id, contentFlaggingGroupId) if appErr != nil { return nil, appErr } return a.postContentReviewBotMessage(rctx, message, userId) } // postReviewerMessage replies on the content review thread of every reviewer that has // one for flaggedPostId, or only on targetReviewerId's thread when that is non-empty. func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGroupId, flaggedPostId string, report *model.PostDeletionReport, reportFileName, targetReviewerId string) ([]*model.Post, *model.AppError) { mappedFields, appErr := a.GetContentFlaggingMappedFields(rctx, 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(rctx, 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 } // The review post is authored by the bot, so the other side of the DM is the reviewer. reviewerUserId := channel.GetOtherUserIdForDM(reviewerPost.UserId) if targetReviewerId != "" && reviewerUserId != targetReviewerId { continue } // Determine the post message and file data, localizing per-reviewer if a report is provided postMessage := message var postFileData []byte var postFileName string if report != nil { T := i18n.GetUserTranslations("") // Fetch reviewer user to get their locale reviewer, userErr := a.GetUser(rctx, reviewerUserId) if userErr != nil { rctx.Logger().Error("Failed to get reviewer user for localization, falling back to default locale", mlog.Err(userErr), mlog.String("user_id", reviewerPost.UserId)) } else { T = i18n.GetUserTranslations(reviewer.Locale) } postMessage = report.RenderSummary(T) postFileData = []byte(report.Render(T)) postFileName = reportFileName } post := &model.Post{ Message: postMessage, UserId: contentReviewBot.UserId, ChannelId: reviewerPost.ChannelId, RootId: postId, } // Upload file attachment if provided if len(postFileData) > 0 { fileInfo, uploadErr := a.UploadFile(rctx, postFileData, reviewerPost.ChannelId, postFileName) if uploadErr != nil { // When the report fails to upload, the details aren't lost as the logs of any item which failed to be deleted are still in the server logs. rctx.Logger().Error("Failed to upload report file attachment, appending to message", mlog.Err(uploadErr), mlog.String("post_id", postId)) } else { post.FileIds = []string{fileInfo.Id} } } 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(rctx request.CTX, 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(rctx, 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 }