1// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2// See LICENSE.txt for license information. 3 4package model 5 6import ( 7 "encoding/json" 8 "errors" 9 "net/http" 10 "regexp" 11 "sort" 12 "strings" 13 "sync" 14 "unicode/utf8" 15 16 "github.com/mattermost/mattermost-server/v6/shared/markdown" 17) 18 19const ( 20 PostSystemMessagePrefix = "system_" 21 PostTypeDefault = "" 22 PostTypeSlackAttachment = "slack_attachment" 23 PostTypeSystemGeneric = "system_generic" 24 PostTypeJoinLeave = "system_join_leave" // Deprecated, use PostJoinChannel or PostLeaveChannel instead 25 PostTypeJoinChannel = "system_join_channel" 26 PostTypeGuestJoinChannel = "system_guest_join_channel" 27 PostTypeLeaveChannel = "system_leave_channel" 28 PostTypeJoinTeam = "system_join_team" 29 PostTypeLeaveTeam = "system_leave_team" 30 PostTypeAutoResponder = "system_auto_responder" 31 PostTypeAddRemove = "system_add_remove" // Deprecated, use PostAddToChannel or PostRemoveFromChannel instead 32 PostTypeAddToChannel = "system_add_to_channel" 33 PostTypeAddGuestToChannel = "system_add_guest_to_chan" 34 PostTypeRemoveFromChannel = "system_remove_from_channel" 35 PostTypeMoveChannel = "system_move_channel" 36 PostTypeAddToTeam = "system_add_to_team" 37 PostTypeRemoveFromTeam = "system_remove_from_team" 38 PostTypeHeaderChange = "system_header_change" 39 PostTypeDisplaynameChange = "system_displayname_change" 40 PostTypeConvertChannel = "system_convert_channel" 41 PostTypePurposeChange = "system_purpose_change" 42 PostTypeChannelDeleted = "system_channel_deleted" 43 PostTypeChannelRestored = "system_channel_restored" 44 PostTypeEphemeral = "system_ephemeral" 45 PostTypeChangeChannelPrivacy = "system_change_chan_privacy" 46 PostTypeAddBotTeamsChannels = "add_bot_teams_channels" 47 PostTypeSystemWarnMetricStatus = "warn_metric_status" 48 PostTypeMe = "me" 49 PostCustomTypePrefix = "custom_" 50 51 PostFileidsMaxRunes = 300 52 PostFilenamesMaxRunes = 4000 53 PostHashtagsMaxRunes = 1000 54 PostMessageMaxRunesV1 = 4000 55 PostMessageMaxBytesV2 = 65535 // Maximum size of a TEXT column in MySQL 56 PostMessageMaxRunesV2 = PostMessageMaxBytesV2 / 4 // Assume a worst-case representation 57 PostPropsMaxRunes = 800000 58 PostPropsMaxUserRunes = PostPropsMaxRunes - 40000 // Leave some room for system / pre-save modifications 59 60 PropsAddChannelMember = "add_channel_member" 61 62 PostPropsAddedUserId = "addedUserId" 63 PostPropsDeleteBy = "deleteBy" 64 PostPropsOverrideIconURL = "override_icon_url" 65 PostPropsOverrideIconEmoji = "override_icon_emoji" 66 67 PostPropsMentionHighlightDisabled = "mentionHighlightDisabled" 68 PostPropsGroupHighlightDisabled = "disable_group_highlight" 69 70 PostPropsPreviewedPost = "previewed_post" 71) 72 73type Post struct { 74 Id string `json:"id"` 75 CreateAt int64 `json:"create_at"` 76 UpdateAt int64 `json:"update_at"` 77 EditAt int64 `json:"edit_at"` 78 DeleteAt int64 `json:"delete_at"` 79 IsPinned bool `json:"is_pinned"` 80 UserId string `json:"user_id"` 81 ChannelId string `json:"channel_id"` 82 RootId string `json:"root_id"` 83 OriginalId string `json:"original_id"` 84 85 Message string `json:"message"` 86 // MessageSource will contain the message as submitted by the user if Message has been modified 87 // by Mattermost for presentation (e.g if an image proxy is being used). It should be used to 88 // populate edit boxes if present. 89 MessageSource string `json:"message_source,omitempty" db:"-"` 90 91 Type string `json:"type"` 92 propsMu sync.RWMutex `db:"-"` // Unexported mutex used to guard Post.Props. 93 Props StringInterface `json:"props"` // Deprecated: use GetProps() 94 Hashtags string `json:"hashtags"` 95 Filenames StringArray `json:"-"` // Deprecated, do not use this field any more 96 FileIds StringArray `json:"file_ids,omitempty"` 97 PendingPostId string `json:"pending_post_id" db:"-"` 98 HasReactions bool `json:"has_reactions,omitempty"` 99 RemoteId *string `json:"remote_id,omitempty"` 100 101 // Transient data populated before sending a post to the client 102 ReplyCount int64 `json:"reply_count" db:"-"` 103 LastReplyAt int64 `json:"last_reply_at" db:"-"` 104 Participants []*User `json:"participants" db:"-"` 105 IsFollowing *bool `json:"is_following,omitempty" db:"-"` // for root posts in collapsed thread mode indicates if the current user is following this thread 106 Metadata *PostMetadata `json:"metadata,omitempty" db:"-"` 107} 108 109type PostEphemeral struct { 110 UserID string `json:"user_id"` 111 Post *Post `json:"post"` 112} 113 114type PostPatch struct { 115 IsPinned *bool `json:"is_pinned"` 116 Message *string `json:"message"` 117 Props *StringInterface `json:"props"` 118 FileIds *StringArray `json:"file_ids"` 119 HasReactions *bool `json:"has_reactions"` 120} 121 122type SearchParameter struct { 123 Terms *string `json:"terms"` 124 IsOrSearch *bool `json:"is_or_search"` 125 TimeZoneOffset *int `json:"time_zone_offset"` 126 Page *int `json:"page"` 127 PerPage *int `json:"per_page"` 128 IncludeDeletedChannels *bool `json:"include_deleted_channels"` 129} 130 131type AnalyticsPostCountsOptions struct { 132 TeamId string 133 BotsOnly bool 134 YesterdayOnly bool 135} 136 137func (o *PostPatch) WithRewrittenImageURLs(f func(string) string) *PostPatch { 138 copy := *o 139 if copy.Message != nil { 140 *copy.Message = RewriteImageURLs(*o.Message, f) 141 } 142 return © 143} 144 145type PostForExport struct { 146 Post 147 TeamName string 148 ChannelName string 149 Username string 150 ReplyCount int 151} 152 153type DirectPostForExport struct { 154 Post 155 User string 156 ChannelMembers *[]string 157} 158 159type ReplyForExport struct { 160 Post 161 Username string 162} 163 164type PostForIndexing struct { 165 Post 166 TeamId string `json:"team_id"` 167 ParentCreateAt *int64 `json:"parent_create_at"` 168} 169 170type FileForIndexing struct { 171 FileInfo 172 ChannelId string `json:"channel_id"` 173 Content string `json:"content"` 174} 175 176// ShallowCopy is an utility function to shallow copy a Post to the given 177// destination without touching the internal RWMutex. 178func (o *Post) ShallowCopy(dst *Post) error { 179 if dst == nil { 180 return errors.New("dst cannot be nil") 181 } 182 o.propsMu.RLock() 183 defer o.propsMu.RUnlock() 184 dst.propsMu.Lock() 185 defer dst.propsMu.Unlock() 186 dst.Id = o.Id 187 dst.CreateAt = o.CreateAt 188 dst.UpdateAt = o.UpdateAt 189 dst.EditAt = o.EditAt 190 dst.DeleteAt = o.DeleteAt 191 dst.IsPinned = o.IsPinned 192 dst.UserId = o.UserId 193 dst.ChannelId = o.ChannelId 194 dst.RootId = o.RootId 195 dst.OriginalId = o.OriginalId 196 dst.Message = o.Message 197 dst.MessageSource = o.MessageSource 198 dst.Type = o.Type 199 dst.Props = o.Props 200 dst.Hashtags = o.Hashtags 201 dst.Filenames = o.Filenames 202 dst.FileIds = o.FileIds 203 dst.PendingPostId = o.PendingPostId 204 dst.HasReactions = o.HasReactions 205 dst.ReplyCount = o.ReplyCount 206 dst.Participants = o.Participants 207 dst.LastReplyAt = o.LastReplyAt 208 dst.Metadata = o.Metadata 209 if o.IsFollowing != nil { 210 dst.IsFollowing = NewBool(*o.IsFollowing) 211 } 212 dst.RemoteId = o.RemoteId 213 return nil 214} 215 216// Clone shallowly copies the post and returns the copy. 217func (o *Post) Clone() *Post { 218 copy := &Post{} 219 o.ShallowCopy(copy) 220 return copy 221} 222 223func (o *Post) ToJSON() (string, error) { 224 copy := o.Clone() 225 copy.StripActionIntegrations() 226 b, err := json.Marshal(copy) 227 return string(b), err 228} 229 230type GetPostsSinceOptions struct { 231 UserId string 232 ChannelId string 233 Time int64 234 SkipFetchThreads bool 235 CollapsedThreads bool 236 CollapsedThreadsExtended bool 237 SortAscending bool 238} 239 240type GetPostsSinceForSyncCursor struct { 241 LastPostUpdateAt int64 242 LastPostId string 243} 244 245type GetPostsSinceForSyncOptions struct { 246 ChannelId string 247 ExcludeRemoteId string 248 IncludeDeleted bool 249} 250 251type GetPostsOptions struct { 252 UserId string 253 ChannelId string 254 PostId string 255 Page int 256 PerPage int 257 SkipFetchThreads bool 258 CollapsedThreads bool 259 CollapsedThreadsExtended bool 260} 261 262func (o *Post) Etag() string { 263 return Etag(o.Id, o.UpdateAt) 264} 265 266func (o *Post) IsValid(maxPostSize int) *AppError { 267 if !IsValidId(o.Id) { 268 return NewAppError("Post.IsValid", "model.post.is_valid.id.app_error", nil, "", http.StatusBadRequest) 269 } 270 271 if o.CreateAt == 0 { 272 return NewAppError("Post.IsValid", "model.post.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest) 273 } 274 275 if o.UpdateAt == 0 { 276 return NewAppError("Post.IsValid", "model.post.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest) 277 } 278 279 if !IsValidId(o.UserId) { 280 return NewAppError("Post.IsValid", "model.post.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) 281 } 282 283 if !IsValidId(o.ChannelId) { 284 return NewAppError("Post.IsValid", "model.post.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) 285 } 286 287 if !(IsValidId(o.RootId) || o.RootId == "") { 288 return NewAppError("Post.IsValid", "model.post.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) 289 } 290 291 if !(len(o.OriginalId) == 26 || o.OriginalId == "") { 292 return NewAppError("Post.IsValid", "model.post.is_valid.original_id.app_error", nil, "", http.StatusBadRequest) 293 } 294 295 if utf8.RuneCountInString(o.Message) > maxPostSize { 296 return NewAppError("Post.IsValid", "model.post.is_valid.msg.app_error", nil, "id="+o.Id, http.StatusBadRequest) 297 } 298 299 if utf8.RuneCountInString(o.Hashtags) > PostHashtagsMaxRunes { 300 return NewAppError("Post.IsValid", "model.post.is_valid.hashtags.app_error", nil, "id="+o.Id, http.StatusBadRequest) 301 } 302 303 switch o.Type { 304 case 305 PostTypeDefault, 306 PostTypeSystemGeneric, 307 PostTypeJoinLeave, 308 PostTypeAutoResponder, 309 PostTypeAddRemove, 310 PostTypeJoinChannel, 311 PostTypeGuestJoinChannel, 312 PostTypeLeaveChannel, 313 PostTypeJoinTeam, 314 PostTypeLeaveTeam, 315 PostTypeAddToChannel, 316 PostTypeAddGuestToChannel, 317 PostTypeRemoveFromChannel, 318 PostTypeMoveChannel, 319 PostTypeAddToTeam, 320 PostTypeRemoveFromTeam, 321 PostTypeSlackAttachment, 322 PostTypeHeaderChange, 323 PostTypePurposeChange, 324 PostTypeDisplaynameChange, 325 PostTypeConvertChannel, 326 PostTypeChannelDeleted, 327 PostTypeChannelRestored, 328 PostTypeChangeChannelPrivacy, 329 PostTypeAddBotTeamsChannels, 330 PostTypeSystemWarnMetricStatus, 331 PostTypeMe: 332 default: 333 if !strings.HasPrefix(o.Type, PostCustomTypePrefix) { 334 return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) 335 } 336 } 337 338 if utf8.RuneCountInString(ArrayToJSON(o.Filenames)) > PostFilenamesMaxRunes { 339 return NewAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id, http.StatusBadRequest) 340 } 341 342 if utf8.RuneCountInString(ArrayToJSON(o.FileIds)) > PostFileidsMaxRunes { 343 return NewAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id, http.StatusBadRequest) 344 } 345 346 if utf8.RuneCountInString(StringInterfaceToJSON(o.GetProps())) > PostPropsMaxRunes { 347 return NewAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id, http.StatusBadRequest) 348 } 349 350 return nil 351} 352 353func (o *Post) SanitizeProps() { 354 if o == nil { 355 return 356 } 357 membersToSanitize := []string{ 358 PropsAddChannelMember, 359 } 360 361 for _, member := range membersToSanitize { 362 if _, ok := o.GetProps()[member]; ok { 363 o.DelProp(member) 364 } 365 } 366 for _, p := range o.Participants { 367 p.Sanitize(map[string]bool{}) 368 } 369} 370 371func (o *Post) PreSave() { 372 if o.Id == "" { 373 o.Id = NewId() 374 } 375 376 o.OriginalId = "" 377 378 if o.CreateAt == 0 { 379 o.CreateAt = GetMillis() 380 } 381 382 o.UpdateAt = o.CreateAt 383 o.PreCommit() 384} 385 386func (o *Post) PreCommit() { 387 if o.GetProps() == nil { 388 o.SetProps(make(map[string]interface{})) 389 } 390 391 if o.Filenames == nil { 392 o.Filenames = []string{} 393 } 394 395 if o.FileIds == nil { 396 o.FileIds = []string{} 397 } 398 399 o.GenerateActionIds() 400 401 // There's a rare bug where the client sends up duplicate FileIds so protect against that 402 o.FileIds = RemoveDuplicateStrings(o.FileIds) 403} 404 405func (o *Post) MakeNonNil() { 406 if o.GetProps() == nil { 407 o.SetProps(make(map[string]interface{})) 408 } 409} 410 411func (o *Post) DelProp(key string) { 412 o.propsMu.Lock() 413 defer o.propsMu.Unlock() 414 propsCopy := make(map[string]interface{}, len(o.Props)-1) 415 for k, v := range o.Props { 416 propsCopy[k] = v 417 } 418 delete(propsCopy, key) 419 o.Props = propsCopy 420} 421 422func (o *Post) AddProp(key string, value interface{}) { 423 o.propsMu.Lock() 424 defer o.propsMu.Unlock() 425 propsCopy := make(map[string]interface{}, len(o.Props)+1) 426 for k, v := range o.Props { 427 propsCopy[k] = v 428 } 429 propsCopy[key] = value 430 o.Props = propsCopy 431} 432 433func (o *Post) GetProps() StringInterface { 434 o.propsMu.RLock() 435 defer o.propsMu.RUnlock() 436 return o.Props 437} 438 439func (o *Post) SetProps(props StringInterface) { 440 o.propsMu.Lock() 441 defer o.propsMu.Unlock() 442 o.Props = props 443} 444 445func (o *Post) GetProp(key string) interface{} { 446 o.propsMu.RLock() 447 defer o.propsMu.RUnlock() 448 return o.Props[key] 449} 450 451func (o *Post) IsSystemMessage() bool { 452 return len(o.Type) >= len(PostSystemMessagePrefix) && o.Type[:len(PostSystemMessagePrefix)] == PostSystemMessagePrefix 453} 454 455// IsRemote returns true if the post originated on a remote cluster. 456func (o *Post) IsRemote() bool { 457 return o.RemoteId != nil && *o.RemoteId != "" 458} 459 460// GetRemoteID safely returns the remoteID or empty string if not remote. 461func (o *Post) GetRemoteID() string { 462 if o.RemoteId != nil { 463 return *o.RemoteId 464 } 465 return "" 466} 467 468func (o *Post) IsJoinLeaveMessage() bool { 469 return o.Type == PostTypeJoinLeave || 470 o.Type == PostTypeAddRemove || 471 o.Type == PostTypeJoinChannel || 472 o.Type == PostTypeLeaveChannel || 473 o.Type == PostTypeJoinTeam || 474 o.Type == PostTypeLeaveTeam || 475 o.Type == PostTypeAddToChannel || 476 o.Type == PostTypeRemoveFromChannel || 477 o.Type == PostTypeAddToTeam || 478 o.Type == PostTypeRemoveFromTeam 479} 480 481func (o *Post) Patch(patch *PostPatch) { 482 if patch.IsPinned != nil { 483 o.IsPinned = *patch.IsPinned 484 } 485 486 if patch.Message != nil { 487 o.Message = *patch.Message 488 } 489 490 if patch.Props != nil { 491 newProps := *patch.Props 492 o.SetProps(newProps) 493 } 494 495 if patch.FileIds != nil { 496 o.FileIds = *patch.FileIds 497 } 498 499 if patch.HasReactions != nil { 500 o.HasReactions = *patch.HasReactions 501 } 502} 503 504func (o *Post) ChannelMentions() []string { 505 return ChannelMentions(o.Message) 506} 507 508// DisableMentionHighlights disables a posts mention highlighting and returns the first channel mention that was present in the message. 509func (o *Post) DisableMentionHighlights() string { 510 mention, hasMentions := findAtChannelMention(o.Message) 511 if hasMentions { 512 o.AddProp(PostPropsMentionHighlightDisabled, true) 513 } 514 return mention 515} 516 517// DisableMentionHighlights disables mention highlighting for a post patch if required. 518func (o *PostPatch) DisableMentionHighlights() { 519 if o.Message == nil { 520 return 521 } 522 if _, hasMentions := findAtChannelMention(*o.Message); hasMentions { 523 if o.Props == nil { 524 o.Props = &StringInterface{} 525 } 526 (*o.Props)[PostPropsMentionHighlightDisabled] = true 527 } 528} 529 530func findAtChannelMention(message string) (mention string, found bool) { 531 re := regexp.MustCompile(`(?i)\B@(channel|all|here)\b`) 532 matched := re.FindStringSubmatch(message) 533 if found = (len(matched) > 0); found { 534 mention = strings.ToLower(matched[0]) 535 } 536 return 537} 538 539func (o *Post) Attachments() []*SlackAttachment { 540 if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok { 541 return attachments 542 } 543 var ret []*SlackAttachment 544 if attachments, ok := o.GetProp("attachments").([]interface{}); ok { 545 for _, attachment := range attachments { 546 if enc, err := json.Marshal(attachment); err == nil { 547 var decoded SlackAttachment 548 if json.Unmarshal(enc, &decoded) == nil { 549 // Ignoring nil actions 550 i := 0 551 for _, action := range decoded.Actions { 552 if action != nil { 553 decoded.Actions[i] = action 554 i++ 555 } 556 } 557 decoded.Actions = decoded.Actions[:i] 558 559 // Ignoring nil fields 560 i = 0 561 for _, field := range decoded.Fields { 562 if field != nil { 563 decoded.Fields[i] = field 564 i++ 565 } 566 } 567 decoded.Fields = decoded.Fields[:i] 568 ret = append(ret, &decoded) 569 } 570 } 571 } 572 } 573 return ret 574} 575 576func (o *Post) AttachmentsEqual(input *Post) bool { 577 attachments := o.Attachments() 578 inputAttachments := input.Attachments() 579 580 if len(attachments) != len(inputAttachments) { 581 return false 582 } 583 584 for i := range attachments { 585 if !attachments[i].Equals(inputAttachments[i]) { 586 return false 587 } 588 } 589 590 return true 591} 592 593var markdownDestinationEscaper = strings.NewReplacer( 594 `\`, `\\`, 595 `<`, `\<`, 596 `>`, `\>`, 597 `(`, `\(`, 598 `)`, `\)`, 599) 600 601// WithRewrittenImageURLs returns a new shallow copy of the post where the message has been 602// rewritten via RewriteImageURLs. 603func (o *Post) WithRewrittenImageURLs(f func(string) string) *Post { 604 copy := o.Clone() 605 copy.Message = RewriteImageURLs(o.Message, f) 606 if copy.MessageSource == "" && copy.Message != o.Message { 607 copy.MessageSource = o.Message 608 } 609 return copy 610} 611 612// RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced 613// according to the function f. For each image URL, f will be invoked, and the resulting markdown 614// will contain the URL returned by that invocation instead. 615// 616// Image URLs are destination URLs used in inline images or reference definitions that are used 617// anywhere in the input markdown as an image. 618func RewriteImageURLs(message string, f func(string) string) string { 619 if !strings.Contains(message, "![") { 620 return message 621 } 622 623 var ranges []markdown.Range 624 625 markdown.Inspect(message, func(blockOrInline interface{}) bool { 626 switch v := blockOrInline.(type) { 627 case *markdown.ReferenceImage: 628 ranges = append(ranges, v.ReferenceDefinition.RawDestination) 629 case *markdown.InlineImage: 630 ranges = append(ranges, v.RawDestination) 631 default: 632 return true 633 } 634 return true 635 }) 636 637 if ranges == nil { 638 return message 639 } 640 641 sort.Slice(ranges, func(i, j int) bool { 642 return ranges[i].Position < ranges[j].Position 643 }) 644 645 copyRanges := make([]markdown.Range, 0, len(ranges)) 646 urls := make([]string, 0, len(ranges)) 647 resultLength := len(message) 648 649 start := 0 650 for i, r := range ranges { 651 switch { 652 case i == 0: 653 case r.Position != ranges[i-1].Position: 654 start = ranges[i-1].End 655 default: 656 continue 657 } 658 original := message[r.Position:r.End] 659 replacement := markdownDestinationEscaper.Replace(f(markdown.Unescape(original))) 660 resultLength += len(replacement) - len(original) 661 copyRanges = append(copyRanges, markdown.Range{Position: start, End: r.Position}) 662 urls = append(urls, replacement) 663 } 664 665 result := make([]byte, resultLength) 666 667 offset := 0 668 for i, r := range copyRanges { 669 offset += copy(result[offset:], message[r.Position:r.End]) 670 offset += copy(result[offset:], urls[i]) 671 } 672 copy(result[offset:], message[ranges[len(ranges)-1].End:]) 673 674 return string(result) 675} 676 677func (o *Post) IsFromOAuthBot() bool { 678 props := o.GetProps() 679 return props["from_webhook"] == "true" && props["override_username"] != "" 680} 681 682func (o *Post) ToNilIfInvalid() *Post { 683 if o.Id == "" { 684 return nil 685 } 686 return o 687} 688 689func (o *Post) RemovePreviewPost() { 690 if o.Metadata == nil || o.Metadata.Embeds == nil { 691 return 692 } 693 n := 0 694 for _, embed := range o.Metadata.Embeds { 695 if embed.Type != PostEmbedPermalink { 696 o.Metadata.Embeds[n] = embed 697 n++ 698 } 699 } 700 o.Metadata.Embeds = o.Metadata.Embeds[:n] 701} 702 703func (o *Post) GetPreviewPost() *PreviewPost { 704 for _, embed := range o.Metadata.Embeds { 705 if embed.Type == PostEmbedPermalink { 706 if previewPost, ok := embed.Data.(*PreviewPost); ok { 707 return previewPost 708 } 709 } 710 } 711 return nil 712} 713 714func (o *Post) GetPreviewedPostProp() string { 715 if val, ok := o.GetProp(PostPropsPreviewedPost).(string); ok { 716 return val 717 } 718 return "" 719} 720