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 &copy
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