Files
team/server/channels/app/post_metadata.go
c9759c392e MM-68952: Resolve public channel mentions for non-members under Compliance (#36815)
* MM-68952: Resolve public channel mentions for non-members under Compliance

Channel mention name resolution reused HasPermissionToReadChannel, a
content-read check that returns false for non-members of a public channel
when Compliance Monitoring is enabled (MM-45272) or when the channel is on
another team (MM-66791). As a result, the channel_mentions post prop was
stripped per-viewer (since #34235), and the webapp fell back to rendering the
raw (anonymized) channel slug instead of a clickable link.

Introduce HasPermissionToResolveChannelMention, which exposes only a public
channel display name and link (not content) and is therefore independent of
ComplianceSettings, while still requiring team membership for public channels
(blocks cross-team disclosure) and channel membership for private/DM/GM
channels. Switch the three mention call sites (FillInPostProps,
sanitizeChannelMentionsForUser, channelMentionsBroadcastHook) to the new
helper. HasPermissionToReadChannel and all content-read paths are unchanged.

* MM-68952: Add author-side and E2E coverage for channel mention resolution

Add a Go test (TestFillInPostPropsChannelMentionResolution) that exercises the
author-side persistence of the channel_mentions prop in FillInPostProps. It
locks in the new behavior: an author who is a team member but not a member of a
referenced public channel now persists the mention prop even when Compliance
Monitoring is enabled, while public channels on other teams and private
channels the author is not in are still dropped.

Add a Playwright spec (channel_mention_resolution.spec.ts) with a license-free
cross-team case (a public channel mention stays unresolved for a viewer not on
the channel's team) and a license-gated case (with Compliance enabled, a team
member who is not in the channel sees the resolved mention link).

* MM-68952: Tighten channel mention test assertions and fix lint

Use strings.Builder when assembling the test message to avoid the
golangci-lint stringsbuilder (modernize) warning about string += string
in a loop. Tighten the cross-team public and private non-member cases to
assert.Nil on the resolved mentions so the contract requires no persisted
channel_mentions map rather than merely an empty one.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-03 08:42:39 -04:00

1243 lines
40 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 and image dimensions
firstLink, images := a.getFirstLinkAndImages(rctx, post.Message)
if unsafeLinksProp := post.GetProp(model.PostPropsUnsafeLinks); unsafeLinksProp != nil {
if prop, ok := unsafeLinksProp.(string); ok && prop == "true" {
images = []string{}
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, images, 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
}
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, imageURLs []string, isNewPost bool) map[string]*model.PostImage {
images := 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.PostEmbedMessageAttachment:
imageURLs = append(imageURLs, a.getImagesInMessageAttachments(rctx, post)...)
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 since images 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 {
images[imageURL] = image
}
}
return images
}
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) []string {
// Post message
names := getEmojiNamesForString(post.Message)
// Reactions
for _, reaction := range reactions {
names = append(names, reaction.EmojiName)
}
// Post attachments
for _, attachment := range post.Attachments() {
if attachment.Title != "" {
names = append(names, getEmojiNamesForString(attachment.Title)...)
}
if attachment.Text != "" {
names = append(names, getEmojiNamesForString(attachment.Text)...)
}
if attachment.Pretext != "" {
names = append(names, getEmojiNamesForString(attachment.Pretext)...)
}
for _, field := range attachment.Fields {
if field == nil {
continue
}
if value, ok := field.Value.(string); ok {
names = append(names, getEmojiNamesForString(value)...)
}
}
}
// Remove duplicates
names = model.RemoveDuplicateStrings(names)
return 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)
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, "@", " "),
",", " "),
),
),
)
}
// Given a string, returns the first autolinked URL in the string as well as an array of all Markdown
// images of the form ![alt text](image url). Note that this does not return Markdown links of the
// form [text](url).
func (a *App) getFirstLinkAndImages(rctx request.CTX, str string) (string, []string) {
firstLink := ""
images := []string{}
markdown.Inspect(str, func(blockOrInline any) bool {
switch v := blockOrInline.(type) {
case *markdown.Autolink:
if link := v.Destination(); firstLink == "" && a.isLinkAllowedForPreview(rctx, link) {
firstLink = link
}
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 firstLink, images
}
func (a *App) getImagesInMessageAttachments(rctx request.CTX, post *model.Post) []string {
var images []string
for _, attachment := range post.Attachments() {
_, imagesInText := a.getFirstLinkAndImages(rctx, attachment.Text)
images = append(images, imagesInText...)
_, imagesInPretext := a.getFirstLinkAndImages(rctx, attachment.Pretext)
images = append(images, imagesInPretext...)
for _, field := range attachment.Fields {
if field == nil {
continue
}
if value, ok := field.Value.(string); ok {
_, imagesInFieldValue := a.getFirstLinkAndImages(rctx, value)
images = append(images, imagesInFieldValue...)
}
}
if attachment.AuthorIcon != "" {
images = append(images, attachment.AuthorIcon)
}
if attachment.ImageURL != "" {
images = append(images, attachment.ImageURL)
}
if attachment.ThumbURL != "" {
images = append(images, attachment.ThumbURL)
}
if attachment.FooterIcon != "" {
images = append(images, attachment.FooterIcon)
}
}
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.getFirstLinkAndImages(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
}