mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
The server module named github.com/hanzoai/team/server/v8 — a path that resolves to the retired workspace's repository, not this one — while server/public still published github.com/mattermost/mattermost/server/public. Both are github.com/hanzoteam/server/... now, which is where this code actually lives. That second half is what made the binary unbootable. Upstream's public path stayed claimed by upstream, mattermost-plugin-ai imported it, and the build linked public/model twice; public/plugin's gob.Register then ran twice under one name and panicked at init, before main. Reverting the rename traded the brand for a working binary. Rebranding the plugin alongside it needs no such trade: the agents fork is github.com/hanzoteam/agents/v2 against our public module, so no module named mattermost/mattermost is in the graph at all. Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and it is ours.
1200 lines
39 KiB
Go
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/hanzoteam/server/server/public/model"
|
|
"github.com/hanzoteam/server/server/public/shared/markdown"
|
|
"github.com/hanzoteam/server/server/public/shared/mlog"
|
|
"github.com/hanzoteam/server/server/public/shared/request"
|
|
"github.com/hanzoteam/server/server/v8/channels/app/imaging"
|
|
"github.com/hanzoteam/server/server/v8/channels/app/oembed"
|
|
"github.com/hanzoteam/server/server/v8/channels/app/platform"
|
|
"github.com/hanzoteam/server/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, ¬AvailErr) {
|
|
// 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, ¬AvailErr) {
|
|
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  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 .
|
|
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  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 == model.APIURLSuffix+"/image" {
|
|
// The image endpoint 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
|
|
}
|