Files
upstream-mattermost/server/channels/app/content_flagging_exposure_report.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

259 lines
8.3 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"cmp"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"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"
)
const exposureProfileBatchSize = 1000
var exposurePreambleSanitizer = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", ",", " ", ";", " ", "\t", " ")
func (a *App) ComputePostExposure(rctx request.CTX, postID string) (*model.PostExposureReport, *model.AppError) {
post, appErr := a.GetSinglePost(rctx, postID, true)
if appErr != nil {
return nil, appErr
}
if post.OriginalId != "" {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.edit_history_post.app_error", nil, "", http.StatusBadRequest)
}
channel, appErr := a.GetChannel(rctx, post.ChannelId)
if appErr != nil {
return nil, appErr
}
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.unsupported_channel_type.app_error", nil, "", http.StatusBadRequest)
}
windowEnd, appErr := a.getPostFlagTime(rctx, post.Id)
if appErr != nil {
return nil, appErr
}
report := &model.PostExposureReport{
Version: model.PostExposureReportVersion,
PostID: post.Id,
ChannelID: channel.Id,
ChannelName: channel.DisplayName,
ChannelType: channel.Type,
TeamID: channel.TeamId,
WindowStart: post.CreateAt,
WindowEnd: windowEnd,
GeneratedAt: model.GetMillis(),
Entries: []*model.PostExposureReportEntry{},
}
// Data source 1: who was in the channel while the post was live.
histories, err := a.Srv().Store().ChannelMemberHistory().GetUsersInChannelDuring(post.CreateAt, windowEnd, []string{channel.Id})
if err != nil {
return nil, model.NewAppError("ComputePostExposure", "app.data_spillage.exposure.get_channel_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// GetUsersInChannelDuring returns one row per membership interval, so a user who left
// and rejoined appears more than once. The report is a list of users, so collapse them.
memberUserIDs := make([]string, 0, len(histories))
seen := make(map[string]bool, len(histories))
for _, h := range histories {
if h.IsBot || seen[h.UserId] {
continue
}
seen[h.UserId] = true
memberUserIDs = append(memberUserIDs, h.UserId)
}
if len(memberUserIDs) == 0 {
return report, nil
}
// Data source 2: channel read state.
lastViewedByUser, appErr := a.getChannelLastViewedAt(rctx, channel.Id)
if appErr != nil {
return nil, appErr
}
users, appErr := a.getUsersProfiles(rctx, memberUserIDs)
if appErr != nil {
return nil, appErr
}
for _, userID := range memberUserIDs {
user, ok := users[userID]
if !ok {
rctx.Logger().Warn("Skipping exposure report entry for a user that no longer exists", mlog.String("user_id", userID), mlog.String("post_id", post.Id))
continue
}
entry := &model.PostExposureReportEntry{
UserID: user.Id,
Username: user.Username,
UserEmail: user.Email,
IsGuest: user.IsGuest(),
IsRemote: user.IsRemote(),
IsDeactivated: user.DeleteAt != 0,
WasChannelMember: true,
}
if lastViewedAt, isMember := lastViewedByUser[userID]; isMember {
entry.LastViewedAt = model.NewPointer(lastViewedAt)
}
if !entry.IsDeactivated {
lastActivityAt, appErr := a.getUserLastActivityAt(rctx, userID)
if appErr != nil {
return nil, appErr
}
entry.LastActivityAt = lastActivityAt
}
report.Entries = append(report.Entries, entry)
}
slices.SortFunc(report.Entries, func(x, y *model.PostExposureReportEntry) int {
return cmp.Or(cmp.Compare(x.Username, y.Username), cmp.Compare(x.UserID, y.UserID))
})
return report, nil
}
func (a *App) getPostFlagTime(rctx request.CTX, postID string) (int64, *model.AppError) {
value, appErr := a.GetPostContentFlaggingPropertyValue(rctx, postID, contentFlaggingPropertyNameReportingTime)
if appErr != nil {
if appErr.StatusCode == http.StatusNotFound {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError)
}
return 0, appErr
}
var reportingTime int64
if err := json.Unmarshal(value.Value, &reportingTime); err != nil {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if reportingTime <= 0 {
return 0, model.NewAppError("getPostFlagTime", "app.data_spillage.exposure.missing_reporting_time.app_error", nil, "", http.StatusInternalServerError)
}
return reportingTime, nil
}
func (a *App) getChannelLastViewedAt(rctx request.CTX, channelID string) (map[string]int64, *model.AppError) {
lastViewedByUser := map[string]int64{}
afterUserID := ""
for {
members, err := a.Srv().Store().Channel().GetMembersWithLastViewedAtSince(rctx, channelID, 0, afterUserID, model.ChannelMemberLastViewedMaxPerPage)
if err != nil {
return nil, model.NewAppError("getChannelLastViewedAt", "app.data_spillage.exposure.get_possible_viewers.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(members) == 0 {
break
}
for _, m := range members {
lastViewedByUser[m.UserId] = m.LastViewedAt
}
afterUserID = members[len(members)-1].UserId
if len(members) < model.ChannelMemberLastViewedMaxPerPage {
break
}
}
return lastViewedByUser, nil
}
func (a *App) getUsersProfiles(rctx request.CTX, userIDs []string) (map[string]*model.User, *model.AppError) {
profiles := make(map[string]*model.User, len(userIDs))
for batch := range slices.Chunk(userIDs, exposureProfileBatchSize) {
users, appErr := a.GetUsers(rctx, batch)
if appErr != nil {
return nil, appErr
}
for _, u := range users {
profiles[u.Id] = u
}
}
return profiles, nil
}
func (a *App) getUserLastActivityAt(rctx request.CTX, userID string) (*int64, *model.AppError) {
sessions, appErr := a.GetSessions(rctx, userID)
if appErr != nil {
return nil, model.NewAppError("getUserLastActivityAt", "app.data_spillage.exposure.get_last_activity.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if len(sessions) == 0 {
return nil, nil
}
var lastActivityAt int64
for _, session := range sessions {
if session.LastActivityAt > lastActivityAt {
lastActivityAt = session.LastActivityAt
}
}
return model.NewPointer(lastActivityAt), nil
}
func WritePostExposureCSV(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error {
if err := writePostExposurePreamble(w, report, T); err != nil {
return err
}
cw := csv.NewWriter(w)
if err := cw.Write(model.PostExposureReportCSVHeader(T)); err != nil {
return err
}
for _, entry := range report.Entries {
if err := cw.Write(entry.ToCSVRow(T)); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
// writePostExposurePreamble writes the report metadata as CSV comment lines above the header
// row, so the artifact is self-describing without a sidecar file. Readers skip these with
// the standard comment option (Go's csv.Reader Comment: '#', pandas comment='#').
func writePostExposurePreamble(w io.Writer, report *model.PostExposureReport, T i18n.TranslateFunc) error {
lines := [][2]string{
{T("app.data_spillage.exposure.meta.report_version"), report.Version},
{T("app.data_spillage.exposure.meta.post_id"), report.PostID},
{T("app.data_spillage.exposure.meta.channel"), fmt.Sprintf("%s (%s)", report.ChannelName, report.ChannelID)},
{T("app.data_spillage.exposure.meta.window_start"), model.FormatExposureTime(report.WindowStart)},
{T("app.data_spillage.exposure.meta.window_end"), model.FormatExposureTime(report.WindowEnd)},
{T("app.data_spillage.exposure.meta.generated_at"), model.FormatExposureTime(report.GeneratedAt)},
{T("app.data_spillage.exposure.meta.total_users"), fmt.Sprintf("%d", len(report.Entries))},
}
for _, line := range lines {
if _, err := fmt.Fprintf(w, "# %s: %s\n", line[0], exposurePreambleSanitizer.Replace(line[1])); err != nil {
return err
}
}
return nil
}