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 "io" 10 "net/http" 11 "regexp" 12 "sort" 13 "strings" 14 "sync" 15 "unicode/utf8" 16 17 "github.com/mattermost/mattermost-server/v5/utils/markdown" 18) 19 20const ( 21 POST_SYSTEM_MESSAGE_PREFIX = "system_" 22 POST_DEFAULT = "" 23 POST_SLACK_ATTACHMENT = "slack_attachment" 24 POST_SYSTEM_GENERIC = "system_generic" 25 POST_JOIN_LEAVE = "system_join_leave" // Deprecated, use POST_JOIN_CHANNEL or POST_LEAVE_CHANNEL instead 26 POST_JOIN_CHANNEL = "system_join_channel" 27 POST_GUEST_JOIN_CHANNEL = "system_guest_join_channel" 28 POST_LEAVE_CHANNEL = "system_leave_channel" 29 POST_JOIN_TEAM = "system_join_team" 30 POST_LEAVE_TEAM = "system_leave_team" 31 POST_AUTO_RESPONDER = "system_auto_responder" 32 POST_ADD_REMOVE = "system_add_remove" // Deprecated, use POST_ADD_TO_CHANNEL or POST_REMOVE_FROM_CHANNEL instead 33 POST_ADD_TO_CHANNEL = "system_add_to_channel" 34 POST_ADD_GUEST_TO_CHANNEL = "system_add_guest_to_chan" 35 POST_REMOVE_FROM_CHANNEL = "system_remove_from_channel" 36 POST_MOVE_CHANNEL = "system_move_channel" 37 POST_ADD_TO_TEAM = "system_add_to_team" 38 POST_REMOVE_FROM_TEAM = "system_remove_from_team" 39 POST_HEADER_CHANGE = "system_header_change" 40 POST_DISPLAYNAME_CHANGE = "system_displayname_change" 41 POST_CONVERT_CHANNEL = "system_convert_channel" 42 POST_PURPOSE_CHANGE = "system_purpose_change" 43 POST_CHANNEL_DELETED = "system_channel_deleted" 44 POST_CHANNEL_RESTORED = "system_channel_restored" 45 POST_EPHEMERAL = "system_ephemeral" 46 POST_CHANGE_CHANNEL_PRIVACY = "system_change_chan_privacy" 47 POST_ADD_BOT_TEAMS_CHANNELS = "add_bot_teams_channels" 48 POST_FILEIDS_MAX_RUNES = 150 49 POST_FILENAMES_MAX_RUNES = 4000 50 POST_HASHTAGS_MAX_RUNES = 1000 51 POST_MESSAGE_MAX_RUNES_V1 = 4000 52 POST_MESSAGE_MAX_BYTES_V2 = 65535 // Maximum size of a TEXT column in MySQL 53 POST_MESSAGE_MAX_RUNES_V2 = POST_MESSAGE_MAX_BYTES_V2 / 4 // Assume a worst-case representation 54 POST_PROPS_MAX_RUNES = 8000 55 POST_PROPS_MAX_USER_RUNES = POST_PROPS_MAX_RUNES - 400 // Leave some room for system / pre-save modifications 56 POST_CUSTOM_TYPE_PREFIX = "custom_" 57 POST_ME = "me" 58 PROPS_ADD_CHANNEL_MEMBER = "add_channel_member" 59 60 POST_PROPS_ADDED_USER_ID = "addedUserId" 61 POST_PROPS_DELETE_BY = "deleteBy" 62 POST_PROPS_OVERRIDE_ICON_URL = "override_icon_url" 63 POST_PROPS_OVERRIDE_ICON_EMOJI = "override_icon_emoji" 64 65 POST_PROPS_MENTION_HIGHLIGHT_DISABLED = "mentionHighlightDisabled" 66 POST_PROPS_GROUP_HIGHLIGHT_DISABLED = "disable_group_highlight" 67 POST_SYSTEM_WARN_METRIC_STATUS = "warn_metric_status" 68) 69 70var AT_MENTION_PATTEN = regexp.MustCompile(`\B@`) 71 72type Post struct { 73 Id string `json:"id"` 74 CreateAt int64 `json:"create_at"` 75 UpdateAt int64 `json:"update_at"` 76 EditAt int64 `json:"edit_at"` 77 DeleteAt int64 `json:"delete_at"` 78 IsPinned bool `json:"is_pinned"` 79 UserId string `json:"user_id"` 80 ChannelId string `json:"channel_id"` 81 RootId string `json:"root_id"` 82 ParentId string `json:"parent_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:"filenames,omitempty"` // 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 100 // Transient data populated before sending a post to the client 101 ReplyCount int64 `json:"reply_count" db:"-"` 102 Metadata *PostMetadata `json:"metadata,omitempty" db:"-"` 103} 104 105type PostEphemeral struct { 106 UserID string `json:"user_id"` 107 Post *Post `json:"post"` 108} 109 110type PostPatch struct { 111 IsPinned *bool `json:"is_pinned"` 112 Message *string `json:"message"` 113 Props *StringInterface `json:"props"` 114 FileIds *StringArray `json:"file_ids"` 115 HasReactions *bool `json:"has_reactions"` 116} 117 118type SearchParameter struct { 119 Terms *string `json:"terms"` 120 IsOrSearch *bool `json:"is_or_search"` 121 TimeZoneOffset *int `json:"time_zone_offset"` 122 Page *int `json:"page"` 123 PerPage *int `json:"per_page"` 124 IncludeDeletedChannels *bool `json:"include_deleted_channels"` 125} 126 127type AnalyticsPostCountsOptions struct { 128 TeamId string 129 BotsOnly bool 130 YesterdayOnly bool 131} 132 133func (o *PostPatch) WithRewrittenImageURLs(f func(string) string) *PostPatch { 134 copy := *o 135 if copy.Message != nil { 136 *copy.Message = RewriteImageURLs(*o.Message, f) 137 } 138 return © 139} 140 141type PostForExport struct { 142 Post 143 TeamName string 144 ChannelName string 145 Username string 146 ReplyCount int 147} 148 149type DirectPostForExport struct { 150 Post 151 User string 152 ChannelMembers *[]string 153} 154 155type ReplyForExport struct { 156 Post 157 Username string 158} 159 160type PostForIndexing struct { 161 Post 162 TeamId string `json:"team_id"` 163 ParentCreateAt *int64 `json:"parent_create_at"` 164} 165 166// ShallowCopy is an utility function to shallow copy a Post to the given 167// destination without touching the internal RWMutex. 168func (o *Post) ShallowCopy(dst *Post) error { 169 if dst == nil { 170 return errors.New("dst cannot be nil") 171 } 172 o.propsMu.RLock() 173 defer o.propsMu.RUnlock() 174 dst.propsMu.Lock() 175 defer dst.propsMu.Unlock() 176 dst.Id = o.Id 177 dst.CreateAt = o.CreateAt 178 dst.UpdateAt = o.UpdateAt 179 dst.EditAt = o.EditAt 180 dst.DeleteAt = o.DeleteAt 181 dst.IsPinned = o.IsPinned 182 dst.UserId = o.UserId 183 dst.ChannelId = o.ChannelId 184 dst.RootId = o.RootId 185 dst.ParentId = o.ParentId 186 dst.OriginalId = o.OriginalId 187 dst.Message = o.Message 188 dst.MessageSource = o.MessageSource 189 dst.Type = o.Type 190 dst.Props = o.Props 191 dst.Hashtags = o.Hashtags 192 dst.Filenames = o.Filenames 193 dst.FileIds = o.FileIds 194 dst.PendingPostId = o.PendingPostId 195 dst.HasReactions = o.HasReactions 196 dst.ReplyCount = o.ReplyCount 197 dst.Metadata = o.Metadata 198 return nil 199} 200 201// Clone shallowly copies the post and returns the copy. 202func (o *Post) Clone() *Post { 203 copy := &Post{} 204 o.ShallowCopy(copy) 205 return copy 206} 207 208func (o *Post) ToJson() string { 209 copy := o.Clone() 210 copy.StripActionIntegrations() 211 b, _ := json.Marshal(copy) 212 return string(b) 213} 214 215func (o *Post) ToUnsanitizedJson() string { 216 b, _ := json.Marshal(o) 217 return string(b) 218} 219 220type GetPostsSinceOptions struct { 221 ChannelId string 222 Time int64 223 SkipFetchThreads bool 224} 225 226type GetPostsOptions struct { 227 ChannelId string 228 PostId string 229 Page int 230 PerPage int 231 SkipFetchThreads bool 232} 233 234func PostFromJson(data io.Reader) *Post { 235 var o *Post 236 json.NewDecoder(data).Decode(&o) 237 return o 238} 239 240func (o *Post) Etag() string { 241 return Etag(o.Id, o.UpdateAt) 242} 243 244func (o *Post) IsValid(maxPostSize int) *AppError { 245 if !IsValidId(o.Id) { 246 return NewAppError("Post.IsValid", "model.post.is_valid.id.app_error", nil, "", http.StatusBadRequest) 247 } 248 249 if o.CreateAt == 0 { 250 return NewAppError("Post.IsValid", "model.post.is_valid.create_at.app_error", nil, "id="+o.Id, http.StatusBadRequest) 251 } 252 253 if o.UpdateAt == 0 { 254 return NewAppError("Post.IsValid", "model.post.is_valid.update_at.app_error", nil, "id="+o.Id, http.StatusBadRequest) 255 } 256 257 if !IsValidId(o.UserId) { 258 return NewAppError("Post.IsValid", "model.post.is_valid.user_id.app_error", nil, "", http.StatusBadRequest) 259 } 260 261 if !IsValidId(o.ChannelId) { 262 return NewAppError("Post.IsValid", "model.post.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) 263 } 264 265 if !(IsValidId(o.RootId) || len(o.RootId) == 0) { 266 return NewAppError("Post.IsValid", "model.post.is_valid.root_id.app_error", nil, "", http.StatusBadRequest) 267 } 268 269 if !(IsValidId(o.ParentId) || len(o.ParentId) == 0) { 270 return NewAppError("Post.IsValid", "model.post.is_valid.parent_id.app_error", nil, "", http.StatusBadRequest) 271 } 272 273 if len(o.ParentId) == 26 && len(o.RootId) == 0 { 274 return NewAppError("Post.IsValid", "model.post.is_valid.root_parent.app_error", nil, "", http.StatusBadRequest) 275 } 276 277 if !(len(o.OriginalId) == 26 || len(o.OriginalId) == 0) { 278 return NewAppError("Post.IsValid", "model.post.is_valid.original_id.app_error", nil, "", http.StatusBadRequest) 279 } 280 281 if utf8.RuneCountInString(o.Message) > maxPostSize { 282 return NewAppError("Post.IsValid", "model.post.is_valid.msg.app_error", nil, "id="+o.Id, http.StatusBadRequest) 283 } 284 285 if utf8.RuneCountInString(o.Hashtags) > POST_HASHTAGS_MAX_RUNES { 286 return NewAppError("Post.IsValid", "model.post.is_valid.hashtags.app_error", nil, "id="+o.Id, http.StatusBadRequest) 287 } 288 289 switch o.Type { 290 case 291 POST_DEFAULT, 292 POST_SYSTEM_GENERIC, 293 POST_JOIN_LEAVE, 294 POST_AUTO_RESPONDER, 295 POST_ADD_REMOVE, 296 POST_JOIN_CHANNEL, 297 POST_GUEST_JOIN_CHANNEL, 298 POST_LEAVE_CHANNEL, 299 POST_JOIN_TEAM, 300 POST_LEAVE_TEAM, 301 POST_ADD_TO_CHANNEL, 302 POST_ADD_GUEST_TO_CHANNEL, 303 POST_REMOVE_FROM_CHANNEL, 304 POST_MOVE_CHANNEL, 305 POST_ADD_TO_TEAM, 306 POST_REMOVE_FROM_TEAM, 307 POST_SLACK_ATTACHMENT, 308 POST_HEADER_CHANGE, 309 POST_PURPOSE_CHANGE, 310 POST_DISPLAYNAME_CHANGE, 311 POST_CONVERT_CHANNEL, 312 POST_CHANNEL_DELETED, 313 POST_CHANNEL_RESTORED, 314 POST_CHANGE_CHANNEL_PRIVACY, 315 POST_ME, 316 POST_ADD_BOT_TEAMS_CHANNELS, 317 POST_SYSTEM_WARN_METRIC_STATUS: 318 default: 319 if !strings.HasPrefix(o.Type, POST_CUSTOM_TYPE_PREFIX) { 320 return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) 321 } 322 } 323 324 if utf8.RuneCountInString(ArrayToJson(o.Filenames)) > POST_FILENAMES_MAX_RUNES { 325 return NewAppError("Post.IsValid", "model.post.is_valid.filenames.app_error", nil, "id="+o.Id, http.StatusBadRequest) 326 } 327 328 if utf8.RuneCountInString(ArrayToJson(o.FileIds)) > POST_FILEIDS_MAX_RUNES { 329 return NewAppError("Post.IsValid", "model.post.is_valid.file_ids.app_error", nil, "id="+o.Id, http.StatusBadRequest) 330 } 331 332 if utf8.RuneCountInString(StringInterfaceToJson(o.GetProps())) > POST_PROPS_MAX_RUNES { 333 return NewAppError("Post.IsValid", "model.post.is_valid.props.app_error", nil, "id="+o.Id, http.StatusBadRequest) 334 } 335 336 return nil 337} 338 339func (o *Post) SanitizeProps() { 340 membersToSanitize := []string{ 341 PROPS_ADD_CHANNEL_MEMBER, 342 } 343 344 for _, member := range membersToSanitize { 345 if _, ok := o.GetProps()[member]; ok { 346 o.DelProp(member) 347 } 348 } 349} 350 351func (o *Post) PreSave() { 352 if o.Id == "" { 353 o.Id = NewId() 354 } 355 356 o.OriginalId = "" 357 358 if o.CreateAt == 0 { 359 o.CreateAt = GetMillis() 360 } 361 362 o.UpdateAt = o.CreateAt 363 o.PreCommit() 364} 365 366func (o *Post) PreCommit() { 367 if o.GetProps() == nil { 368 o.SetProps(make(map[string]interface{})) 369 } 370 371 if o.Filenames == nil { 372 o.Filenames = []string{} 373 } 374 375 if o.FileIds == nil { 376 o.FileIds = []string{} 377 } 378 379 o.GenerateActionIds() 380 381 // There's a rare bug where the client sends up duplicate FileIds so protect against that 382 o.FileIds = RemoveDuplicateStrings(o.FileIds) 383} 384 385func (o *Post) MakeNonNil() { 386 if o.GetProps() == nil { 387 o.SetProps(make(map[string]interface{})) 388 } 389} 390 391func (o *Post) DelProp(key string) { 392 o.propsMu.Lock() 393 defer o.propsMu.Unlock() 394 propsCopy := make(map[string]interface{}, len(o.Props)-1) 395 for k, v := range o.Props { 396 propsCopy[k] = v 397 } 398 delete(propsCopy, key) 399 o.Props = propsCopy 400} 401 402func (o *Post) AddProp(key string, value interface{}) { 403 o.propsMu.Lock() 404 defer o.propsMu.Unlock() 405 propsCopy := make(map[string]interface{}, len(o.Props)+1) 406 for k, v := range o.Props { 407 propsCopy[k] = v 408 } 409 propsCopy[key] = value 410 o.Props = propsCopy 411} 412 413func (o *Post) GetProps() StringInterface { 414 o.propsMu.RLock() 415 defer o.propsMu.RUnlock() 416 return o.Props 417} 418 419func (o *Post) SetProps(props StringInterface) { 420 o.propsMu.Lock() 421 defer o.propsMu.Unlock() 422 o.Props = props 423} 424 425func (o *Post) GetProp(key string) interface{} { 426 o.propsMu.RLock() 427 defer o.propsMu.RUnlock() 428 return o.Props[key] 429} 430 431func (o *Post) IsSystemMessage() bool { 432 return len(o.Type) >= len(POST_SYSTEM_MESSAGE_PREFIX) && o.Type[:len(POST_SYSTEM_MESSAGE_PREFIX)] == POST_SYSTEM_MESSAGE_PREFIX 433} 434 435func (o *Post) IsJoinLeaveMessage() bool { 436 return o.Type == POST_JOIN_LEAVE || 437 o.Type == POST_ADD_REMOVE || 438 o.Type == POST_JOIN_CHANNEL || 439 o.Type == POST_LEAVE_CHANNEL || 440 o.Type == POST_JOIN_TEAM || 441 o.Type == POST_LEAVE_TEAM || 442 o.Type == POST_ADD_TO_CHANNEL || 443 o.Type == POST_REMOVE_FROM_CHANNEL || 444 o.Type == POST_ADD_TO_TEAM || 445 o.Type == POST_REMOVE_FROM_TEAM 446} 447 448func (o *Post) Patch(patch *PostPatch) { 449 if patch.IsPinned != nil { 450 o.IsPinned = *patch.IsPinned 451 } 452 453 if patch.Message != nil { 454 o.Message = *patch.Message 455 } 456 457 if patch.Props != nil { 458 newProps := *patch.Props 459 o.SetProps(newProps) 460 } 461 462 if patch.FileIds != nil { 463 o.FileIds = *patch.FileIds 464 } 465 466 if patch.HasReactions != nil { 467 o.HasReactions = *patch.HasReactions 468 } 469} 470 471func (o *PostPatch) ToJson() string { 472 b, err := json.Marshal(o) 473 if err != nil { 474 return "" 475 } 476 477 return string(b) 478} 479 480func PostPatchFromJson(data io.Reader) *PostPatch { 481 decoder := json.NewDecoder(data) 482 var post PostPatch 483 err := decoder.Decode(&post) 484 if err != nil { 485 return nil 486 } 487 488 return &post 489} 490 491func (o *SearchParameter) SearchParameterToJson() string { 492 b, err := json.Marshal(o) 493 if err != nil { 494 return "" 495 } 496 497 return string(b) 498} 499 500func SearchParameterFromJson(data io.Reader) (*SearchParameter, error) { 501 decoder := json.NewDecoder(data) 502 var searchParam SearchParameter 503 if err := decoder.Decode(&searchParam); err != nil { 504 return nil, err 505 } 506 507 return &searchParam, nil 508} 509 510func (o *Post) ChannelMentions() []string { 511 return ChannelMentions(o.Message) 512} 513 514// DisableMentionHighlights disables a posts mention highlighting and returns the first channel mention that was present in the message. 515func (o *Post) DisableMentionHighlights() string { 516 mention, hasMentions := findAtChannelMention(o.Message) 517 if hasMentions { 518 o.AddProp(POST_PROPS_MENTION_HIGHLIGHT_DISABLED, true) 519 } 520 return mention 521} 522 523// DisableMentionHighlights disables mention highlighting for a post patch if required. 524func (o *PostPatch) DisableMentionHighlights() { 525 if o.Message == nil { 526 return 527 } 528 if _, hasMentions := findAtChannelMention(*o.Message); hasMentions { 529 if o.Props == nil { 530 o.Props = &StringInterface{} 531 } 532 (*o.Props)[POST_PROPS_MENTION_HIGHLIGHT_DISABLED] = true 533 } 534} 535 536func findAtChannelMention(message string) (mention string, found bool) { 537 re := regexp.MustCompile(`(?i)\B@(channel|all|here)\b`) 538 matched := re.FindStringSubmatch(message) 539 if found = (len(matched) > 0); found { 540 mention = strings.ToLower(matched[0]) 541 } 542 return 543} 544 545func (o *Post) Attachments() []*SlackAttachment { 546 if attachments, ok := o.GetProp("attachments").([]*SlackAttachment); ok { 547 return attachments 548 } 549 var ret []*SlackAttachment 550 if attachments, ok := o.GetProp("attachments").([]interface{}); ok { 551 for _, attachment := range attachments { 552 if enc, err := json.Marshal(attachment); err == nil { 553 var decoded SlackAttachment 554 if json.Unmarshal(enc, &decoded) == nil { 555 ret = append(ret, &decoded) 556 } 557 } 558 } 559 } 560 return ret 561} 562 563func (o *Post) AttachmentsEqual(input *Post) bool { 564 attachments := o.Attachments() 565 inputAttachments := input.Attachments() 566 567 if len(attachments) != len(inputAttachments) { 568 return false 569 } 570 571 for i := range attachments { 572 if !attachments[i].Equals(inputAttachments[i]) { 573 return false 574 } 575 } 576 577 return true 578} 579 580var markdownDestinationEscaper = strings.NewReplacer( 581 `\`, `\\`, 582 `<`, `\<`, 583 `>`, `\>`, 584 `(`, `\(`, 585 `)`, `\)`, 586) 587 588// WithRewrittenImageURLs returns a new shallow copy of the post where the message has been 589// rewritten via RewriteImageURLs. 590func (o *Post) WithRewrittenImageURLs(f func(string) string) *Post { 591 copy := o.Clone() 592 copy.Message = RewriteImageURLs(o.Message, f) 593 if copy.MessageSource == "" && copy.Message != o.Message { 594 copy.MessageSource = o.Message 595 } 596 return copy 597} 598 599func (o *PostEphemeral) ToUnsanitizedJson() string { 600 b, _ := json.Marshal(o) 601 return string(b) 602} 603 604// RewriteImageURLs takes a message and returns a copy that has all of the image URLs replaced 605// according to the function f. For each image URL, f will be invoked, and the resulting markdown 606// will contain the URL returned by that invocation instead. 607// 608// Image URLs are destination URLs used in inline images or reference definitions that are used 609// anywhere in the input markdown as an image. 610func RewriteImageURLs(message string, f func(string) string) string { 611 if !strings.Contains(message, "![") { 612 return message 613 } 614 615 var ranges []markdown.Range 616 617 markdown.Inspect(message, func(blockOrInline interface{}) bool { 618 switch v := blockOrInline.(type) { 619 case *markdown.ReferenceImage: 620 ranges = append(ranges, v.ReferenceDefinition.RawDestination) 621 case *markdown.InlineImage: 622 ranges = append(ranges, v.RawDestination) 623 default: 624 return true 625 } 626 return true 627 }) 628 629 if ranges == nil { 630 return message 631 } 632 633 sort.Slice(ranges, func(i, j int) bool { 634 return ranges[i].Position < ranges[j].Position 635 }) 636 637 copyRanges := make([]markdown.Range, 0, len(ranges)) 638 urls := make([]string, 0, len(ranges)) 639 resultLength := len(message) 640 641 start := 0 642 for i, r := range ranges { 643 switch { 644 case i == 0: 645 case r.Position != ranges[i-1].Position: 646 start = ranges[i-1].End 647 default: 648 continue 649 } 650 original := message[r.Position:r.End] 651 replacement := markdownDestinationEscaper.Replace(f(markdown.Unescape(original))) 652 resultLength += len(replacement) - len(original) 653 copyRanges = append(copyRanges, markdown.Range{Position: start, End: r.Position}) 654 urls = append(urls, replacement) 655 } 656 657 result := make([]byte, resultLength) 658 659 offset := 0 660 for i, r := range copyRanges { 661 offset += copy(result[offset:], message[r.Position:r.End]) 662 offset += copy(result[offset:], urls[i]) 663 } 664 copy(result[offset:], message[ranges[len(ranges)-1].End:]) 665 666 return string(result) 667} 668