Files
team/server/channels/app/post_metadata.go
2bb8d01100 Mattermost Blocks (#36338)
* Mattermost Blocks

* Rethink some block props and styles

* Remove obsolete comment

* Style fixes and add examples

* Add mention, search, and image metadata support

* Several UI improvements

* Validate blocks by translating them

* Add initial e2e tests

* Fix lint

* Fix tests and lint

* Fix lint

* Improve e2e and simplify doPostAction

* server side cleanup

* Fix typo

* Fix style

* Fix some tests

* Fix more tests

* Fix cypress tests

* Fix playwhright tests

* Fix flaky system console test

* Frontend cleanup

* Add blocks editor

* Fix CI

* Limit all blocks width

* Fix test

* Enable webhook service on playwhright CI

* fix flaky test

* Fix style

* Cleanups

* Address coderabbit comments

* Address coderabbit comments

* Address CI errors

* Fix tests

* Address changes in valid actions

* UX fixes

* Fix test

* Fix danger buttons and add allow emojis in buttons

* style tweaks

use semantic color tokens for buttons and accents, update max-heights for scollables,  adjustments to containers, removed btn-sm (using default button size for mm blocks with height and padding adjustments for use within blocks. removed hover selector from utils to prevent hover state issue with primary button, refined semantic-color-warning.

* Address feedback

* Add interactive content to post previews

* Add collapse transitions

* Address autocomplete position

* Handle columns within column sets

* Fix css lint

* Fix tests

* Use feature flag

* Address feedback

* Fix lint and add missing tests

* Fix e2e tests

* Fix e2e test

* Allow interactive posts without a message

* Fix lint

* Address feedback

* Fix ci

* Fix lint

* Add limits to block translations

* Fix lint

* Address feedback

* Address feedback

* Update snapshot

* Fix collapsible not defaulting to the right value

* Add total text limit

* Sync translations with mobile

* Fix snapshot

---------

Co-authored-by: Matthew Birtch <[email protected]>
Co-authored-by: Mattermost Build <[email protected]>
2026-06-29 20:33:46 +02:00

1200 lines
39 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"bufio"
"bytes"
"fmt"
"image"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/dyatlov/go-opengraph/opengraph"
"github.com/pkg/errors"
"golang.org/x/net/idna"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app/imaging"
"github.com/mattermost/mattermost/server/v8/channels/app/oembed"
"github.com/mattermost/mattermost/server/v8/channels/app/platform"
"github.com/mattermost/mattermost/server/v8/channels/utils/imgutils"
)
type linkMetadataCache struct {
OpenGraph *opengraph.OpenGraph
PostImage *model.PostImage
Permalink *model.Permalink
URL string
}
const MaxMetadataImageSize = MaxOpenGraphResponseSize
func (s *Server) initPostMetadata() {
// Dump any cached links if the proxy settings have changed so image URLs can be updated
s.platform.AddConfigListener(func(before, after *model.Config) {
if (before.ImageProxySettings.Enable != after.ImageProxySettings.Enable) ||
(before.ImageProxySettings.ImageProxyType != after.ImageProxySettings.ImageProxyType) ||
(before.ImageProxySettings.RemoteImageProxyURL != after.ImageProxySettings.RemoteImageProxyURL) ||
(before.ImageProxySettings.RemoteImageProxyOptions != after.ImageProxySettings.RemoteImageProxyOptions) {
if err := platform.PurgeLinkCache(); err != nil {
mlog.Warn("Failed to remove cached links when the proxy settings changed", mlog.Err(err))
}
}
})
}
func (a *App) PreparePostListForClient(rctx request.CTX, originalList *model.PostList) *model.PostList {
list := &model.PostList{
Posts: make(map[string]*model.Post, len(originalList.Posts)),
Order: originalList.Order,
NextPostId: originalList.NextPostId,
PrevPostId: originalList.PrevPostId,
HasNext: originalList.HasNext,
FirstInaccessiblePostTime: originalList.FirstInaccessiblePostTime,
}
for id, originalPost := range originalList.Posts {
post := a.PreparePostForClientWithEmbedsAndImages(rctx, originalPost, &model.PreparePostForClientOpts{})
list.Posts[id] = post
}
if a.IsPostPriorityEnabled() {
priority, _ := a.GetPriorityForPostList(list)
acknowledgements, _ := a.GetAcknowledgementsForPostList(list)
for _, id := range list.Order {
if _, ok := priority[id]; ok {
list.Posts[id].Metadata.Priority = priority[id]
}
if _, ok := acknowledgements[id]; ok {
list.Posts[id].Metadata.Acknowledgements = acknowledgements[id]
}
}
}
a.populatePostListTranslations(rctx, list)
return list
}
// populatePostListTranslations fetches and populates translation metadata for posts that don't already have it.
// Posts from WebSocket broadcasts (post_created) already have translations populated.
// This function handles API requests (GetPostsForChannel, etc.) by fetching only the user's language.
func (a *App) populatePostListTranslations(rctx request.CTX, list *model.PostList) {
// Check if auto-translation is available before making database calls
if a.AutoTranslation() == nil || !a.AutoTranslation().IsFeatureAvailable() {
return
}
userID := rctx.Session().UserId
// Check which posts need translation data populated
postsNeedingTranslations := make(map[string][]string) // channelID -> postIDs
for _, post := range list.Posts {
// Skip if translations already populated (e.g., from CreatePost)
if post.Metadata != nil && len(post.Metadata.Translations) > 0 {
continue
}
postsNeedingTranslations[post.ChannelId] = append(postsNeedingTranslations[post.ChannelId], post.Id)
}
if len(postsNeedingTranslations) == 0 {
return // All posts already have translations
}
// For API requests, fetch only the user's language translation
for channelID, postIDs := range postsNeedingTranslations {
userLang, err := a.AutoTranslation().GetUserLanguage(userID, channelID)
if err != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if !errors.As(err, &notAvailErr) {
// Log non-availability errors
rctx.Logger().Warn("Failed to get user language for auto-translation", mlog.String("channel_id", channelID), mlog.Err(err))
}
continue
}
if userLang == "" {
continue
}
translationsMap, err := a.AutoTranslation().GetBatch(model.TranslationObjectTypePost, postIDs, userLang)
if err != nil {
var notAvailErr *model.ErrAutoTranslationNotAvailable
if errors.As(err, &notAvailErr) {
rctx.Logger().Debug("Auto-translation feature not available during GetBatch", mlog.Err(err))
} else {
// Real error - log it
rctx.Logger().Warn("Failed to fetch translations batch", mlog.Err(err))
}
continue
}
// Populate each post's metadata
for postID, t := range translationsMap {
post := list.Posts[postID]
if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
if post.Metadata.Translations == nil {
post.Metadata.Translations = make(map[string]*model.PostTranslation)
}
post.Metadata.Translations[t.Lang] = t.ToPostTranslation()
if t.UpdateAt > post.UpdateAt {
post.UpdateAt = t.UpdateAt
}
}
}
}
// OverrideIconURLIfEmoji changes the post icon override URL prop, if it has an emoji icon,
// so that it points to the URL (relative) of the emoji - static if emoji is default, /api if custom.
func (a *App) OverrideIconURLIfEmoji(rctx request.CTX, post *model.Post) {
prop, ok := post.GetProps()[model.PostPropsOverrideIconEmoji]
if !ok || prop == nil {
return
}
emojiName, ok := prop.(string)
if !ok {
return
}
if !*a.Config().ServiceSettings.EnablePostIconOverride || emojiName == "" {
return
}
emojiName = strings.ReplaceAll(emojiName, ":", "")
if emojiURL, err := a.GetEmojiStaticURL(rctx, emojiName); err == nil {
post.AddProp(model.PostPropsOverrideIconURL, emojiURL)
} else {
rctx.Logger().Warn("Failed to retrieve URL for overridden profile icon (emoji)", mlog.String("emojiName", emojiName), mlog.Err(err))
}
}
func (a *App) PreparePostForClient(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post := originalPost.Clone()
// Proxy image links before constructing metadata so that requests go through the proxy
post = a.PostWithProxyAddedToImageURLs(post)
a.OverrideIconURLIfEmoji(rctx, post)
if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
if post.DeleteAt > 0 && !opts.RetainContent {
// For deleted posts we don't fill out metadata nor do we return the post content
post.Message = ""
post.Metadata = &model.PostMetadata{}
return post
}
// Emojis and reaction counts
if emojis, reactions, err := a.getEmojisAndReactionsForPost(rctx, post); err != nil {
rctx.Logger().Warn("Failed to get emojis and reactions for a post", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
post.Metadata.Emojis = emojis
post.Metadata.Reactions = reactions
}
// Files
a.preparePostFilesForClient(rctx, post, opts)
if post.Type == model.PostTypeBurnOnRead {
// For the sender, populate ExpireAt from TemporaryPost when all recipients have revealed.
// This ensures the countdown timer persists after page reload.
if post.UserId == rctx.Session().UserId && post.Metadata.ExpireAt == 0 {
if unreadCount, err := a.Srv().Store().ReadReceipt().GetUnreadCountForPost(rctx, post); err == nil && unreadCount == 0 {
// Bypass cache to ensure fresh data from DB in clustered environments
if tmpPost, err := a.Srv().Store().TemporaryPost().Get(rctx, post.Id, false); err == nil {
post.Metadata.ExpireAt = tmpPost.ExpireAt
}
}
}
// if metadata expire is not set, it means the post is not revealed yet
// so we need to reset the metadata. Or, if the user is the author, we don't reset the metadata.
if post.Metadata.ExpireAt == 0 && post.UserId != rctx.Session().UserId {
if scheduledPost, ok := rctx.Context().Value(model.PostContextKeyIsScheduledPost).(bool); ok && scheduledPost {
// if the post is a scheduled post, we don't reset the metadata
} else {
post.Metadata = &model.PostMetadata{}
}
}
}
if opts.IncludePriority && a.IsPostPriorityEnabled() && post.RootId == "" {
// Use context-aware method to respect master/replica flag
if priority, err := a.GetPriorityForPostWithContext(rctx, post.Id); err != nil {
rctx.Logger().Warn("Failed to get post priority for a post", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
post.Metadata.Priority = priority
}
// Post's acknowledgements if any
if acknowledgements, err := a.GetAcknowledgementsForPost(post.Id); err != nil {
rctx.Logger().Warn("Failed to get post acknowledgements for a post", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
post.Metadata.Acknowledgements = acknowledgements
}
}
return post
}
func (a *App) preparePostFilesForClient(rctx request.CTX, post *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
if fileInfos, _, err := a.getFileMetadataForPost(rctx, post, opts.IsNewPost || opts.IsEditPost, opts.IncludeDeleted); err != nil {
rctx.Logger().Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Err(err))
} else {
post.Metadata.Files = fileInfos
}
return post
}
func (a *App) PreparePostForClientWithEmbedsAndImages(rctx request.CTX, originalPost *model.Post, opts *model.PreparePostForClientOpts) *model.Post {
post := a.PreparePostForClient(rctx, originalPost, opts)
post = a.getEmbedsAndImages(rctx, post, opts.IsNewPost)
post = a.preparePostFilesForClient(rctx, post, opts)
return post
}
func (a *App) getEmbedsAndImages(rctx request.CTX, post *model.Post, isNewPost bool) *model.Post {
if post.Metadata == nil {
post.Metadata = &model.PostMetadata{}
}
if post.Metadata.Embeds == nil {
post.Metadata.Embeds = []*model.PostEmbed{}
}
// Embeds use the first autolink from the post message only (not from attachments or blocks).
firstLink := a.getFirstLink(rctx, post.Message)
if post.HasUnsafeLinks() {
if !looksLikeAPermalink(firstLink, *a.Config().ServiceSettings.SiteURL) {
return post
}
}
if embed, err := a.getEmbedForPost(rctx, post, firstLink, isNewPost); err != nil {
appErr, ok := err.(*model.AppError)
isNotFound := ok && appErr.StatusCode == http.StatusNotFound
// Ignore NotFound errors.
if !isNotFound {
rctx.Logger().Debug("Failed to get embedded content for a post", mlog.String("post_id", post.Id), mlog.Err(err))
}
} else if embed != nil {
post.Metadata.Embeds = append(post.Metadata.Embeds, embed)
}
post.Metadata.Images = a.getImagesForPost(rctx, post, isNewPost)
return post
}
func removePermalinkMetadataFromPost(post *model.Post) {
removeEmbeddedPostsFromMetadata(post)
post.DelProp(model.PostPropsPreviewedPost)
}
func removeEmbeddedPostsFromMetadata(post *model.Post) {
if post.Metadata == nil || len(post.Metadata.Embeds) == 0 {
return
}
// Remove all permalink embeds and only keep non-permalink embeds.
// We always have only one permalink embed even if the post
// contains multiple permalinks.
var newEmbeds []*model.PostEmbed
for _, embed := range post.Metadata.Embeds {
if embed.Type != model.PostEmbedPermalink {
newEmbeds = append(newEmbeds, embed)
}
}
post.Metadata.Embeds = newEmbeds
}
func (a *App) SanitizePostMetadataForUser(rctx request.CTX, post *model.Post, userID string) (*model.Post, bool, *model.AppError) {
isMemberForPreviews := true
// Sanitize permalink embeds based on permissions (only if present)
if post.Metadata != nil && len(post.Metadata.Embeds) > 0 {
previewPost := post.GetPreviewPost()
if previewPost != nil {
previewedChannel, err := a.GetChannel(rctx, previewPost.Post.ChannelId)
if err != nil {
return nil, false, err
}
if previewedChannel != nil {
var hasPermission bool
hasPermission, isMemberForPreviews = a.HasPermissionToReadChannel(rctx, userID, previewedChannel)
if !hasPermission {
removePermalinkMetadataFromPost(post)
// Since we remove the permalink metadata, we return true for isMember
isMemberForPreviews = true
}
}
}
}
// Sanitize channel mentions based on permissions
// sanitizeChannelMentionsForUser returns immediately if no channel mentions exist
post = a.sanitizeChannelMentionsForUser(rctx, post, userID)
// Strip file attachments denied by ABAC — covers both the post and permalink embeds.
if post.Metadata != nil {
a.sanitizeFileAttachmentsForUser(rctx, post, userID)
}
return post, isMemberForPreviews, nil
}
// sanitizeChannelMentionsForUser filters channel mentions in post props based on the viewer's
// permissions. Only channels the user has permission to read will be included in the returned post.
// This prevents information disclosure of private channel names/metadata.
func (a *App) sanitizeChannelMentionsForUser(rctx request.CTX, post *model.Post, userID string) *model.Post {
channelMentionsProp := post.GetProp(model.PostPropsChannelMentions)
if channelMentionsProp == nil {
return post
}
mentionsMap, ok := channelMentionsProp.(map[string]any)
if !ok {
return post
}
sanitized := make(map[string]any)
for channelName, data := range mentionsMap {
dataMap, ok := data.(map[string]any)
if !ok {
continue
}
// Get team_name from the mention data
teamName, _ := dataMap["team_name"].(string)
if teamName == "" {
// No team information, skip this mention
continue
}
// Fetch the channel from database (gets fresh data)
channel, err := a.GetChannelByNameForTeamName(rctx, channelName, teamName, true)
if err != nil {
// Channel not found or error fetching, skip
continue
}
// Resolve the channel mention if the viewer may see the channel's name/link.
if a.HasPermissionToResolveChannelMention(rctx, userID, channel) {
// User has permission - include in sanitized props with fresh display_name
// Reuse team_name from original props to avoid additional DB query
// (team renames are extremely rare and don't warrant the performance cost)
sanitized[channelName] = map[string]any{
"display_name": channel.DisplayName, // Fresh from database (handles channel renames)
"team_name": teamName, // Reused from props (avoids team lookup)
}
}
// Otherwise, omit from props (no information disclosure)
}
// Update post props with sanitized mentions
if len(sanitized) > 0 {
post.AddProp(model.PostPropsChannelMentions, sanitized)
} else {
post.DelProp(model.PostPropsChannelMentions)
}
return post
}
// sanitizeFileAttachmentsForUser strips file metadata from the post and from any embedded
// permalink preview posts if the user is denied the download_file_attachment action.
func (a *App) sanitizeFileAttachmentsForUser(rctx request.CTX, post *model.Post, userID string) {
if a.Srv().Channels().AccessControl == nil {
return
}
cfg := a.Config().AccessControlSettings.EnableAttributeBasedAccessControl
if cfg == nil || !*cfg {
return
}
if !a.Config().FeatureFlags.PermissionPolicies {
return
}
// No requesting user (e.g. a system post created by a background job with no
// session, such as the ABAC membership sync's channel-join messages). There
// is nobody to sanitize attachments for, so skip. A genuine reader re-runs
// this with their own session id when the post is served.
if userID == "" {
return
}
user, err := a.GetUser(userID)
if err != nil {
rctx.Logger().Warn("Failed to get user for file attachment sanitization, stripping attachments",
mlog.String("user_id", userID),
mlog.Err(err),
)
post.Metadata.Files = nil
post.FileIds = model.StringArray{}
return
}
if len(post.Metadata.Files) > 0 {
if !a.HasPermissionToFileAction(rctx, userID, user.Roles, post.ChannelId, model.AccessControlPolicyActionDownloadFileAttachment) {
rctx.Logger().Debug("Stripping file attachments from post due to ABAC permission policy",
mlog.String("user_id", userID),
mlog.String("post_id", post.Id),
mlog.String("channel_id", post.ChannelId),
mlog.Int("files_removed", len(post.Metadata.Files)),
)
post.Metadata.RedactedFileCount = len(post.Metadata.Files)
post.Metadata.Files = nil
post.FileIds = model.StringArray{}
}
}
// Also strip files from permalink-embedded posts. embed.Data is *model.PreviewPost;
// each referenced post is checked against its own channel.
for _, embed := range post.Metadata.Embeds {
if embed.Type != model.PostEmbedPermalink {
continue
}
previewPost, ok := embed.Data.(*model.PreviewPost)
if !ok || previewPost == nil || previewPost.Post == nil {
continue
}
if previewPost.Post.Metadata == nil || len(previewPost.Post.Metadata.Files) == 0 {
continue
}
if !a.HasPermissionToFileAction(rctx, userID, user.Roles, previewPost.Post.ChannelId, model.AccessControlPolicyActionDownloadFileAttachment) {
// Clone both the inner Post and the outer PreviewPost before mutating.
// embed.Data points into the global link-metadata cache; writing through the
// shared *PreviewPost pointer would corrupt it for concurrent requests.
referencedPost := previewPost.Post.Clone()
referencedPost.Metadata.RedactedFileCount = len(referencedPost.Metadata.Files)
referencedPost.Metadata.Files = nil
referencedPost.FileIds = model.StringArray{}
previewPostCopy := *previewPost
previewPostCopy.Post = referencedPost
embed.Data = &previewPostCopy
}
}
}
func (a *App) SanitizePostListMetadataForUser(rctx request.CTX, postList *model.PostList, userID string) (*model.PostList, bool, *model.AppError) {
clonedPostList := postList.Clone()
allPreviewsHaveMembership := true
for postID, post := range clonedPostList.Posts {
sanitizedPost, isMember, err := a.SanitizePostMetadataForUser(rctx, post, userID)
if err != nil {
return nil, false, err
}
clonedPostList.Posts[postID] = sanitizedPost
allPreviewsHaveMembership = allPreviewsHaveMembership && isMember
}
return clonedPostList, allPreviewsHaveMembership, nil
}
func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) {
if len(post.FileIds) == 0 {
return nil, 0, nil
}
return a.GetFileInfosForPost(rctx, post, fromMaster, includeDeleted)
}
func (a *App) getEmojisAndReactionsForPost(rctx request.CTX, post *model.Post) ([]*model.Emoji, []*model.Reaction, *model.AppError) {
var reactions []*model.Reaction
if post.HasReactions {
var err *model.AppError
reactions, err = a.GetReactionsForPost(post.Id)
if err != nil {
return nil, nil, err
}
}
emojis, err := a.getCustomEmojisForPost(rctx, post, reactions)
if err != nil {
return nil, nil, err
}
return emojis, reactions, nil
}
func (a *App) getEmbedForPost(rctx request.CTX, post *model.Post, firstLink string, isNewPost bool) (*model.PostEmbed, error) {
if _, ok := post.GetProps()[model.PostPropsAttachments]; ok {
return &model.PostEmbed{
Type: model.PostEmbedMessageAttachment,
}, nil
}
if _, ok := post.GetProps()["boards"]; ok {
return &model.PostEmbed{
Type: model.PostEmbedBoards,
Data: post.GetProps()["boards"],
}, nil
}
if firstLink == "" {
return nil, nil
}
// Permalink previews are not toggled via the ServiceSettings.EnableLinkPreviews config setting.
if !*a.Config().ServiceSettings.EnableLinkPreviews && !looksLikeAPermalink(firstLink, *a.Config().ServiceSettings.SiteURL) {
return nil, nil
}
og, image, permalink, err := a.getLinkMetadata(rctx, firstLink, post.CreateAt, isNewPost, post.GetPreviewedPostProp())
if err != nil {
return nil, err
}
if !*a.Config().ServiceSettings.EnablePermalinkPreviews {
permalink = nil
}
if og != nil {
return &model.PostEmbed{
Type: model.PostEmbedOpengraph,
URL: firstLink,
Data: og,
}, nil
}
if image != nil {
// See MM-67372
if image.Format == "svg" || model.IsSVGImageURL(firstLink) {
rctx.Logger().Debug("Skipping SVG image embed",
mlog.String("post_id", post.Id),
mlog.String("url", firstLink))
// Return a link embed instead of an image embed
return &model.PostEmbed{
Type: model.PostEmbedLink,
URL: firstLink,
}, nil
}
// Note that we're not passing the image info here since it'll be part of the PostMetadata.Images field
return &model.PostEmbed{
Type: model.PostEmbedImage,
URL: firstLink,
}, nil
}
if permalink != nil {
return &model.PostEmbed{Type: model.PostEmbedPermalink, Data: permalink.PreviewPost}, nil
}
return &model.PostEmbed{
Type: model.PostEmbedLink,
URL: firstLink,
}, nil
}
func (a *App) getImagesForPost(rctx request.CTX, post *model.Post, isNewPost bool) map[string]*model.PostImage {
if post.HasUnsafeLinks() {
return map[string]*model.PostImage{}
}
imageURLs := a.markdownImageURLsFromStrings(rctx, post.AllStrings(model.AllStringsOptions{OmitInteractiveBlocks: !a.Config().FeatureFlags.MmBlocksEnabled}))
imageURLs = append(imageURLs, post.InteractiveBlocksImageURLs(a.Config().FeatureFlags.MmBlocksEnabled)...)
postImages := map[string]*model.PostImage{}
for _, embed := range post.Metadata.Embeds {
switch embed.Type {
case model.PostEmbedImage:
// These dimensions will generally be cached by a previous call to getEmbedForPost
imageURLs = append(imageURLs, embed.URL)
case model.PostEmbedOpengraph:
openGraph, ok := embed.Data.(*opengraph.OpenGraph)
if !ok {
rctx.Logger().Warn("Could not read the image data: the data could not be casted to OpenGraph",
mlog.String("post_id", post.Id),
mlog.String("data type", fmt.Sprintf("%t", embed.Data)),
)
continue
}
for _, image := range openGraph.Images {
var imageURL string
if image.SecureURL != "" {
imageURL = image.SecureURL
} else if image.URL != "" {
imageURL = image.URL
}
if imageURL == "" {
continue
}
imageURLs = append(imageURLs, imageURL)
}
}
}
// Removing duplicates isn't strictly required since postImages is a map, but it feels safer to do it beforehand
if len(imageURLs) > 1 {
imageURLs = model.RemoveDuplicateStrings(imageURLs)
}
for _, imageURL := range imageURLs {
// prevent infinite loop if a OG image URL is the same post's permalink
resolvedURL := resolveMetadataURL(imageURL, a.GetSiteURL())
if looksLikeAPermalink(resolvedURL, a.GetSiteURL()) {
continue
}
if _, image, _, err := a.getLinkMetadata(rctx, imageURL, post.CreateAt, isNewPost, post.GetPreviewedPostProp()); err != nil {
appErr, ok := err.(*model.AppError)
isNotFound := ok && appErr.StatusCode == http.StatusNotFound
// Ignore NotFound errors.
if !isNotFound {
rctx.Logger().Debug("Failed to get dimensions of an image in a post",
mlog.String("post_id", post.Id),
mlog.String("image_url", imageURL),
mlog.Err(err),
)
}
} else if image != nil {
postImages[imageURL] = image
}
}
return postImages
}
func getEmojiNamesForString(s string) []string {
names := model.EmojiPattern.FindAllString(s, -1)
for i, name := range names {
names[i] = strings.Trim(name, ":")
}
return names
}
func getEmojiNamesForPost(post *model.Post, reactions []*model.Reaction, mmBlocksEnabled bool) []string {
allStrings := post.AllStrings(model.AllStringsOptions{OmitInteractiveBlocks: !mmBlocksEnabled})
var names []string
for _, s := range allStrings {
names = append(names, getEmojiNamesForString(s)...)
}
for _, reaction := range reactions {
names = append(names, reaction.EmojiName)
}
return model.RemoveDuplicateStrings(names)
}
func (a *App) getCustomEmojisForPost(rctx request.CTX, post *model.Post, reactions []*model.Reaction) ([]*model.Emoji, *model.AppError) {
if !*a.Config().ServiceSettings.EnableCustomEmoji {
// Only custom emoji are returned
return []*model.Emoji{}, nil
}
names := getEmojiNamesForPost(post, reactions, a.Config().FeatureFlags.MmBlocksEnabled)
if len(names) == 0 {
return []*model.Emoji{}, nil
}
return a.GetMultipleEmojiByName(rctx, names)
}
func (a *App) isLinkAllowedForPreview(rctx request.CTX, link string) bool {
domains := normalizeDomains(*a.Config().ServiceSettings.RestrictLinkPreviews)
for _, d := range domains {
parsed, err := url.Parse(link)
if err != nil {
rctx.Logger().Warn("Unable to parse the link", mlog.String("link", link), mlog.Err(err))
// We disable link preview if link is badly formed
// to remain on the safe side
return false
}
// Conforming to IDNA2008 using the UTS-46 standard.
cleaned, err := idna.Lookup.ToASCII(parsed.Hostname())
if err != nil {
rctx.Logger().Warn("Unable to lookup hostname to ASCII", mlog.String("hostname", parsed.Hostname()), mlog.Err(err))
// Same applies if compatibility processing fails.
return false
}
if strings.Contains(cleaned, d) {
return false
}
}
return true
}
func normalizeDomains(domains string) []string {
// commas and @ signs are optional
// can be in the form of "@corp.mattermost.com, mattermost.com mattermost.org" -> corp.mattermost.com mattermost.com mattermost.org
return strings.Fields(
strings.TrimSpace(
strings.ToLower(
strings.ReplaceAll(
strings.ReplaceAll(domains, "@", " "),
",", " "),
),
),
)
}
// markdownImageURLsFromStrings collects ![alt](url) and reference-style image URLs from the given
// strings (typically model.Post.AllStrings).
func (a *App) markdownImageURLsFromStrings(rctx request.CTX, allStrings []string) []string {
var images []string
for _, s := range allStrings {
images = append(images, a.getImages(rctx, s)...)
}
return images
}
// getFirstLink returns the first autolinked URL in the string. It does not return Markdown links of
// the form [text](url) or image URLs from ![alt](url).
func (a *App) getFirstLink(rctx request.CTX, str string) string {
firstLink := ""
markdown.Inspect(str, func(blockOrInline any) bool {
if v, ok := blockOrInline.(*markdown.Autolink); ok {
if link := v.Destination(); firstLink == "" && a.isLinkAllowedForPreview(rctx, link) {
firstLink = link
}
}
return true
})
return firstLink
}
// getImages returns all Markdown image URLs of the form ![alt text](image url) and reference-style
// images. It does not return plain autolinks or Markdown links of the form [text](url).
func (a *App) getImages(rctx request.CTX, str string) []string {
images := []string{}
markdown.Inspect(str, func(blockOrInline any) bool {
switch v := blockOrInline.(type) {
case *markdown.InlineImage:
if link := v.Destination(); a.isLinkAllowedForPreview(rctx, link) {
images = append(images, link)
}
case *markdown.ReferenceImage:
if link := v.ReferenceDefinition.Destination(); a.isLinkAllowedForPreview(rctx, link) {
images = append(images, link)
}
}
return true
})
return images
}
func looksLikeAPermalink(url, siteURL string) bool {
path, hasPrefix := strings.CutPrefix(strings.TrimSpace(url), siteURL)
if !hasPrefix {
return false
}
path = strings.TrimPrefix(path, "/")
matched, err := regexp.MatchString(`^[0-9a-z_-]{1,64}/pl/[a-z0-9]{26}$`, path)
if err != nil {
mlog.Warn("error matching regex", mlog.Err(err))
}
return matched
}
func (a *App) containsPermalink(rctx request.CTX, post *model.Post) bool {
link := a.getFirstLink(rctx, post.Message)
if link == "" {
return false
}
return looksLikeAPermalink(link, a.GetSiteURL())
}
// filterSVGImage filters out SVG images (MM-67372).
// Returns nil if the image is an SVG, otherwise returns the image unchanged.
func filterSVGImage(image *model.PostImage, imageURL string) *model.PostImage {
if image == nil {
return nil
}
if image.Format == "svg" {
return nil
}
if model.IsSVGImageURL(imageURL) {
return nil
}
return image
}
func (a *App) getLinkMetadata(rctx request.CTX, requestURL string, timestamp int64, isNewPost bool, previewedPostPropVal string) (*opengraph.OpenGraph, *model.PostImage, *model.Permalink, error) {
requestURL = resolveMetadataURL(requestURL, a.GetSiteURL())
// If it's an embedded image, nothing to do.
if strings.HasPrefix(strings.ToLower(requestURL), "data:image/") {
return nil, nil, nil, nil
}
timestamp = model.FloorToNearestHour(timestamp)
// Check cache
og, image, permalink, ok := getLinkMetadataFromCache(requestURL, timestamp)
if !*a.Config().ServiceSettings.EnablePermalinkPreviews {
permalink = nil
}
if ok && previewedPostPropVal == "" {
og = model.TruncateOpenGraph(og)
image = filterSVGImage(image, requestURL)
return og, image, permalink, nil
}
// Check the database if this isn't a new post. If it is a new post and the data is cached, it should be in memory.
if !isNewPost {
og, image, ok = a.getLinkMetadataFromDatabase(requestURL, timestamp)
if ok && previewedPostPropVal == "" {
og = model.TruncateOpenGraph(og)
image = filterSVGImage(image, requestURL)
cacheLinkMetadata(rctx, requestURL, timestamp, og, image, nil)
return og, image, nil, nil
}
}
var err error
if looksLikeAPermalink(requestURL, a.GetSiteURL()) && *a.Config().ServiceSettings.EnablePermalinkPreviews {
permalink, err = a.getLinkMetadataForPermalink(rctx, requestURL)
if err != nil {
return nil, nil, nil, err
}
} else if oEmbedProvider := oembed.FindEndpointForURL(requestURL); oEmbedProvider != nil {
og, err = a.getLinkMetadataFromOEmbed(rctx, requestURL, oEmbedProvider)
} else {
og, image, err = a.getLinkMetadataForURL(rctx, requestURL)
// We intentionally don't return early on an error because we want to save that there is no metadata for this link
a.saveLinkMetadataToDatabase(requestURL, timestamp, og, image)
}
// Write back to cache and database, even if there was an error and the results are nil
cacheLinkMetadata(rctx, requestURL, timestamp, og, image, permalink)
return og, image, permalink, err
}
func (a *App) getLinkMetadataForPermalink(rctx request.CTX, requestURL string) (*model.Permalink, error) {
referencedPostID := requestURL[len(requestURL)-26:]
referencedPost, appErr := a.GetSinglePost(rctx, referencedPostID, false)
// TODO: Look into saving a value in the LinkMetadata.Data field to prevent perpetually re-querying for the deleted post.
if appErr != nil {
return nil, appErr
}
if referencedPost.Type == model.PostTypeBurnOnRead {
return nil, model.NewAppError("getLinkMetadataForPermalink", "api.post.get_link_metadata_for_permalink.burn_on_read.app_error", nil, "", http.StatusForbidden)
}
referencedChannel, appErr := a.GetChannel(rctx, referencedPost.ChannelId)
if appErr != nil {
return nil, appErr
}
var referencedTeam *model.Team
if referencedChannel.Type == model.ChannelTypeDirect || referencedChannel.Type == model.ChannelTypeGroup {
referencedTeam = &model.Team{}
} else {
referencedTeam, appErr = a.GetTeam(referencedChannel.TeamId)
if appErr != nil {
return nil, appErr
}
}
// Get metadata for embedded post
var permalink *model.Permalink
if a.containsPermalink(rctx, referencedPost) {
// referencedPost contains a permalink: we don't get its metadata
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)}
} else {
// referencedPost does not contain a permalink: we get its metadata
referencedPostWithMetadata := a.PreparePostForClientWithEmbedsAndImages(rctx, referencedPost, &model.PreparePostForClientOpts{
IncludePriority: true,
})
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPostWithMetadata, referencedTeam, referencedChannel)}
}
a.populatePostListTranslations(rctx, &model.PostList{Posts: map[string]*model.Post{permalink.PreviewPost.Post.Id: permalink.PreviewPost.Post}})
return permalink, nil
}
func (a *App) getLinkMetadataFromOEmbed(rctx request.CTX, requestURL string, provider *oembed.ProviderEndpoint) (*opengraph.OpenGraph, error) {
request, err := http.NewRequest("GET", provider.GetProviderURL(requestURL), nil)
if err != nil {
return nil, err
}
request.Header.Add("Accept", "application/json")
request.Header.Add("Accept-Language", *a.Config().LocalizationSettings.DefaultServerLocale)
client := a.HTTPService().MakeClient(false)
client.Timeout = time.Duration(*a.Config().ExperimentalSettings.LinkMetadataTimeoutMilliseconds) * time.Millisecond
res, err := client.Do(request)
if err != nil {
rctx.Logger().Warn("error fetching oEmbed data", mlog.Err(err))
return nil, errors.Wrap(err, "getLinkMetadataFromOEmbed: Unable to get oEmbed data")
}
defer func() {
if _, err = io.Copy(io.Discard, res.Body); err != nil {
rctx.Logger().Warn("error discarding oEmbed response body", mlog.Err(err))
}
res.Body.Close()
}()
return a.parseOpenGraphFromOEmbed(requestURL, res.Body)
}
func (a *App) getLinkMetadataForURL(rctx request.CTX, requestURL string) (*opengraph.OpenGraph, *model.PostImage, error) {
var request *http.Request
// Make request for a web page or an image
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, nil, err
}
var body io.ReadCloser
var contentType string
if (request.URL.Scheme+"://"+request.URL.Host) == a.GetSiteURL() && request.URL.Path == "/api/v4/image" {
// /api/v4/image requires authentication, so bypass the API by hitting the proxy directly
body, contentType, err = a.ImageProxy().GetImageDirect(a.ImageProxy().GetUnproxiedImageURL(request.URL.String()))
} else {
request.Header.Add("Accept", "image/*")
request.Header.Add("Accept", "text/html;q=0.8")
request.Header.Add("Accept-Language", *a.Config().LocalizationSettings.DefaultServerLocale)
client := a.HTTPService().MakeClient(false)
client.Timeout = time.Duration(*a.Config().ExperimentalSettings.LinkMetadataTimeoutMilliseconds) * time.Millisecond
var res *http.Response
res, err = client.Do(request)
if err != nil {
rctx.Logger().Warn("error fetching OG image data", mlog.Err(err))
}
if res != nil {
body = res.Body
contentType = res.Header.Get("Content-Type")
}
}
if body != nil {
defer func() {
if _, err = io.Copy(io.Discard, body); err != nil {
rctx.Logger().Warn("error discarding OG image response body", mlog.Err(err))
}
body.Close()
}()
}
var og *opengraph.OpenGraph
var image *model.PostImage
if err == nil {
// Parse the data
og, image, err = a.parseLinkMetadata(rctx, requestURL, body, contentType)
}
og = model.TruncateOpenGraph(og) // remove unwanted length of texts
return og, image, err
}
// resolveMetadataURL resolves a given URL relative to the server's site URL.
func resolveMetadataURL(requestURL string, siteURL string) string {
base, err := url.Parse(siteURL)
if err != nil {
return ""
}
resolved, err := base.Parse(requestURL)
if err != nil {
return ""
}
return resolved.String()
}
func getLinkMetadataFromCache(requestURL string, timestamp int64) (*opengraph.OpenGraph, *model.PostImage, *model.Permalink, bool) {
var cached linkMetadataCache
err := platform.LinkCache().Get(strconv.FormatInt(model.GenerateLinkMetadataHash(requestURL, timestamp), 16), &cached)
if err != nil {
return nil, nil, nil, false
}
// Verify that the cached entry matches the requested URL
if cached.URL != requestURL {
return nil, nil, nil, false
}
return cached.OpenGraph, cached.PostImage, cached.Permalink, true
}
func (a *App) getLinkMetadataFromDatabase(requestURL string, timestamp int64) (*opengraph.OpenGraph, *model.PostImage, bool) {
linkMetadata, err := a.Srv().Store().LinkMetadata().Get(requestURL, timestamp)
if err != nil {
return nil, nil, false
}
data := linkMetadata.Data
switch v := data.(type) {
case *opengraph.OpenGraph:
return v, nil, true
case *model.PostImage:
return nil, v, true
default:
return nil, nil, true
}
}
func (a *App) saveLinkMetadataToDatabase(requestURL string, timestamp int64, og *opengraph.OpenGraph, image *model.PostImage) {
metadata := &model.LinkMetadata{
URL: requestURL,
Timestamp: timestamp,
}
if og != nil {
metadata.Type = model.LinkMetadataTypeOpengraph
metadata.Data = og
} else if image != nil {
metadata.Type = model.LinkMetadataTypeImage
metadata.Data = image
} else {
metadata.Type = model.LinkMetadataTypeNone
}
_, err := a.Srv().Store().LinkMetadata().Save(metadata)
if err != nil {
mlog.Warn("Failed to write link metadata", mlog.String("request_url", requestURL), mlog.Err(err))
}
}
func cacheLinkMetadata(rctx request.CTX, requestURL string, timestamp int64, og *opengraph.OpenGraph, image *model.PostImage, permalink *model.Permalink) {
metadata := linkMetadataCache{
OpenGraph: og,
PostImage: image,
Permalink: permalink,
URL: requestURL,
}
if err := platform.LinkCache().SetWithExpiry(strconv.FormatInt(model.GenerateLinkMetadataHash(requestURL, timestamp), 16), metadata, platform.LinkCacheDuration); err != nil {
rctx.Logger().Warn("Failed to cache link metadata", mlog.String("request_url", requestURL), mlog.Err(err))
}
}
// peekContentType peeks at the first 512 bytes of p, and attempts to detect
// the content type. Returns empty string if error occurs.
func peekContentType(p *bufio.Reader) string {
byt, err := p.Peek(512)
if err != nil && err != bufio.ErrBufferFull && err != io.EOF {
return ""
}
return http.DetectContentType(byt)
}
func (a *App) parseLinkMetadata(rctx request.CTX, requestURL string, body io.Reader, contentType string) (*opengraph.OpenGraph, *model.PostImage, error) {
if contentType == "" {
bufRd := bufio.NewReader(body)
// If the content-type is missing we try to detect it from the actual data.
contentType = peekContentType(bufRd)
body = bufRd
}
if strings.HasPrefix(contentType, "image/svg+xml") {
// See MM-67372
rctx.Logger().Debug("Filtering SVG image from link metadata",
mlog.String("url", requestURL))
return nil, nil, nil
} else if strings.HasPrefix(contentType, "image") {
image, err := parseImages(rctx, requestURL, io.LimitReader(body, MaxMetadataImageSize))
return nil, image, err
} else if strings.HasPrefix(contentType, "text/html") {
og := a.parseOpenGraphMetadata(requestURL, body, contentType)
// The OpenGraph library and Go HTML library don't error for malformed input, so check that at least
// one of these required fields exists before returning the OpenGraph data
if og.Title != "" || og.Type != "" || og.URL != "" {
return og, nil, nil
}
return nil, nil, nil
}
// Not an image or web page with OpenGraph information
return nil, nil, nil
}
func parseImages(rctx request.CTX, requestURL string, body io.Reader) (*model.PostImage, error) {
// Store any data that is read for the config for any further processing
buf := &bytes.Buffer{}
t := io.TeeReader(body, buf)
// Read the image config to get the format and dimensions
config, format, err := image.DecodeConfig(t)
if err != nil {
return nil, err
}
image := &model.PostImage{
Width: config.Width,
Height: config.Height,
Format: format,
}
if format == "jpeg" {
if imageOrientation, err := imaging.GetImageOrientation(io.MultiReader(buf, body), format); err == nil &&
(imageOrientation == imaging.RotatedCWMirrored ||
imageOrientation == imaging.RotatedCCW ||
imageOrientation == imaging.RotatedCCWMirrored ||
imageOrientation == imaging.RotatedCW) {
image.Width, image.Height = image.Height, image.Width
} else if err != nil {
rctx.Logger().Warn("Failed to get image orientation", mlog.Err(err), mlog.String("request_url", requestURL))
}
}
if format == "gif" {
// Decoding the config may have read some of the image data, so re-read the data that has already been read first
frameCount, err := imgutils.CountGIFFrames(io.MultiReader(buf, body))
if err != nil {
return nil, err
}
image.FrameCount = frameCount
}
// Make image information nil when the format is tiff
if format == "tiff" {
image = nil
}
return image, nil
}