Files
upstream-mattermost/server/public/model/post.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

1781 lines
61 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"cmp"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type PostContextKey string
const (
PostSystemMessagePrefix = "system_"
PostTypeDefault = ""
PostTypeMessageAttachment = "slack_attachment"
PostTypeSystemGeneric = "system_generic"
PostTypeJoinLeave = "system_join_leave" // Deprecated, use PostJoinChannel or PostLeaveChannel instead
PostTypeJoinChannel = "system_join_channel"
PostTypeGuestJoinChannel = "system_guest_join_channel"
PostTypeLeaveChannel = "system_leave_channel"
PostTypeJoinTeam = "system_join_team"
PostTypeLeaveTeam = "system_leave_team"
PostTypeAutoResponder = "system_auto_responder"
PostTypeAutotranslationChange = "system_autotranslation"
PostTypeAddRemove = "system_add_remove" // Deprecated, use PostAddToChannel or PostRemoveFromChannel instead
PostTypeAddToChannel = "system_add_to_channel"
PostTypeAddGuestToChannel = "system_add_guest_to_chan"
PostTypeRemoveFromChannel = "system_remove_from_channel"
PostTypeMoveChannel = "system_move_channel"
PostTypeAddToTeam = "system_add_to_team"
PostTypeRemoveFromTeam = "system_remove_from_team"
// Values stay within the Posts.Type varchar(26) limit (see note below), so
// these use the shorter "abac" instead of spelling out "access_control".
PostTypeAccessControlTeamRemoval = "system_team_abac_removal" // 24 chars
PostTypeAccessControlTeamAddition = "system_team_abac_addition" // 25 chars
PostTypeHeaderChange = "system_header_change"
PostTypeDisplaynameChange = "system_displayname_change"
PostTypeConvertChannel = "system_convert_channel"
PostTypePurposeChange = "system_purpose_change"
PostTypeChannelDeleted = "system_channel_deleted"
PostTypeChannelRestored = "system_channel_restored"
PostTypeEphemeral = "system_ephemeral"
PostTypeChangeChannelPrivacy = "system_change_chan_privacy"
PostTypeWrangler = "system_wrangler"
PostTypeGMConvertedToChannel = "system_gm_to_channel"
PostTypeAddBotTeamsChannels = "add_bot_teams_channels"
PostTypeMe = "me"
PostCustomTypePrefix = "custom_"
PostTypeReminder = "reminder"
PostTypeBurnOnRead = "burn_on_read"
PostTypeCard = "card"
// PostTypeSharedChannelState is a system post for share/unshare events; the client translates using props.
// Name must fit Posts.Type varchar(26) (see store migrations).
PostTypeSharedChannelState = "system_shared_chan_state"
PostFileidsMaxRunes = 300
PostFilenamesMaxRunes = 4000
PostHashtagsMaxRunes = 1000
PostMessageMaxRunesV1 = 4000
PostMessageMaxBytesV2 = 1048576
PostMessageMaxRunesV2 = PostMessageMaxBytesV2 / 4 // Assume a worst-case representation
// Reporting API constants
MaxReportingPerPage = 1000 // Maximum number of posts that can be requested per page in reporting endpoints
ReportingTimeFieldCreateAt = "create_at"
ReportingTimeFieldUpdateAt = "update_at"
ReportingSortDirectionAsc = "asc"
ReportingSortDirectionDesc = "desc"
PostPropsMaxRunes = 800000
PostPropsMaxUserRunes = PostPropsMaxRunes - 40000 // Leave some room for system / pre-save modifications
PropsAddChannelMember = "add_channel_member"
PostPropsAddedUserId = "addedUserId"
PostPropsDeleteBy = "deleteBy"
PostPropsOverrideIconURL = "override_icon_url"
PostPropsOverrideIconEmoji = "override_icon_emoji"
PostPropsOverrideUsername = "override_username"
PostPropsFromWebhook = "from_webhook"
PostPropsFromBot = "from_bot"
PostPropsFromOAuthApp = "from_oauth_app"
PostPropsWebhookDisplayName = "webhook_display_name"
PostPropsFromPlugin = "from_plugin"
PostPropsMentionHighlightDisabled = "mentionHighlightDisabled"
PostPropsGroupHighlightDisabled = "disable_group_highlight"
PostPropsPreviewedPost = "previewed_post"
PostPropsForceNotification = "force_notification"
PostPropsSilentNotification = "silent_notification"
PostPropsChannelMentions = "channel_mentions"
PostPropsCurrentTeamId = "current_team_id"
PostPropsUnsafeLinks = "unsafe_links"
PostPropsAIGeneratedByUserID = "ai_generated_by"
PostPropsAIGeneratedByUsername = "ai_generated_by_username"
PostPropsExpireAt = "expire_at"
PostPropsReadDurationSeconds = "read_duration"
// Shared-channel state posts (PostTypeSharedChannelState): props for client-side i18n.
PostPropsSharedChannelState = "shared_channel_state"
PostPropsSharedChannelWorkspaceName = "workspace_name"
// Interactive Messages
PostPropsAttachments = "attachments"
PostPropsMmBlocks = "mm_blocks"
PostPropsBlockKitBlocks = "blocks"
PostPropsAdaptiveCards = "cards"
PostPropsMmBlocksActions = "mm_blocks_actions"
PostPriorityUrgent = "urgent"
DefaultExpirySeconds = 60 * 60 * 24 * 7 // 7 days
DefaultReadDurationSeconds = 10 * 60 // 10 minutes
PostContextKeyIsScheduledPost PostContextKey = "isScheduledPost"
)
// Values for PostPropsSharedChannelState on posts with Type PostTypeSharedChannelState.
const (
SharedChannelStatePostValueShared = "shared"
SharedChannelStatePostValueUnshared = "unshared"
)
type Post struct {
Id string `json:"id" xml:"Id"`
CreateAt int64 `json:"create_at" xml:"CreateAt"`
UpdateAt int64 `json:"update_at" xml:"UpdateAt"`
EditAt int64 `json:"edit_at" xml:"EditAt"`
DeleteAt int64 `json:"delete_at" xml:"DeleteAt"`
IsPinned bool `json:"is_pinned" xml:"IsPinned"`
UserId string `json:"user_id" xml:"UserId"`
ChannelId string `json:"channel_id" xml:"ChannelId"`
RootId string `json:"root_id" xml:"RootId"`
OriginalId string `json:"original_id" xml:"OriginalId"`
Message string `json:"message" xml:"Message"`
// MessageSource will contain the message as submitted by the user if Message has been modified
// by Mattermost for presentation (e.g if an image proxy is being used). It should be used to
// populate edit boxes if present.
MessageSource string `json:"message_source,omitempty" xml:"MessageSource,omitempty"`
Type string `json:"type" xml:"Type"`
propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Post.Props.
Props StringInterface `json:"props" xml:"Props"` // Deprecated: use GetProps()
Hashtags string `json:"hashtags" xml:"Hashtags"`
Filenames StringArray `json:"-" xml:"-"` // Deprecated, do not use this field any more
FileIds StringArray `json:"file_ids" xml:"FileIds>Id"`
PendingPostId string `json:"pending_post_id" xml:"PendingPostId"`
HasReactions bool `json:"has_reactions,omitempty" xml:"HasReactions,omitempty"`
RemoteId *string `json:"remote_id,omitempty" xml:"RemoteId,omitempty"`
// Transient data populated before sending a post to the client
ReplyCount int64 `json:"reply_count" xml:"ReplyCount"`
LastReplyAt int64 `json:"last_reply_at" xml:"LastReplyAt"`
Participants []*User `json:"participants" xml:"Participants>User"`
IsFollowing *bool `json:"is_following,omitempty" xml:"IsFollowing,omitempty"` // for root posts in collapsed thread mode indicates if the current user is following this thread
Metadata *PostMetadata `json:"metadata,omitempty" xml:"-"`
}
func (o *Post) Auditable() map[string]any {
var metaData map[string]any
if o.Metadata != nil {
metaData = o.Metadata.Auditable()
}
return map[string]any{
"id": o.Id,
"create_at": o.CreateAt,
"update_at": o.UpdateAt,
"edit_at": o.EditAt,
"delete_at": o.DeleteAt,
"is_pinned": o.IsPinned,
"user_id": o.UserId,
"channel_id": o.ChannelId,
"root_id": o.RootId,
"original_id": o.OriginalId,
"type": o.Type,
"props": o.GetProps(),
"file_ids": o.FileIds,
"pending_post_id": o.PendingPostId,
"remote_id": o.RemoteId,
"reply_count": o.ReplyCount,
"last_reply_at": o.LastReplyAt,
"is_following": o.IsFollowing,
"metadata": metaData,
}
}
func (o *Post) LogClone() any {
return o.Auditable()
}
type PostEphemeral struct {
UserID string `json:"user_id"`
Post *Post `json:"post"`
}
type PostPatch struct {
IsPinned *bool `json:"is_pinned"`
Message *string `json:"message"`
Props *StringInterface `json:"props"`
FileIds *StringArray `json:"file_ids"`
HasReactions *bool `json:"has_reactions"`
}
func (o *PostPatch) IsEmpty() bool {
return o.IsPinned == nil && o.Message == nil && o.Props == nil && o.FileIds == nil && o.HasReactions == nil
}
type PostReminder struct {
TargetTime int64 `json:"target_time"`
// These fields are only used internally for interacting with DB.
PostId string `json:",omitempty"`
UserId string `json:",omitempty"`
}
type PostPriority struct {
Priority *string `json:"priority"`
RequestedAck *bool `json:"requested_ack"`
PersistentNotifications *bool `json:"persistent_notifications"`
// These fields are only used internally for interacting with DB.
PostId string `json:",omitempty"`
ChannelId string `json:",omitempty"`
}
type PostPersistentNotifications struct {
PostId string
CreateAt int64
LastSentAt int64
DeleteAt int64
SentCount int16
}
type GetPersistentNotificationsPostsParams struct {
MaxTime int64
MaxSentCount int16
PerPage int
}
type MoveThreadParams struct {
ChannelId string `json:"channel_id"`
}
type SearchParameter struct {
Terms *string `json:"terms"`
IsOrSearch *bool `json:"is_or_search"`
TimeZoneOffset *int `json:"time_zone_offset"`
Page *int `json:"page"`
PerPage *int `json:"per_page"`
IncludeDeletedChannels *bool `json:"include_deleted_channels"`
}
func (sp SearchParameter) Auditable() map[string]any {
return map[string]any{
"terms": sp.Terms,
"is_or_search": sp.IsOrSearch,
"time_zone_offset": sp.TimeZoneOffset,
"page": sp.Page,
"per_page": sp.PerPage,
"include_deleted_channels": sp.IncludeDeletedChannels,
}
}
func (sp SearchParameter) LogClone() any {
return sp.Auditable()
}
type AnalyticsPostCountsOptions struct {
TeamId string
BotsOnly bool
YesterdayOnly bool
}
func (o *PostPatch) WithRewrittenImageURLs(f func(string) string) *PostPatch {
pCopy := *o //nolint:revive
if pCopy.Message != nil {
*pCopy.Message = RewriteImageURLs(*o.Message, f)
}
return &pCopy
}
func (o *PostPatch) Auditable() map[string]any {
return map[string]any{
"is_pinned": o.IsPinned,
"props": o.Props,
"file_ids": o.FileIds,
"has_reactions": o.HasReactions,
}
}
type PostForExport struct {
Post
TeamName string
ChannelName string
Username string
ReplyCount int
FlaggedBy StringArray
}
type DirectPostForExport struct {
Post
User string
ChannelMembers *[]string
FlaggedBy StringArray
}
type ReplyForExport struct {
Post
Username string
FlaggedBy StringArray
}
type PostForIndexing struct {
Post
TeamId string `json:"team_id"`
ParentCreateAt *int64 `json:"parent_create_at"`
ChannelType string `json:"channel_type"`
}
type FileForIndexing struct {
FileInfo
ChannelId string `json:"channel_id"`
Content string `json:"content"`
}
// ShouldIndex tells if a file should be indexed or not.
// index files which are-
// a. not deleted
// b. have an associated post ID, if no post ID, then,
// b.i. the file should belong to the channel's bookmarks, as indicated by the "CreatorId" field.
//
// Files not passing this criteria will be deleted from ES index.
// We're deleting those files from ES index instead of simply skipping them while fetching a batch of files
// because existing ES indexes might have these files already indexed, so we need to remove them from index.
func (file *FileForIndexing) ShouldIndex() bool {
// NOTE - this function is used in server as well as Enterprise code.
// Make sure to update public package dependency in both server and Enterprise code when
// updating the logic here and to test both places.
return file != nil && file.DeleteAt == 0 && (file.PostId != "" || file.CreatorId == BookmarkFileOwner)
}
// ShallowCopy is an utility function to shallow copy a Post to the given
// destination without touching the internal RWMutex.
func (o *Post) ShallowCopy(dst *Post) error {
if dst == nil {
return errors.New("dst cannot be nil")
}
o.propsMu.RLock()
defer o.propsMu.RUnlock()
dst.propsMu.Lock()
defer dst.propsMu.Unlock()
dst.Id = o.Id
dst.CreateAt = o.CreateAt
dst.UpdateAt = o.UpdateAt
dst.EditAt = o.EditAt
dst.DeleteAt = o.DeleteAt
dst.IsPinned = o.IsPinned
dst.UserId = o.UserId
dst.ChannelId = o.ChannelId
dst.RootId = o.RootId
dst.OriginalId = o.OriginalId
dst.Message = o.Message
dst.MessageSource = o.MessageSource
dst.Type = o.Type
dst.Props = o.Props
dst.Hashtags = o.Hashtags
dst.Filenames = o.Filenames
dst.FileIds = o.FileIds
dst.PendingPostId = o.PendingPostId
dst.HasReactions = o.HasReactions
dst.ReplyCount = o.ReplyCount
dst.Participants = o.Participants
dst.LastReplyAt = o.LastReplyAt
dst.Metadata = o.Metadata
if o.IsFollowing != nil {
dst.IsFollowing = new(*o.IsFollowing)
}
dst.RemoteId = o.RemoteId
return nil
}
// Clone shallowly copies the post and returns the copy.
func (o *Post) Clone() *Post {
pCopy := &Post{} //nolint:revive
o.ShallowCopy(pCopy)
return pCopy
}
func (o *Post) ToJSON() (string, error) {
pCopy := o.Clone() //nolint:revive
pCopy.StripActionIntegrations()
b, err := json.Marshal(pCopy)
return string(b), err
}
func (o *Post) EncodeJSON(w io.Writer) error {
o.StripActionIntegrations()
return json.NewEncoder(w).Encode(o)
}
type CreatePostFlags struct {
TriggerWebhooks bool
SetOnline bool
ForceNotification bool
SilentNotification bool
FromIncomingWebhook bool
FromPlugin bool
// AllowMmBlocksActions permits props.mm_blocks_actions on create. Set only
// by CreateWebhookPost — from_webhook is user-forgeable on the REST API.
AllowMmBlocksActions bool
// Display-identity overrides applied by trusted integration entry points
// (incoming webhooks, slash commands). SanitizeProps strips the matching
// post.Props keys by default so clients cannot forge them; the CreatePost
// handler re-injects these values onto the saved post only when
// FromIncomingWebhook is set — not for FromPlugin, bot, or OAuth-app
// authors, since no render path honors an override for those (see the
// reinjection block in CreatePost).
OverrideUsername string
OverrideIconURL string
OverrideIconEmoji string
WebhookDisplayName string
}
type GetPostsSinceOptions struct {
UserId string
ChannelId string
Time int64
SkipFetchThreads bool
CollapsedThreads bool
CollapsedThreadsExtended bool
SortAscending bool
ExcludeMembershipSystemPosts bool
}
type GetPostsSinceForSyncCursor struct {
LastPostUpdateAt int64
LastPostUpdateID string
LastPostCreateAt int64
LastPostCreateID string
}
func (c GetPostsSinceForSyncCursor) IsEmpty() bool {
return c.LastPostCreateAt == 0 && c.LastPostCreateID == "" && c.LastPostUpdateAt == 0 && c.LastPostUpdateID == ""
}
type GetPostsSinceForSyncOptions struct {
ChannelId string
ExcludeRemoteId string
IncludeDeleted bool
SinceCreateAt bool // determines whether the cursor will be based on CreateAt or UpdateAt
ExcludeChannelMetadataSystemPosts bool // if true, exclude channel metadata system posts (header, display name, purpose changes)
ExcludedPostTypes []string // post types to exclude from sync
}
type GetPostsOptions struct {
UserId string
ChannelId string
PostId string
Page int
PerPage int
SkipFetchThreads bool
CollapsedThreads bool
CollapsedThreadsExtended bool
FromPost string // PostId after which to send the items
FromCreateAt int64 // CreateAt after which to send the items
FromUpdateAt int64 // UpdateAt after which to send the items. This cannot be used with FromCreateAt.
Direction string // Only accepts up|down. Indicates the order in which to send the items.
UpdatesOnly bool // This flag is used to make the API work with the updateAt value.
IncludeDeleted bool
IncludePostPriority bool
ExcludeMembershipSystemPosts bool
// ExcludeExpiredBurnOnReadPosts, when set, makes the query skip burn-on-read
// posts whose read receipt has already expired for UserId. It is only set by
// the app layer when the burn-on-read feature is enabled, so it adds no query
// overhead otherwise.
ExcludeExpiredBurnOnReadPosts bool
}
type PostCountOptions struct {
// Only include posts on a specific team. "" for any team.
TeamId string
MustHaveFile bool
MustHaveHashtag bool
ExcludeDeleted bool
ExcludeSystemPosts bool
UsersPostsOnly bool
// AllowFromCache looks up cache only when ExcludeDeleted and UsersPostsOnly are true and rest are falsy.
AllowFromCache bool
// retrieves posts in the inclusive range: [SinceUpdateAt + LastPostId, UntilUpdateAt]
SincePostID string
SinceUpdateAt int64
UntilUpdateAt int64
}
func (o *Post) Etag() string {
return Etag(o.Id, o.UpdateAt)
}
func (o *Post) IsValid(maxPostSize int) *AppError {
if !IsValidId(o.Id) {
return NewAppError("Post.IsValid", "model.post.is_valid.id.app_error", nil, "", http.StatusBadRequest)
}
if o.CreateAt == 0 {
return NewAppError("Post.IsValid", "model.post.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if o.UpdateAt == 0 {
return NewAppError("Post.IsValid", "model.post.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if !IsValidId(o.UserId) {
return NewAppError("Post.IsValid", "model.post.is_valid.user_id.app_error", nil, "", http.StatusBadRequest)
}
if !IsValidId(o.ChannelId) {
return NewAppError("Post.IsValid", "model.post.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest)
}
if !(IsValidId(o.RootId) || o.RootId == "") {
return NewAppError("Post.IsValid", "model.post.is_valid.root_id.app_error", nil, "", http.StatusBadRequest)
}
if !(len(o.OriginalId) == 26 || o.OriginalId == "") {
return NewAppError("Post.IsValid", "model.post.is_valid.original_id.app_error", nil, "", http.StatusBadRequest)
}
if utf8.RuneCountInString(o.Message) > maxPostSize {
return NewAppError("Post.IsValid", "model.post.is_valid.message_length.app_error",
map[string]any{"Length": utf8.RuneCountInString(o.Message), "MaxLength": maxPostSize}, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(o.Hashtags) > PostHashtagsMaxRunes {
return NewAppError("Post.IsValid", "model.post.is_valid.hashtags.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
switch o.Type {
case
PostTypeDefault,
PostTypeSystemGeneric,
PostTypeJoinLeave,
PostTypeAutoResponder,
PostTypeAddRemove,
PostTypeJoinChannel,
PostTypeGuestJoinChannel,
PostTypeLeaveChannel,
PostTypeJoinTeam,
PostTypeLeaveTeam,
PostTypeAddToChannel,
PostTypeAddGuestToChannel,
PostTypeRemoveFromChannel,
PostTypeMoveChannel,
PostTypeAddToTeam,
PostTypeRemoveFromTeam,
PostTypeAccessControlTeamRemoval,
PostTypeAccessControlTeamAddition,
PostTypeMessageAttachment,
PostTypeHeaderChange,
PostTypePurposeChange,
PostTypeDisplaynameChange,
PostTypeConvertChannel,
PostTypeChannelDeleted,
PostTypeChannelRestored,
PostTypeChangeChannelPrivacy,
PostTypeAddBotTeamsChannels,
PostTypeReminder,
PostTypeMe,
PostTypeWrangler,
PostTypeGMConvertedToChannel,
PostTypeAutotranslationChange,
PostTypeBurnOnRead,
PostTypeCard,
PostTypeSharedChannelState:
default:
if !strings.HasPrefix(o.Type, PostCustomTypePrefix) {
return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest)
}
}
if utf8.RuneCountInString(ArrayToJSON(o.Filenames)) > PostFilenamesMaxRunes {
return NewAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes {
return NewAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes {
return NewAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id, http.StatusBadRequest)
}
return nil
}
// SanitizeProps is for the create/update write path only — it strips
// display-identity props unconditionally so a caller can re-inject them under
// verified authority. Calling it on a read path that returns an
// already-persisted post to a client wipes that legitimately-settled identity;
// use SanitizeNonIdentityProps for reads instead.
func (o *Post) SanitizeProps() {
if o == nil {
return
}
membersToSanitize := []string{
PropsAddChannelMember,
}
// Notification-policy markers (silent_notification, force_notification) are
// authorization-load-bearing: they change notification delivery for everyone
// in the channel, so they must be stripped on every locally-originated post-
// creation path. The server re-injects them in app.CreatePost under verified
// authority: silent_notification via isIntegrationPostAuthor, force_notification
// via the server-set CreatePostFlags.ForceNotification flag.
//
// The from_* identity markers (from_webhook, from_bot, from_oauth_app,
// from_plugin) are stripped here so a regular client cannot forge them;
// trusted integration entry points re-set from_bot/from_oauth_app/
// from_plugin/from_webhook directly in app.CreatePost based on user/session
// type or the FromIncomingWebhook/FromPlugin flag (see isIntegrationPostAuthor
// and the reinjection block in CreatePost).
//
// The display-identity overrides (override_username, override_icon_url,
// override_icon_emoji, webhook_display_name) are also stripped here, but
// re-injected only for FromIncomingWebhook — not the broader
// isIntegrationPostAuthor set — since override display has been an
// incoming-webhook-specific feature since 2015 and no render path honors it
// for bot/OAuth/plugin authors. This closes the user-PAT-impersonation idiom
// (forge from_webhook + override_username/override_icon_url) that
// previously rendered a post as another identity in thread lists, push
// notifications, edit history, and post previews.
//
// For posts that arrived through Shared Channels federation (RemoteId is set
// by the receiving cluster, never by an API caller — see SanitizeInput), the
// origin cluster has already enforced its own integration-prop authority, so
// we preserve all of these to keep notification and identity semantics
// consistent across federation.
if !o.IsRemote() {
membersToSanitize = append(membersToSanitize,
PostPropsForceNotification,
PostPropsSilentNotification,
PostPropsFromWebhook,
PostPropsFromBot,
PostPropsFromOAuthApp,
PostPropsFromPlugin,
PostPropsOverrideUsername,
PostPropsOverrideIconURL,
PostPropsOverrideIconEmoji,
PostPropsWebhookDisplayName,
)
}
for _, member := range membersToSanitize {
if _, ok := o.GetProps()[member]; ok {
o.DelProp(member)
}
}
for _, p := range o.Participants {
p.Sanitize(map[string]bool{})
}
}
// SanitizeNonIdentityProps strips PropsAddChannelMember and, for
// non-federated posts, the notification-policy markers (force_notification,
// silent_notification) — the same set SanitizeProps stripped before v12 added
// identity-marker stripping to it.
//
// Use this instead of SanitizeProps on read paths that return an
// already-persisted post to a client (Threads, CRT notifications, etc.).
// Those posts had their from_*/override_* props settled once already at
// create/update time via SanitizeProps plus the server's verified-identity
// re-injection — calling the stronger SanitizeProps again on read would wipe
// that legitimately-stored identity from the response.
func (o *Post) SanitizeNonIdentityProps() {
if o == nil {
return
}
membersToSanitize := []string{
PropsAddChannelMember,
}
if !o.IsRemote() {
membersToSanitize = append(membersToSanitize,
PostPropsForceNotification,
PostPropsSilentNotification,
)
}
for _, member := range membersToSanitize {
if _, ok := o.GetProps()[member]; ok {
o.DelProp(member)
}
}
for _, p := range o.Participants {
p.Sanitize(map[string]bool{})
}
}
// postIdentityPropsPreservedOnUpdate are server-controlled markers re-applied after
// SanitizeProps during UpdatePost so edits cannot strip integration identity.
// The display-identity overrides are restored from the prior post because v12
// SanitizeProps strips them on every edit; the caller of UpdatePost typically
// does not re-supply them, and PostActionRetainPropKeys also relies on this
// path to carry override_username/override_icon_url through interactive action
// updates (see app/integration_action.go).
var postIdentityPropsPreservedOnUpdate = []string{
PostPropsSilentNotification,
PostPropsFromBot,
PostPropsFromWebhook,
PostPropsFromOAuthApp,
PostPropsFromPlugin,
PostPropsOverrideUsername,
PostPropsOverrideIconURL,
PostPropsOverrideIconEmoji,
PostPropsWebhookDisplayName,
}
func (o *Post) PreserveIdentityPropsFrom(old *Post) {
if o == nil || old == nil {
return
}
for _, key := range postIdentityPropsPreservedOnUpdate {
if v := old.GetProp(key); v != nil {
o.AddProp(key, v)
}
}
}
// Remove any input data from the post object that is not user controlled
func (o *Post) SanitizeInput() {
o.DeleteAt = 0
o.RemoteId = new("")
if o.Metadata != nil {
o.Metadata.Embeds = nil
}
}
func (o *Post) ContainsIntegrationsReservedProps() []string {
return ContainsIntegrationsReservedProps(o.GetProps())
}
func (o *PostPatch) ContainsIntegrationsReservedProps() []string {
if o == nil || o.Props == nil {
return nil
}
return ContainsIntegrationsReservedProps(*o.Props)
}
func ContainsIntegrationsReservedProps(props StringInterface) []string {
foundProps := []string{}
if props != nil {
reservedProps := []string{
PostPropsFromWebhook,
PostPropsFromPlugin,
PostPropsSilentNotification,
PostPropsForceNotification,
PostPropsOverrideUsername,
PostPropsWebhookDisplayName,
PostPropsOverrideIconURL,
PostPropsOverrideIconEmoji,
PostPropsMmBlocksActions,
}
for _, key := range reservedProps {
if _, ok := props[key]; ok {
foundProps = append(foundProps, key)
}
}
}
return foundProps
}
func (o *Post) PreSave() {
if o.Id == "" {
o.Id = NewId()
}
o.OriginalId = ""
if o.CreateAt == 0 {
o.CreateAt = GetMillis()
}
o.UpdateAt = o.CreateAt
o.PreCommit()
}
func (o *Post) PreCommit() {
if o.GetProps() == nil {
o.SetProps(make(map[string]any))
}
if o.Filenames == nil {
o.Filenames = []string{}
}
if o.FileIds == nil {
o.FileIds = []string{}
}
o.GenerateActionIds()
// There's a rare bug where the client sends up duplicate FileIds so protect against that
o.FileIds = RemoveDuplicateStrings(o.FileIds)
}
func (o *Post) MakeNonNil() {
if o.GetProps() == nil {
o.SetProps(make(map[string]any))
}
}
func (o *Post) DelProp(key string) {
o.propsMu.Lock()
defer o.propsMu.Unlock()
propsCopy := make(map[string]any, len(o.Props)-1)
maps.Copy(propsCopy, o.Props)
delete(propsCopy, key)
o.Props = propsCopy
}
func (o *Post) AddProp(key string, value any) {
o.propsMu.Lock()
defer o.propsMu.Unlock()
propsCopy := make(map[string]any, len(o.Props)+1)
maps.Copy(propsCopy, o.Props)
propsCopy[key] = value
o.Props = propsCopy
}
func (o *Post) GetProps() StringInterface {
o.propsMu.RLock()
defer o.propsMu.RUnlock()
return o.Props
}
func (o *Post) SetProps(props StringInterface) {
o.propsMu.Lock()
defer o.propsMu.Unlock()
o.Props = props
}
func (o *Post) GetProp(key string) any {
o.propsMu.RLock()
defer o.propsMu.RUnlock()
return o.Props[key]
}
// HasUnsafeLinks reports whether props.unsafe_links is the string "true", meaning integrations
// have marked the post as carrying untrusted URLs (see PostPropsUnsafeLinks and post metadata handling).
func (o *Post) HasUnsafeLinks() bool {
v := o.GetProp(PostPropsUnsafeLinks)
if v == nil {
return false
}
s, ok := v.(string)
return ok && s == "true"
}
type AllStringsOptions struct {
OmitInteractiveBlocks bool
}
// AllStrings returns human-readable text from the post: the post Message as stored when it is not
// whitespace-only (same bytes as Message so markdown structure is preserved), then message attachment
// author name, title, text, pretext, footer, each attachment field title, each attachment field
// value (strings trimmed; non-strings rendered like fmt.Sprint for indexing), plus
// strings from interactive blocks (mm_blocks, Block Kit blocks, Adaptive cards).
// It is intended for mention checks, search indexing, and similar uses alongside integration metadata.
func (o *Post) AllStrings(opts AllStringsOptions) []string {
out := appendNonWhitespaceOnlyMessage(nil, o.Message)
for _, attachment := range o.Attachments() {
if attachment == nil {
continue
}
out = appendNonWhitespaceOnlyMessage(out, attachment.AuthorName)
out = appendNonWhitespaceOnlyMessage(out, attachment.Title)
out = appendNonWhitespaceOnlyMessage(out, attachment.Text)
out = appendNonWhitespaceOnlyMessage(out, attachment.Pretext)
out = appendNonWhitespaceOnlyMessage(out, attachment.Footer)
for _, field := range attachment.Fields {
if field == nil {
continue
}
out = appendNonWhitespaceOnlyMessage(out, field.Title)
if field.Value == nil {
continue
}
switch v := field.Value.(type) {
case string:
out = appendNonWhitespaceOnlyMessage(out, v)
default:
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
out = append(out, s)
}
}
}
}
if !opts.OmitInteractiveBlocks {
out = appendHumanReadableInteractiveStrings(o, out)
}
return out
}
// InteractiveBlocksImageURLs collects non-markdown image URLs from props.mm_blocks, props.blocks
// (Block Kit), props.cards (Adaptive Cards), and message attachments (image_url, thumb_url, author_icon, footer_icon).
// Direct mm_blocks image URLs, Block Kit image blocks, and Adaptive Card Image elements are included.
// Markdown ![alt](url) in interactive text is not included; merge with URLs from Post.AllStrings separately.
// Link preview restrictions (e.g. RestrictLinkPreviews) are not applied here; callers enforce policy when fetching metadata.
func (o *Post) InteractiveBlocksImageURLs(mmBlocksEnabled bool) []string {
props := o.GetProps()
if props == nil {
return nil
}
var out []string
if mmBlocksEnabled {
if raw, ok := props[PostPropsMmBlocks]; ok {
out = appendMmBlockImageURLs(out, raw)
}
if raw, ok := props[PostPropsBlockKitBlocks]; ok {
out = appendBlockKitImageURLs(out, raw)
}
if raw, ok := props[PostPropsAdaptiveCards]; ok {
out = appendAdaptiveCardImageURLs(out, raw)
}
}
return appendAttachmentsImageURLs(out, o.Attachments())
}
func appendNonWhitespaceOnlyMessage(out []string, s string) []string {
if strings.TrimSpace(s) == "" {
return out
}
return append(out, s)
}
// nonEmptyInteractivePayloadPropKeys lists non-empty interactive payload props (mm_blocks,
// Block Kit blocks, Adaptive cards, message attachments). The client uses a single priority; more than one is invalid.
func (o *Post) nonEmptyInteractivePayloadPropKeys() []string {
props := o.GetProps()
if props == nil {
return nil
}
var keys []string
if interactivePropJSONArrayNonEmpty(props[PostPropsMmBlocks]) {
keys = append(keys, PostPropsMmBlocks)
}
if interactivePropJSONArrayNonEmpty(props[PostPropsBlockKitBlocks]) {
keys = append(keys, PostPropsBlockKitBlocks)
}
if interactivePropJSONArrayNonEmpty(props[PostPropsAdaptiveCards]) {
keys = append(keys, PostPropsAdaptiveCards)
}
if len(o.Attachments()) > 0 {
keys = append(keys, PostPropsAttachments)
}
return keys
}
// ValidateProps checks all known props for validity.
// Currently, it logs warnings for invalid props rather than returning an error.
// In a future version, this will be updated to return errors for invalid props.
func (o *Post) ValidateProps(logger mlog.LoggerIFace) {
if err := o.propsIsValid(); err != nil {
logger.Warn(
"Invalid post props. In a future version this will result in an error. Please update your integration to be compliant.",
mlog.String("post_id", o.Id),
mlog.Err(err),
)
}
}
func (o *Post) propsIsValid() error {
var multiErr *multierror.Error
props := o.GetProps()
// Check basic props validity
if props == nil {
return nil
}
if props[PostPropsAddedUserId] != nil {
if addedUserID, ok := props[PostPropsAddedUserId].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a string"))
} else if !IsValidId(addedUserID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("added_user_id prop must be a valid user ID"))
}
}
if props[PostPropsDeleteBy] != nil {
if deleteByID, ok := props[PostPropsDeleteBy].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a string"))
} else if !IsValidId(deleteByID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("delete_by prop must be a valid user ID"))
}
}
// Validate integration props
if props[PostPropsOverrideIconURL] != nil {
if iconURL, ok := props[PostPropsOverrideIconURL].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a string"))
} else if iconURL == "" || !IsValidHTTPURL(iconURL) {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_url prop must be a valid URL"))
}
}
if props[PostPropsOverrideIconEmoji] != nil {
if _, ok := props[PostPropsOverrideIconEmoji].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_icon_emoji prop must be a string"))
}
}
if props[PostPropsOverrideUsername] != nil {
if _, ok := props[PostPropsOverrideUsername].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("override_username prop must be a string"))
}
}
if props[PostPropsFromWebhook] != nil {
if fromWebhook, ok := props[PostPropsFromWebhook].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be a string"))
} else if fromWebhook != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_webhook prop must be \"true\""))
}
}
if props[PostPropsFromBot] != nil {
if fromBot, ok := props[PostPropsFromBot].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be a string"))
} else if fromBot != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_bot prop must be \"true\""))
}
}
if props[PostPropsFromOAuthApp] != nil {
if fromOAuthApp, ok := props[PostPropsFromOAuthApp].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be a string"))
} else if fromOAuthApp != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_oauth_app prop must be \"true\""))
}
}
if props[PostPropsFromPlugin] != nil {
if fromPlugin, ok := props[PostPropsFromPlugin].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be a string"))
} else if fromPlugin != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("from_plugin prop must be \"true\""))
}
}
if props[PostPropsUnsafeLinks] != nil {
if unsafeLinks, ok := props[PostPropsUnsafeLinks].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be a string"))
} else if unsafeLinks != "true" {
multiErr = multierror.Append(multiErr, fmt.Errorf("unsafe_links prop must be \"true\""))
}
}
if props[PostPropsWebhookDisplayName] != nil {
if _, ok := props[PostPropsWebhookDisplayName].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("webhook_display_name prop must be a string"))
}
}
if props[PostPropsMentionHighlightDisabled] != nil {
if _, ok := props[PostPropsMentionHighlightDisabled].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("mention_highlight_disabled prop must be a boolean"))
}
}
if props[PostPropsGroupHighlightDisabled] != nil {
if _, ok := props[PostPropsGroupHighlightDisabled].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("disable_group_highlight prop must be a boolean"))
}
}
if props[PostPropsPreviewedPost] != nil {
if previewedPostID, ok := props[PostPropsPreviewedPost].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a string"))
} else if !IsValidId(previewedPostID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("previewed_post prop must be a valid post ID"))
}
}
if props[PostPropsForceNotification] != nil {
if _, ok := props[PostPropsForceNotification].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("force_notification prop must be a boolean"))
}
}
if props[PostPropsSilentNotification] != nil {
if _, ok := props[PostPropsSilentNotification].(bool); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("silent_notification prop must be a boolean"))
}
}
if props[PostPropsAIGeneratedByUserID] != nil {
if aiGenUserID, ok := props[PostPropsAIGeneratedByUserID].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by prop must be a string"))
} else if !IsValidId(aiGenUserID) {
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by prop must be a valid user ID"))
}
}
if props[PostPropsAIGeneratedByUsername] != nil {
if _, ok := props[PostPropsAIGeneratedByUsername].(string); !ok {
multiErr = multierror.Append(multiErr, fmt.Errorf("ai_generated_by_username prop must be a string"))
}
}
if err := ValidateMmBlocksActions(o); err != nil {
multiErr = multierror.Append(multiErr, fmt.Errorf("invalid mm_blocks_actions: %w", err))
}
for i, a := range o.Attachments() {
if err := a.IsValid(); err != nil {
multiErr = multierror.Append(multiErr, multierror.Prefix(err, fmt.Sprintf("message attachtment at index %d is invalid:", i)))
}
}
if keys := o.nonEmptyInteractivePayloadPropKeys(); len(keys) > 1 {
multiErr = multierror.Append(multiErr, fmt.Errorf(
"at most one interactive payload may be set among mm_blocks, blocks, cards, and attachments; found: %s",
strings.Join(keys, ", ")))
}
return multiErr.ErrorOrNil()
}
// IsSystemMessagePostType reports whether the given post type string is reserved for
// system-generated messages.
func IsSystemMessagePostType(postType string) bool {
return strings.HasPrefix(postType, PostSystemMessagePrefix)
}
func (o *Post) IsSystemMessage() bool {
return IsSystemMessagePostType(o.Type)
}
// IsAccessControlTeamMembershipNotification reports whether the post is a team
// membership-policy DM (removal/auto-add). These suppress email so a bulk policy
// sync doesn't mail every affected user.
func (o *Post) IsAccessControlTeamMembershipNotification() bool {
return o.Type == PostTypeAccessControlTeamRemoval || o.Type == PostTypeAccessControlTeamAddition
}
func (o *Post) HasForceNotification() bool {
switch v := o.GetProp(PostPropsForceNotification).(type) {
case bool:
return v
case string:
return v != ""
default:
return false
}
}
func (o *Post) HasSilentNotification() bool {
prop := o.GetProp(PostPropsSilentNotification)
if prop == nil {
return false
}
if silent, ok := prop.(bool); ok {
return silent
}
return false
}
// IsNotificationSuppressed reports whether the post should not trigger delivery-side
// notifications (push, email, mention counts, CRT thread_updated). Force notification wins.
func (o *Post) IsNotificationSuppressed() bool {
if o.HasForceNotification() {
return false
}
return o.HasSilentNotification()
}
// ExcludesFromChannelMessageCount reports whether the post should not advance channel message counts.
// Mirrors IsNotificationSuppressed so force_notification overrides silent_notification consistently
// across the notification and unread paths.
func (o *Post) ExcludesFromChannelMessageCount() bool {
return o.IsJoinLeaveMessage() || o.IsNotificationSuppressed()
}
// IsRemote returns true if the post originated on a remote cluster.
func (o *Post) IsRemote() bool {
return o.RemoteId != nil && *o.RemoteId != ""
}
// GetRemoteID safely returns the remoteID or empty string if not remote.
func (o *Post) GetRemoteID() string {
if o.RemoteId != nil {
return *o.RemoteId
}
return ""
}
// JoinLeaveMessagePostTypes returns the post types checked by IsJoinLeaveMessage.
func JoinLeaveMessagePostTypes() []string {
return []string{
PostTypeJoinLeave,
PostTypeAddRemove,
PostTypeJoinChannel,
PostTypeLeaveChannel,
PostTypeJoinTeam,
PostTypeLeaveTeam,
PostTypeAddToChannel,
PostTypeRemoveFromChannel,
PostTypeAddToTeam,
PostTypeRemoveFromTeam,
}
}
func (o *Post) IsJoinLeaveMessage() bool {
return slices.Contains(JoinLeaveMessagePostTypes(), o.Type)
}
// MembershipSystemPostTypes is the superset of JoinLeaveMessagePostTypes that
// additionally includes guest join/add types, used by the per-channel
// disable-join-leave-messages read filter.
func MembershipSystemPostTypes() []string {
return append(JoinLeaveMessagePostTypes(), PostTypeGuestJoinChannel, PostTypeAddGuestToChannel)
}
func IsMembershipSystemPost(post *Post) bool {
return post != nil && slices.Contains(MembershipSystemPostTypes(), post.Type)
}
// IsAddMembershipSystemPost returns true for posts that notify a user they were
// added to a channel or team.
func IsAddMembershipSystemPost(post *Post) bool {
if post == nil {
return false
}
return post.Type == PostTypeAddToChannel ||
post.Type == PostTypeAddGuestToChannel ||
post.Type == PostTypeAddToTeam
}
func (o *Post) Patch(patch *PostPatch) {
if patch.IsPinned != nil {
o.IsPinned = *patch.IsPinned
}
if patch.Message != nil {
o.Message = *patch.Message
}
if patch.Props != nil {
newProps := *patch.Props
o.SetProps(newProps)
}
if patch.FileIds != nil {
o.FileIds = *patch.FileIds
}
if patch.HasReactions != nil {
o.HasReactions = *patch.HasReactions
}
}
func (o *Post) ChannelMentions() []string {
return ChannelMentions(o.Message)
}
// Deprecated: Use ChannelMentionsAllWithOptions instead.
// ChannelMentionsAll returns all ~channel mentions from the same human-readable strings as
// model.Post.AllStrings (message, attachments, interactive blocks are omitted).
func (o *Post) ChannelMentionsAll() []string {
return ChannelMentionsFromStrings(o.AllStrings(AllStringsOptions{OmitInteractiveBlocks: false}))
}
// ChannelMentionsAllWithOptions returns all ~channel mentions from the same human-readable strings as
// model.Post.AllStrings (message, attachments, mm_blocks, blocks, cards). This is used by
// FillInPostProps to populate channel_mentions for rendering.
func (o *Post) ChannelMentionsAllWithOptions(opts AllStringsOptions) []string {
return ChannelMentionsFromStrings(o.AllStrings(opts))
}
// DisableMentionHighlights disables a posts mention highlighting and returns the first channel mention that was present in the message.
func (o *Post) DisableMentionHighlights() string {
mention, hasMentions := findAtChannelMention(o.Message)
if hasMentions {
o.AddProp(PostPropsMentionHighlightDisabled, true)
}
return mention
}
// DisableMentionHighlights disables mention highlighting for a post patch if required.
func (o *PostPatch) DisableMentionHighlights() {
if o.Message == nil {
return
}
if _, hasMentions := findAtChannelMention(*o.Message); hasMentions {
if o.Props == nil {
o.Props = &StringInterface{}
}
(*o.Props)[PostPropsMentionHighlightDisabled] = true
}
}
func findAtChannelMention(message string) (mention string, found bool) {
re := regexp.MustCompile(`(?i)\B@(channel|all|here)\b`)
matched := re.FindStringSubmatch(message)
if found = (len(matched) > 0); found {
mention = strings.ToLower(matched[0])
}
return
}
func (o *Post) Attachments() []*MessageAttachment {
if attachments, ok := o.GetProp(PostPropsAttachments).([]*MessageAttachment); ok {
return attachments
}
var ret []*MessageAttachment
if attachments, ok := o.GetProp(PostPropsAttachments).([]any); ok {
for _, attachment := range attachments {
if enc, err := json.Marshal(attachment); err == nil {
var decoded MessageAttachment
if json.Unmarshal(enc, &decoded) == nil {
// Ignoring nil actions
i := 0
for _, action := range decoded.Actions {
if action != nil {
decoded.Actions[i] = action
i++
}
}
decoded.Actions = decoded.Actions[:i]
// Ignoring nil fields
i = 0
for _, field := range decoded.Fields {
if field != nil {
decoded.Fields[i] = field
i++
}
}
decoded.Fields = decoded.Fields[:i]
ret = append(ret, &decoded)
}
}
}
}
return ret
}
func (o *Post) AttachmentsEqual(input *Post) bool {
attachments := o.Attachments()
inputAttachments := input.Attachments()
if len(attachments) != len(inputAttachments) {
return false
}
for i := range attachments {
if !attachments[i].Equals(inputAttachments[i]) {
return false
}
}
return true
}
var markdownDestinationEscaper = strings.NewReplacer(
`\`, `\\`,
`<`, `\<`,
`>`, `\>`,
`(`, `\(`,
`)`, `\)`,
)
// WithRewrittenImageURLs returns a new shallow copy of the post where the message has been
// rewritten via RewriteImageURLs. Interactive payloads (mm_blocks, Block Kit, Adaptive Cards) are
// not rewritten here; the client applies the image proxy via ExternalImage and related components.
func (o *Post) WithRewrittenImageURLs(f func(string) string) *Post {
pCopy := o.Clone()
pCopy.Message = RewriteImageURLs(o.Message, f)
if pCopy.MessageSource == "" && pCopy.Message != o.Message {
pCopy.MessageSource = o.Message
}
return pCopy
}
// RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced
// according to the function f. For each image URL, f will be invoked, and the resulting markdown
// will contain the URL returned by that invocation instead.
//
// Image URLs are destination URLs used in inline images or reference definitions that are used
// anywhere in the input markdown as an image.
func RewriteImageURLs(message string, f func(string) string) string {
if !strings.Contains(message, "![") {
return message
}
var ranges []markdown.Range
markdown.Inspect(message, func(blockOrInline any) bool {
switch v := blockOrInline.(type) {
case *markdown.ReferenceImage:
ranges = append(ranges, v.ReferenceDefinition.RawDestination)
case *markdown.InlineImage:
ranges = append(ranges, v.RawDestination)
default:
return true
}
return true
})
if ranges == nil {
return message
}
slices.SortFunc(ranges, func(a, b markdown.Range) int {
return cmp.Compare(a.Position, b.Position)
})
copyRanges := make([]markdown.Range, 0, len(ranges))
urls := make([]string, 0, len(ranges))
resultLength := len(message)
start := 0
for i, r := range ranges {
switch {
case i == 0:
case r.Position != ranges[i-1].Position:
start = ranges[i-1].End
default:
continue
}
original := message[r.Position:r.End]
replacement := markdownDestinationEscaper.Replace(f(markdown.Unescape(original)))
resultLength += len(replacement) - len(original)
copyRanges = append(copyRanges, markdown.Range{Position: start, End: r.Position})
urls = append(urls, replacement)
}
result := make([]byte, resultLength)
offset := 0
for i, r := range copyRanges {
offset += copy(result[offset:], message[r.Position:r.End])
offset += copy(result[offset:], urls[i])
}
copy(result[offset:], message[ranges[len(ranges)-1].End:])
return string(result)
}
func (o *Post) IsFromOAuthBot() bool {
props := o.GetProps()
return props[PostPropsFromWebhook] == "true" && props[PostPropsOverrideUsername] != ""
}
func (o *Post) ToNilIfInvalid() *Post {
if o.Id == "" {
return nil
}
return o
}
func (o *Post) ForPlugin() *Post {
p := o.Clone()
p.Metadata = nil
if p.Type == fmt.Sprintf("%sup_notification", PostCustomTypePrefix) {
p.DelProp("requested_features")
}
return p
}
func (o *Post) GetPreviewPost() *PreviewPost {
if o.Metadata == nil {
return nil
}
for _, embed := range o.Metadata.Embeds {
if embed != nil && embed.Type == PostEmbedPermalink {
if previewPost, ok := embed.Data.(*PreviewPost); ok {
return previewPost
}
}
}
return nil
}
func (o *Post) GetPreviewedPostProp() string {
if val, ok := o.GetProp(PostPropsPreviewedPost).(string); ok {
return val
}
return ""
}
func (o *Post) GetPriority() *PostPriority {
if o.Metadata == nil {
return nil
}
return o.Metadata.Priority
}
func (o *Post) GetPersistentNotification() *bool {
priority := o.GetPriority()
if priority == nil {
return nil
}
return priority.PersistentNotifications
}
func (o *Post) GetRequestedAck() *bool {
priority := o.GetPriority()
if priority == nil {
return nil
}
return priority.RequestedAck
}
func (o *Post) IsUrgent() bool {
postPriority := o.GetPriority()
if postPriority == nil {
return false
}
if postPriority.Priority == nil {
return false
}
return *postPriority.Priority == PostPriorityUrgent
}
func (o *Post) CleanPost() *Post {
o.Id = ""
o.CreateAt = 0
o.UpdateAt = 0
o.EditAt = 0
return o
}
type UpdatePostOptions struct {
SafeUpdate bool
IsRestorePost bool
// AllowMmBlocksActionsUpdate grants the caller permission to add,
// remove, or modify the mm_blocks_actions prop. Without it,
// non-integration sessions cannot change mm_blocks_actions and the
// prop is reset to its prior value. Set only from trusted paths (e.g.
// the post-action integration response handler which has already
// validated the incoming value).
AllowMmBlocksActionsUpdate bool
// AllowIdentityPropsUpdate grants the caller permission to change the
// display-identity props (override_username, override_icon_url,
// override_icon_emoji, webhook_display_name) on edit. Without it,
// UpdatePost restores those props from the prior post — so a regular
// user editing a bot's post cannot strip or replace the bot's identity,
// but it also blocks the bot from updating its own identity. Shared
// Channels federation receive (the only production caller) sets this
// to true, paired with a genuine RemoteId, to forward the remote's new
// override values into the saved post. SanitizeProps still strips the
// props on the incoming payload for a non-federated post — UpdatePost
// captures the values BEFORE that strip and re-applies them after
// PreserveIdentityPropsFrom when this flag is set.
//
// Empty-string semantics: a captured value of "" is treated as "not
// supplied" and the prior post's value is preserved. This means a
// hypothetical non-federated opted-in caller cannot CLEAR an override
// via UpdatePost this way — only set it to a new non-empty value.
// The one real caller (federation receive) is not subject to this
// limitation: for a federated post, SanitizeProps leaves the remote's
// complete prop set untouched (including a prop's absence), so a
// remote genuinely clearing an override propagates via the payload
// itself, not through this empty-string mechanism.
AllowIdentityPropsUpdate bool
}
func DefaultUpdatePostOptions() *UpdatePostOptions {
return &UpdatePostOptions{
SafeUpdate: false,
IsRestorePost: false,
}
}
type PreparePostForClientOpts struct {
IsNewPost bool
IsEditPost bool
IncludePriority bool
RetainContent bool
IncludeDeleted bool
}
// ReportPostOptions contains options for querying posts for reporting/compliance purposes
type ReportPostOptions struct {
ChannelId string `json:"channel_id"`
StartTime int64 `json:"start_time,omitempty"` // Optional: Start time for query range (unix timestamp in milliseconds)
TimeField string `json:"time_field,omitempty"` // "create_at" or "update_at" (default: "create_at")
SortDirection string `json:"sort_direction,omitempty"` // "asc" or "desc" (default: "asc")
PerPage int `json:"per_page,omitempty"` // Number of posts per page (default: 100, max: MaxReportingPerPage)
IncludeDeleted bool `json:"include_deleted,omitempty"` // Include deleted posts
ExcludeSystemPosts bool `json:"exclude_system_posts,omitempty"` // Exclude all system posts (any type starting with "system_")
IncludeMetadata bool `json:"include_metadata,omitempty"` // Include file info, reactions, etc.
}
type RewriteAction string
const (
RewriteActionCustom RewriteAction = "custom"
RewriteActionShorten RewriteAction = "shorten"
RewriteActionElaborate RewriteAction = "elaborate"
RewriteActionImproveWriting RewriteAction = "improve_writing"
RewriteActionFixSpelling RewriteAction = "fix_spelling"
RewriteActionSimplify RewriteAction = "simplify"
RewriteActionSummarize RewriteAction = "summarize"
)
type RewriteRequest struct {
AgentID string `json:"agent_id"`
Message string `json:"message"`
Action RewriteAction `json:"action"`
CustomPrompt string `json:"custom_prompt,omitempty"`
RootID string `json:"root_id,omitempty"`
}
type RewriteResponse struct {
RewrittenText string `json:"rewritten_text"`
}
const RewriteSystemPrompt = `You are a JSON API that rewrites text. Your response must be valid JSON only.
Return this exact format: {"rewritten_text":"content"}.
Do not use markdown, code blocks, or any formatting. Start with { and end with }.`
// ReportPostOptionsCursor contains cursor information for pagination.
// The cursor is an opaque base64-encoded string that encodes all pagination state.
// Clients should treat this as an opaque token and pass it back unchanged.
//
// Internal format (before base64 encoding):
//
// v1: "version:channel_id:time_field:include_deleted:exclude_system_posts:sort_direction:timestamp:post_id"
//
// Field order (general to specific):
// - version: Allows format evolution
// - channel_id: Which channel to query (filter)
// - time_field: Which timestamp column to use for ordering (filter/config)
// - include_deleted: Whether to include deleted posts (filter)
// - exclude_system_posts: Whether to exclude channel metadata system posts (filter)
// - sort_direction: Query direction ASC vs DESC (filter/config)
// - timestamp: The cursor position in time (pagination state)
// - post_id: Tie-breaker for posts with identical timestamps (pagination state)
//
// Version history:
// - v1: Initial format with all query-affecting parameters ordered general→specific, base64-encoded for opacity
// ReportPostOptionsCursor contains the pagination cursor for posts reporting.
//
// The cursor is opaque and self-contained:
// - It's base64-encoded and contains all query parameters (channel_id, time_field, sort_direction, etc.)
// - When a cursor is provided, query parameters in the request body are IGNORED
// - The cursor's embedded parameters take precedence over request body parameters
// - This allows clients to keep sending the same parameters on every page without errors
// - For the first page, omit the cursor field or set it to ""
type ReportPostOptionsCursor struct {
Cursor string `json:"cursor,omitempty"` // Optional: Opaque base64-encoded cursor string (omit or use "" for first request)
}
// ReportPostListResponse contains the response for cursor-based post reporting queries
type ReportPostListResponse struct {
Posts []*Post `json:"posts"`
NextCursor *ReportPostOptionsCursor `json:"next_cursor,omitempty"` // nil if no more pages
}
// ReportPostQueryParams contains the fully resolved query parameters for the store layer.
// This struct is used internally after cursor decoding and parameter resolution.
// The store layer receives these concrete parameters and executes the query.
type ReportPostQueryParams struct {
ChannelId string // Required: Channel to query
CursorTime int64 // Pagination cursor time position
CursorId string // Pagination cursor ID for tie-breaking
TimeField string // Resolved: "create_at" or "update_at"
SortDirection string // Resolved: "asc" or "desc"
IncludeDeleted bool // Resolved: include deleted posts
ExcludeSystemPosts bool // Resolved: exclude system posts
PerPage int // Number of posts per page (already validated)
}
// Validate validates the ReportPostQueryParams fields.
// This should be called after parameter resolution (from cursor or options) and before passing to the store layer.
// Note: PerPage is handled separately in the API layer (capped at 100-1000 range).
func (q *ReportPostQueryParams) Validate() *AppError {
// Validate ChannelId
if !IsValidId(q.ChannelId) {
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_channel_id", nil, "channel_id must be a valid 26-character ID", 400)
}
// Validate TimeField
if q.TimeField != ReportingTimeFieldCreateAt && q.TimeField != ReportingTimeFieldUpdateAt {
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_time_field", nil, fmt.Sprintf("time_field must be %q or %q", ReportingTimeFieldCreateAt, ReportingTimeFieldUpdateAt), 400)
}
// Validate SortDirection
if q.SortDirection != ReportingSortDirectionAsc && q.SortDirection != ReportingSortDirectionDesc {
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_sort_direction", nil, fmt.Sprintf("sort_direction must be %q or %q", ReportingSortDirectionAsc, ReportingSortDirectionDesc), 400)
}
// Validate CursorId - can be empty (first page) or must be a valid ID format (subsequent pages)
if q.CursorId != "" && !IsValidId(q.CursorId) {
return NewAppError("ReportPostQueryParams.Validate", "model.post.query_params.invalid_cursor_id", nil, "cursor_id must be a valid 26-character ID", 400)
}
// CursorTime is validated by the fact it's an int64
// PerPage is handled in API layer before calling Validate()
return nil
}
// EncodeReportPostCursor creates an opaque cursor string from pagination state.
// The cursor encodes all query-affecting parameters to ensure consistency across pages.
// The cursor is base64-encoded to ensure it's truly opaque and URL-safe.
//
// Internal format: "version:channel_id:time_field:include_deleted:exclude_system_posts:sort_direction:timestamp:post_id"
// Example (before encoding): "1:abc123xyz:create_at:false:true:asc:1635724800000:post456def"
func EncodeReportPostCursor(channelId string, timeField string, includeDeleted bool, excludeSystemPosts bool, sortDirection string, timestamp int64, postId string) string {
plainText := fmt.Sprintf("1:%s:%s:%t:%t:%s:%d:%s",
channelId,
timeField,
includeDeleted,
excludeSystemPosts,
sortDirection,
timestamp,
postId)
return base64.URLEncoding.EncodeToString([]byte(plainText))
}
// DecodeReportPostCursorV1 parses an opaque cursor string into query parameters.
// Returns a partially populated ReportPostQueryParams (missing PerPage which comes from the request).
func DecodeReportPostCursorV1(cursor string) (*ReportPostQueryParams, *AppError) {
decoded, err := base64.URLEncoding.DecodeString(cursor)
if err != nil {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_base64", nil, err.Error(), 400)
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 8 {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_format", nil, fmt.Sprintf("expected 8 parts, got %d", len(parts)), 400)
}
version, err := strconv.Atoi(parts[0])
if err != nil {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_version", nil, fmt.Sprintf("version must be an integer: %s", err.Error()), 400)
}
if version != 1 {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.unsupported_version", nil, fmt.Sprintf("version %d", version), 400)
}
includeDeleted, err := strconv.ParseBool(parts[3])
if err != nil {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_include_deleted", nil, fmt.Sprintf("include_deleted must be a boolean: %s", err.Error()), 400)
}
excludeSystemPosts, err := strconv.ParseBool(parts[4])
if err != nil {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_exclude_system_posts", nil, fmt.Sprintf("exclude_system_posts must be a boolean: %s", err.Error()), 400)
}
timestamp, err := strconv.ParseInt(parts[6], 10, 64)
if err != nil {
return nil, NewAppError("DecodeReportPostCursorV1", "model.post.decode_cursor.invalid_timestamp", nil, fmt.Sprintf("timestamp must be an integer: %s", err.Error()), 400)
}
return &ReportPostQueryParams{
ChannelId: parts[1],
CursorTime: timestamp,
CursorId: parts[7],
TimeField: parts[2],
SortDirection: parts[5],
IncludeDeleted: includeDeleted,
ExcludeSystemPosts: excludeSystemPosts,
}, nil
}