MM-11868/MM-12010/MM-12011/MM-12036 Improve post metadata structure (#9693)

* MM-11868/MM-12010/MM-12011/MM-12036 Include dimensions for opengraph and message attachment images in post metadata

* Remove duplicate check from getFirstLinkAndImages

* Add tests for getImagesInMessageAttachments

* Rename PostMetadata.FileInfos to PostMetadata.Files

* Rename Metadata.ImageDimensions to Metadata.Images
Этот коммит содержится в:
Harrison Healey
2018-10-24 04:49:55 -04:00
родитель 3929cb0141
Коммит 18684dd6de
5 изменённых файлов: 516 добавлений и 196 удалений

Просмотреть файл

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/dyatlov/go-opengraph/opengraph" "github.com/dyatlov/go-opengraph/opengraph"
"github.com/mattermost/mattermost-server/mlog"
"github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/utils" "github.com/mattermost/mattermost-server/utils"
"github.com/mattermost/mattermost-server/utils/markdown" "github.com/mattermost/mattermost-server/utils/markdown"
@@ -51,106 +52,134 @@ func (a *App) PreparePostListForClient(originalList *model.PostList) (*model.Pos
func (a *App) PreparePostForClient(originalPost *model.Post) (*model.Post, *model.AppError) { func (a *App) PreparePostForClient(originalPost *model.Post) (*model.Post, *model.AppError) {
post := originalPost.Clone() post := originalPost.Clone()
needReactionCounts := post.ReactionCounts == nil // Proxy image links before constructing metadata so that requests go through the proxy
needEmojis := post.Emojis == nil
needOpenGraphData := post.OpenGraphData == nil
needImageDimensions := post.ImageDimensions == nil
// Get reactions to post
var reactions []*model.Reaction
if needReactionCounts || needEmojis {
var err *model.AppError
reactions, err = a.GetReactionsForPost(post.Id)
if err != nil {
return post, err
}
}
if needReactionCounts {
post.ReactionCounts = model.CountReactions(reactions)
}
// Get emojis for post
if needEmojis {
emojis, err := a.getCustomEmojisForPost(post.Message, reactions)
if err != nil {
return post, err
}
post.Emojis = emojis
}
// Get files for post
if post.FileInfos == nil {
fileInfos, err := a.GetFileInfosForPost(post.Id, false)
if err != nil {
return post, err
}
post.FileInfos = fileInfos
}
// Proxy image links in post
post = a.PostWithProxyAddedToImageURLs(post) post = a.PostWithProxyAddedToImageURLs(post)
// Get OpenGraph and image metadata if post.Metadata == nil {
if needOpenGraphData || needImageDimensions { post.Metadata = &model.PostMetadata{}
err := a.preparePostWithOpenGraphAndImageMetadata(post, needOpenGraphData, needImageDimensions)
if err != nil { // Emojis and reaction counts
return post, err if emojis, reactionCounts, err := a.getEmojisAndReactionCountsForPost(post); err != nil {
mlog.Warn("Failed to get emojis and reactions for a post", mlog.String("post_id", post.Id), mlog.Any("err", err))
} else {
post.Metadata.Emojis = emojis
post.Metadata.ReactionCounts = reactionCounts
} }
// Files
if fileInfos, err := a.GetFileInfosForPost(post.Id, false); err != nil {
mlog.Warn("Failed to get files for a post", mlog.String("post_id", post.Id), mlog.Any("err", err))
} else {
post.Metadata.Files = fileInfos
}
// Embeds and image dimensions
firstLink, images := getFirstLinkAndImages(post.Message)
if embed, err := a.getEmbedForPost(post, firstLink); err != nil {
mlog.Warn("Failed to get embedded content for a post", mlog.String("post_id", post.Id), mlog.Any("err", err))
} else if embed == nil {
post.Metadata.Embeds = []*model.PostEmbed{}
} else {
post.Metadata.Embeds = []*model.PostEmbed{embed}
}
post.Metadata.Images = a.getImagesForPost(post, images)
} }
return post, nil return post, nil
} }
func (a *App) preparePostWithOpenGraphAndImageMetadata(post *model.Post, needOpenGraphData, needImageDimensions bool) *model.AppError { func (a *App) getEmojisAndReactionCountsForPost(post *model.Post) ([]*model.Emoji, model.ReactionCounts, *model.AppError) {
var appError *model.AppError reactions, err := a.GetReactionsForPost(post.Id)
if err != nil {
if needOpenGraphData { return nil, nil, err
post.OpenGraphData = []*opengraph.OpenGraph{}
} }
if needImageDimensions { emojis, err := a.getCustomEmojisForPost(post.Message, reactions)
post.ImageDimensions = []*model.PostImageDimensions{} if err != nil {
return nil, nil, err
} }
firstLink, images := getFirstLinkAndImages(post.Message) return emojis, model.CountReactions(reactions), nil
}
func (a *App) getEmbedForPost(post *model.Post, firstLink string) (*model.PostEmbed, error) {
if _, ok := post.Props["attachments"]; ok {
return &model.PostEmbed{
Type: model.POST_EMBED_MESSAGE_ATTACHMENT,
}, nil
}
// Look at the first link to see if it's a web page or an image
if firstLink != "" { if firstLink != "" {
og, dimensions, err := a.getLinkMetadata(firstLink, true) og, image, err := a.getLinkMetadata(firstLink, true)
if err != nil { if err != nil {
// Keep going so that one bad link doesn't prevent other image dimensions from being sent to the client return nil, err
appError = model.NewAppError("PreparePostForClient", "app.post.metadata.link.app_error", nil, err.Error(), http.StatusInternalServerError)
} }
if needOpenGraphData { if og != nil {
post.OpenGraphData = append(post.OpenGraphData, og) return &model.PostEmbed{
Type: model.POST_EMBED_OPENGRAPH,
URL: firstLink,
Data: og,
}, nil
} }
if needImageDimensions { if image != nil {
post.ImageDimensions = append(post.ImageDimensions, dimensions) // Note that we're not passing the image info here since they'll be part of the PostMetadata.Images field
return &model.PostEmbed{
Type: model.POST_EMBED_IMAGE,
URL: firstLink,
}, nil
} }
} }
if needImageDimensions { return nil, nil
// And dimensions for other images }
for _, image := range images {
_, dimensions, err := a.getLinkMetadata(image, true)
if err != nil {
// Keep going so that one bad link doesn't prevent other image dimensions from being sent to the client
appError = model.NewAppError("PreparePostForClient", "app.post.metadata.link.app_error", nil, err.Error(), http.StatusInternalServerError)
continue
}
if dimensions != nil { func (a *App) getImagesForPost(post *model.Post, imageURLs []string) map[string]*model.PostImage {
post.ImageDimensions = append(post.ImageDimensions, dimensions) images := map[string]*model.PostImage{}
for _, embed := range post.Metadata.Embeds {
switch embed.Type {
case model.POST_EMBED_IMAGE:
// These dimensions will generally be cached by a previous call to getEmbedForPost
imageURLs = append(imageURLs, embed.URL)
case model.POST_EMBED_MESSAGE_ATTACHMENT:
imageURLs = append(imageURLs, getImagesInMessageAttachments(post)...)
case model.POST_EMBED_OPENGRAPH:
for _, image := range embed.Data.(*opengraph.OpenGraph).Images {
if image.Width != 0 || image.Height != 0 {
// The site has already told us the image dimensions
images[image.URL] = &model.PostImage{
Width: int(image.Width),
Height: int(image.Height),
}
} else {
// The site did not specify its image dimensions
imageURLs = append(imageURLs, image.URL)
}
} }
} }
} }
return appError // 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 {
if _, image, err := a.getLinkMetadata(imageURL, true); err != nil {
mlog.Warn("Failed to get dimensions of an image in a post",
mlog.String("post_id", post.Id), mlog.String("image_url", imageURL), mlog.Any("err", err))
} else {
images[imageURL] = image
}
}
return images
} }
func (a *App) getCustomEmojisForPost(message string, reactions []*model.Reaction) ([]*model.Emoji, *model.AppError) { func (a *App) getCustomEmojisForPost(message string, reactions []*model.Reaction) ([]*model.Emoji, *model.AppError) {
@@ -200,20 +229,37 @@ func getFirstLinkAndImages(str string) (string, []string) {
return true return true
}) })
if len(images) > 1 {
images = model.RemoveDuplicateStrings(images)
}
return firstLink, images return firstLink, images
} }
func (a *App) getLinkMetadata(requestURL string, useCache bool) (*opengraph.OpenGraph, *model.PostImageDimensions, error) { func getImagesInMessageAttachments(post *model.Post) []string {
var images []string
for _, attachment := range post.Attachments() {
_, imagesInText := getFirstLinkAndImages(attachment.Text)
images = append(images, imagesInText...)
_, imagesInPretext := getFirstLinkAndImages(attachment.Pretext)
images = append(images, imagesInPretext...)
for _, field := range attachment.Fields {
if value, ok := field.Value.(string); ok {
_, imagesInFieldValue := getFirstLinkAndImages(value)
images = append(images, imagesInFieldValue...)
}
}
}
return images
}
func (a *App) getLinkMetadata(requestURL string, useCache bool) (*opengraph.OpenGraph, *model.PostImage, error) {
// Check cache // Check cache
if useCache { if useCache {
og, dimensions, ok := getLinkMetadataFromCache(requestURL) og, image, ok := getLinkMetadataFromCache(requestURL)
if ok { if ok {
return og, dimensions, nil return og, image, nil
} }
} }
@@ -232,17 +278,17 @@ func (a *App) getLinkMetadata(requestURL string, useCache bool) (*opengraph.Open
defer consumeAndClose(res) defer consumeAndClose(res)
// Parse the data // Parse the data
og, dimensions, err := a.parseLinkMetadata(requestURL, res.Body, res.Header.Get("Content-Type")) og, image, err := a.parseLinkMetadata(requestURL, res.Body, res.Header.Get("Content-Type"))
// Write back to cache // Write back to cache
if useCache { if useCache {
cacheLinkMetadata(requestURL, og, dimensions) cacheLinkMetadata(requestURL, og, image)
} }
return og, dimensions, err return og, image, err
} }
func getLinkMetadataFromCache(requestURL string) (*opengraph.OpenGraph, *model.PostImageDimensions, bool) { func getLinkMetadataFromCache(requestURL string) (*opengraph.OpenGraph, *model.PostImage, bool) {
cached, ok := linkCache.Get(requestURL) cached, ok := linkCache.Get(requestURL)
if !ok { if !ok {
return nil, nil, false return nil, nil, false
@@ -251,28 +297,28 @@ func getLinkMetadataFromCache(requestURL string) (*opengraph.OpenGraph, *model.P
switch v := cached.(type) { switch v := cached.(type) {
case *opengraph.OpenGraph: case *opengraph.OpenGraph:
return v, nil, true return v, nil, true
case *model.PostImageDimensions: case *model.PostImage:
return nil, v, true return nil, v, true
default: default:
return nil, nil, true return nil, nil, true
} }
} }
func cacheLinkMetadata(requestURL string, og *opengraph.OpenGraph, dimensions *model.PostImageDimensions) { func cacheLinkMetadata(requestURL string, og *opengraph.OpenGraph, image *model.PostImage) {
var val interface{} var val interface{}
if og != nil { if og != nil {
val = og val = og
} else if dimensions != nil { } else if image != nil {
val = dimensions val = image
} }
linkCache.AddWithExpiresInSecs(requestURL, val, LINK_CACHE_DURATION) linkCache.AddWithExpiresInSecs(requestURL, val, LINK_CACHE_DURATION)
} }
func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType string) (*opengraph.OpenGraph, *model.PostImageDimensions, error) { func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType string) (*opengraph.OpenGraph, *model.PostImage, error) {
if strings.HasPrefix(contentType, "image") { if strings.HasPrefix(contentType, "image") {
dimensions, err := parseImageDimensions(requestURL, body) image, err := parseImages(body)
return nil, dimensions, err return nil, image, err
} else if strings.HasPrefix(contentType, "text/html") { } else if strings.HasPrefix(contentType, "text/html") {
og := a.ParseOpenGraphMetadata(requestURL, body, contentType) og := a.ParseOpenGraphMetadata(requestURL, body, contentType)
@@ -289,17 +335,16 @@ func (a *App) parseLinkMetadata(requestURL string, body io.Reader, contentType s
} }
} }
func parseImageDimensions(requestURL string, body io.Reader) (*model.PostImageDimensions, error) { func parseImages(body io.Reader) (*model.PostImage, error) {
config, _, err := image.DecodeConfig(body) config, _, err := image.DecodeConfig(body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dimensions := &model.PostImageDimensions{ image := &model.PostImage{
URL: requestURL,
Width: config.Width, Width: config.Width,
Height: config.Height, Height: config.Height,
} }
return dimensions, nil return image, nil
} }

Просмотреть файл

@@ -41,20 +41,22 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
t.Run("doesn't mutate provided post", func(t *testing.T) {
assert.NotEqual(t, clientPost, post, "should've returned a new post") assert.NotEqual(t, clientPost, post, "should've returned a new post")
assert.Equal(t, message, post.Message, "shouldn't have mutated post.Message")
assert.NotEqual(t, nil, post.ReactionCounts, "shouldn't have mutated post.ReactionCounts")
assert.NotEqual(t, nil, post.FileInfos, "shouldn't have mutated post.FileInfos")
assert.NotEqual(t, nil, post.Emojis, "shouldn't have mutated post.Emojis")
assert.NotEqual(t, nil, post.ImageDimensions, "shouldn't have mutated post.ImageDimensions")
assert.NotEqual(t, nil, post.OpenGraphData, "shouldn't have mutated post.OpenGraphData")
assert.Equal(t, clientPost.Message, post.Message, "shouldn't have changed Message") assert.Equal(t, message, post.Message, "shouldn't have mutated post.Message")
assert.Len(t, post.ReactionCounts, 0, "should've populated ReactionCounts") assert.Equal(t, (*model.PostMetadata)(nil), post.Metadata, "shouldn't have mutated post.Metadata")
assert.Len(t, post.FileInfos, 0, "should've populated FileInfos") })
assert.Len(t, post.Emojis, 0, "should've populated Emojis")
assert.Len(t, post.ImageDimensions, 0, "should've populated ImageDimensions") t.Run("populates all fields", func(t *testing.T) {
assert.Len(t, post.OpenGraphData, 0, "should've populated OpenGraphData") assert.Equal(t, message, clientPost.Message, "shouldn't have changed Message")
assert.NotEqual(t, nil, clientPost.Metadata, "should've populated Metadata")
assert.Len(t, clientPost.Metadata.Embeds, 0, "should've populated Embeds")
assert.Len(t, clientPost.Metadata.ReactionCounts, 0, "should've populated ReactionCounts")
assert.Len(t, clientPost.Metadata.Files, 0, "should've populated Files")
assert.Len(t, clientPost.Metadata.Emojis, 0, "should've populated Emojis")
assert.Len(t, clientPost.Metadata.Images, 0, "should've populated Images")
})
}) })
t.Run("metadata already set", func(t *testing.T) { t.Run("metadata already set", func(t *testing.T) {
@@ -83,10 +85,10 @@ func TestPreparePostForClient(t *testing.T) {
assert.Equal(t, model.ReactionCounts{ assert.Equal(t, model.ReactionCounts{
"smile": 1, "smile": 1,
}, clientPost.ReactionCounts, "should've populated post.ReactionCounts") }, clientPost.Metadata.ReactionCounts, "should've populated ReactionCounts")
}) })
t.Run("file infos", func(t *testing.T) { t.Run("files", func(t *testing.T) {
th := setup() th := setup()
defer th.TearDown() defer th.TearDown()
@@ -105,7 +107,7 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
assert.Equal(t, []*model.FileInfo{fileInfo}, clientPost.FileInfos, "should've populated post.FileInfos") assert.Equal(t, []*model.FileInfo{fileInfo}, clientPost.Metadata.Files, "should've populated Files")
}) })
t.Run("emojis without custom emojis enabled", func(t *testing.T) { t.Run("emojis without custom emojis enabled", func(t *testing.T) {
@@ -132,10 +134,16 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
assert.Len(t, clientPost.ReactionCounts, 2, "should've populated post.ReactionCounts") t.Run("populates emojis", func(t *testing.T) {
assert.Equal(t, 1, clientPost.ReactionCounts["smile"], "should've populated post.ReactionCounts for smile") assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Metadata.Emojis, "should've populated empty Emojis")
assert.Equal(t, 2, clientPost.ReactionCounts["angry"], "should've populated post.ReactionCounts for angry") })
assert.ElementsMatch(t, []*model.Emoji{}, clientPost.Emojis, "should've populated empty post.Emojis")
t.Run("populates reaction counts", func(t *testing.T) {
reactionCounts := clientPost.Metadata.ReactionCounts
assert.Len(t, reactionCounts, 2, "should've populated ReactionCounts")
assert.Equal(t, 1, reactionCounts["smile"], "should've included 'smile' in ReactionCounts")
assert.Equal(t, 2, reactionCounts["angry"], "should've included 'angry' in ReactionCounts")
})
}) })
t.Run("emojis with custom emojis enabled", func(t *testing.T) { t.Run("emojis with custom emojis enabled", func(t *testing.T) {
@@ -165,11 +173,17 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
assert.Len(t, clientPost.ReactionCounts, 3, "should've populated post.ReactionCounts") t.Run("pupulates emojis", func(t *testing.T) {
assert.Equal(t, 1, clientPost.ReactionCounts[emoji1.Name], "should've populated post.ReactionCounts for emoji1") assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3}, clientPost.Metadata.Emojis, "should've populated post.Emojis")
assert.Equal(t, 2, clientPost.ReactionCounts[emoji2.Name], "should've populated post.ReactionCounts for emoji2") })
assert.Equal(t, 1, clientPost.ReactionCounts["angry"], "should've populated post.ReactionCounts for angry")
assert.ElementsMatch(t, []*model.Emoji{emoji1, emoji2, emoji3}, clientPost.Emojis, "should've populated post.Emojis") t.Run("populates reaction counts", func(t *testing.T) {
reactionCounts := clientPost.Metadata.ReactionCounts
assert.Len(t, reactionCounts, 3, "should've populated ReactionCounts")
assert.Equal(t, 1, reactionCounts[emoji1.Name], "should've included emoji1 in ReactionCounts")
assert.Equal(t, 2, reactionCounts[emoji2.Name], "should've included emoji2 in ReactionCounts")
assert.Equal(t, 1, reactionCounts["angry"], "should've included angry in ReactionCounts")
})
}) })
t.Run("markdown image dimensions", func(t *testing.T) { t.Run("markdown image dimensions", func(t *testing.T) {
@@ -186,20 +200,35 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
assert.Len(t, clientPost.ImageDimensions, 2) t.Run("populates image dimensions", func(t *testing.T) {
assert.Equal(t, &model.PostImageDimensions{ imageDimensions := clientPost.Metadata.Images
URL: "https://github.com/hmhealey/test-files/raw/master/logoVertical.png", assert.Len(t, imageDimensions, 2)
assert.Equal(t, &model.PostImage{
Width: 1068, Width: 1068,
Height: 552, Height: 552,
}, clientPost.ImageDimensions[0]) }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/logoVertical.png"])
assert.Equal(t, &model.PostImageDimensions{ assert.Equal(t, &model.PostImage{
URL: "https://github.com/hmhealey/test-files/raw/master/icon.png",
Width: 501, Width: 501,
Height: 501, Height: 501,
}, clientPost.ImageDimensions[1]) }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/icon.png"])
})
}) })
t.Run("linked image dimensions", func(t *testing.T) { t.Run("proxy linked images", func(t *testing.T) {
th := setup()
defer th.TearDown()
testProxyLinkedImage(t, th, false)
})
t.Run("proxy opengraph images", func(t *testing.T) {
th := setup()
defer th.TearDown()
testProxyOpenGraphImage(t, th, false)
})
t.Run("image embed", func(t *testing.T) {
th := setup() th := setup()
defer th.TearDown() defer th.TearDown()
@@ -214,23 +243,28 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
// Reminder that only the first link gets dimensions // Reminder that only the first link gets an embed and dimensions
assert.Len(t, clientPost.ImageDimensions, 1)
assert.Equal(t, &model.PostImageDimensions{ t.Run("populates embeds", func(t *testing.T) {
assert.ElementsMatch(t, []*model.PostEmbed{
{
Type: model.POST_EMBED_IMAGE,
URL: "https://github.com/hmhealey/test-files/raw/master/logoVertical.png", URL: "https://github.com/hmhealey/test-files/raw/master/logoVertical.png",
},
}, clientPost.Metadata.Embeds)
})
t.Run("populates image dimensions", func(t *testing.T) {
imageDimensions := clientPost.Metadata.Images
assert.Len(t, imageDimensions, 1)
assert.Equal(t, &model.PostImage{
Width: 1068, Width: 1068,
Height: 552, Height: 552,
}, clientPost.ImageDimensions[0]) }, imageDimensions["https://github.com/hmhealey/test-files/raw/master/logoVertical.png"])
})
}) })
t.Run("proxy linked images", func(t *testing.T) { t.Run("opengraph embed", func(t *testing.T) {
th := setup()
defer th.TearDown()
testProxyLinkedImage(t, th, false)
})
t.Run("opengraph", func(t *testing.T) {
th := setup() th := setup()
defer th.TearDown() defer th.TearDown()
@@ -244,8 +278,12 @@ func TestPreparePostForClient(t *testing.T) {
clientPost, err := th.App.PreparePostForClient(post) clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err) require.Nil(t, err)
assert.Len(t, clientPost.OpenGraphData, 1) t.Run("populates embeds", func(t *testing.T) {
assert.Equal(t, &opengraph.OpenGraph{ assert.ElementsMatch(t, []*model.PostEmbed{
{
Type: model.POST_EMBED_OPENGRAPH,
URL: "https://github.com/hmhealey/test-files",
Data: &opengraph.OpenGraph{
Description: "Contribute to hmhealey/test-files development by creating an account on GitHub.", Description: "Contribute to hmhealey/test-files development by creating an account on GitHub.",
SiteName: "GitHub", SiteName: "GitHub",
Title: "hmhealey/test-files", Title: "hmhealey/test-files",
@@ -256,18 +294,57 @@ func TestPreparePostForClient(t *testing.T) {
URL: "https://avatars1.githubusercontent.com/u/3277310?s=400&v=4", URL: "https://avatars1.githubusercontent.com/u/3277310?s=400&v=4",
}, },
}, },
}, clientPost.OpenGraphData[0]) },
},
}, clientPost.Metadata.Embeds)
}) })
t.Run("opengraph image dimensions", func(t *testing.T) { t.Run("populates image dimensions", func(t *testing.T) {
// TODO imageDimensions := clientPost.Metadata.Images
assert.Len(t, imageDimensions, 1)
assert.Equal(t, &model.PostImage{
Width: 420,
Height: 420,
}, imageDimensions["https://avatars1.githubusercontent.com/u/3277310?s=400&v=4"])
})
}) })
t.Run("proxy opengraph images", func(t *testing.T) { t.Run("message attachment embed", func(t *testing.T) {
th := setup() th := setup()
defer th.TearDown() defer th.TearDown()
testProxyOpenGraphImage(t, th, false) post, err := th.App.CreatePost(&model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
Props: map[string]interface{}{
"attachments": []interface{}{
map[string]interface{}{
"text": "![icon](https://github.com/hmhealey/test-files/raw/master/icon.png)",
},
},
},
}, th.BasicChannel, false)
require.Nil(t, err)
clientPost, err := th.App.PreparePostForClient(post)
require.Nil(t, err)
t.Run("populates embeds", func(t *testing.T) {
assert.ElementsMatch(t, []*model.PostEmbed{
{
Type: model.POST_EMBED_MESSAGE_ATTACHMENT,
},
}, clientPost.Metadata.Embeds)
})
t.Run("populates image dimensions", func(t *testing.T) {
imageDimensions := clientPost.Metadata.Images
assert.Len(t, imageDimensions, 1)
assert.Equal(t, &model.PostImage{
Width: 501,
Height: 501,
}, imageDimensions["https://github.com/hmhealey/test-files/raw/master/icon.png"])
})
}) })
} }
@@ -346,15 +423,20 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) {
image.URL = "https://avatars1.githubusercontent.com/u/3277310?s=400&v=4" image.URL = "https://avatars1.githubusercontent.com/u/3277310?s=400&v=4"
} }
assert.Len(t, clientPost.OpenGraphData, 1) assert.ElementsMatch(t, []*model.PostEmbed{
assert.Equal(t, &opengraph.OpenGraph{ {
Type: model.POST_EMBED_OPENGRAPH,
URL: "https://github.com/hmhealey/test-files",
Data: &opengraph.OpenGraph{
Description: "Contribute to hmhealey/test-files development by creating an account on GitHub.", Description: "Contribute to hmhealey/test-files development by creating an account on GitHub.",
SiteName: "GitHub", SiteName: "GitHub",
Title: "hmhealey/test-files", Title: "hmhealey/test-files",
Type: "object", Type: "object",
URL: "https://github.com/hmhealey/test-files", URL: "https://github.com/hmhealey/test-files",
Images: []*opengraph.Image{image}, Images: []*opengraph.Image{image},
}, clientPost.OpenGraphData[0]) },
},
}, clientPost.Metadata.Embeds)
} }
func TestGetCustomEmojisForPost_Message(t *testing.T) { func TestGetCustomEmojisForPost_Message(t *testing.T) {
@@ -503,7 +585,7 @@ func TestGetFirstLinkAndImages(t *testing.T) {
"multiple images with duplicate": { "multiple images with duplicate": {
Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo which is their logo](http://example.com/logo2)", Input: "this is a ![our logo](http://example.com/logo) and ![their logo](http://example.com/logo2) and ![my logo which is their logo](http://example.com/logo2)",
ExpectedFirstLink: "", ExpectedFirstLink: "",
ExpectedImages: []string{"http://example.com/logo", "http://example.com/logo2"}, ExpectedImages: []string{"http://example.com/logo", "http://example.com/logo2", "http://example.com/logo2"},
}, },
"reference image": { "reference image": {
Input: `this is a ![our logo][logo] Input: `this is a ![our logo][logo]
@@ -534,6 +616,166 @@ func TestGetFirstLinkAndImages(t *testing.T) {
} }
} }
func TestGetImagesInMessageAttachments(t *testing.T) {
for _, test := range []struct {
Name string
Post *model.Post
Expected []string
}{
{
Name: "no attachments",
Post: &model.Post{},
Expected: []string{},
},
{
Name: "empty attachments",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{},
},
},
Expected: []string{},
},
{
Name: "attachment with no fields that can contain images",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Title: "This is the title",
},
},
},
},
Expected: []string{},
},
{
Name: "images in text",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Text: "![logo](https://example.com/logo) and ![icon](https://example.com/icon)",
},
},
},
},
Expected: []string{"https://example.com/logo", "https://example.com/icon"},
},
{
Name: "images in pretext",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Pretext: "![logo](https://example.com/logo1) and ![icon](https://example.com/icon1)",
},
},
},
},
Expected: []string{"https://example.com/logo1", "https://example.com/icon1"},
},
{
Name: "images in fields",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Fields: []*model.SlackAttachmentField{
{
Value: "![logo](https://example.com/logo2) and ![icon](https://example.com/icon2)",
},
},
},
},
},
},
Expected: []string{"https://example.com/logo2", "https://example.com/icon2"},
},
{
Name: "images in multiple fields",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Fields: []*model.SlackAttachmentField{
{
Value: "![logo](https://example.com/logo)",
},
{
Value: "![icon](https://example.com/icon)",
},
},
},
},
},
},
Expected: []string{"https://example.com/logo", "https://example.com/icon"},
},
{
Name: "non-string field",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Fields: []*model.SlackAttachmentField{
{
Value: 77,
},
},
},
},
},
},
Expected: []string{},
},
{
Name: "images in multiple locations",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Text: "![text](https://example.com/text)",
Pretext: "![pretext](https://example.com/pretext)",
Fields: []*model.SlackAttachmentField{
{
Value: "![field1](https://example.com/field1)",
},
{
Value: "![field2](https://example.com/field2)",
},
},
},
},
},
},
Expected: []string{"https://example.com/text", "https://example.com/pretext", "https://example.com/field1", "https://example.com/field2"},
},
{
Name: "multiple attachments",
Post: &model.Post{
Props: map[string]interface{}{
"attachments": []*model.SlackAttachment{
{
Text: "![logo](https://example.com/logo)",
},
{
Text: "![icon](https://example.com/icon)",
},
},
},
},
Expected: []string{"https://example.com/logo", "https://example.com/icon"},
},
} {
t.Run(test.Name, func(t *testing.T) {
images := getImagesInMessageAttachments(test.Post)
assert.ElementsMatch(t, images, test.Expected)
})
}
}
func TestParseLinkMetadata(t *testing.T) { func TestParseLinkMetadata(t *testing.T) {
th := Setup().InitBasic() th := Setup().InitBasic()
defer th.TearDown() defer th.TearDown()
@@ -565,8 +807,7 @@ func TestParseLinkMetadata(t *testing.T) {
assert.Nil(t, err) assert.Nil(t, err)
assert.Nil(t, og) assert.Nil(t, og)
assert.Equal(t, &model.PostImageDimensions{ assert.Equal(t, &model.PostImage{
URL: imageURL,
Width: 408, Width: 408,
Height: 336, Height: 336,
}, dimensions) }, dimensions)
@@ -608,29 +849,25 @@ func TestParseLinkMetadata(t *testing.T) {
}) })
} }
func TestParseImageDimensions(t *testing.T) { func TestParseImages(t *testing.T) {
for name, testCase := range map[string]struct { for name, testCase := range map[string]struct {
FileName string FileName string
URL string
ExpectedWidth int ExpectedWidth int
ExpectedHeight int ExpectedHeight int
ExpectError bool ExpectError bool
}{ }{
"png": { "png": {
FileName: "test.png", FileName: "test.png",
URL: "https://example.com/test.png",
ExpectedWidth: 408, ExpectedWidth: 408,
ExpectedHeight: 336, ExpectedHeight: 336,
}, },
"animated gif": { "animated gif": {
FileName: "testgif.gif", FileName: "testgif.gif",
URL: "http://example.com/test.gif?foo=bar",
ExpectedWidth: 118, ExpectedWidth: 118,
ExpectedHeight: 118, ExpectedHeight: 118,
}, },
"not an image": { "not an image": {
FileName: "README.md", FileName: "README.md",
URL: "https://example.com/test.png",
ExpectError: true, ExpectError: true,
}, },
} { } {
@@ -638,14 +875,13 @@ func TestParseImageDimensions(t *testing.T) {
file, err := testutils.ReadTestFile(testCase.FileName) file, err := testutils.ReadTestFile(testCase.FileName)
require.Nil(t, err) require.Nil(t, err)
dimensions, err := parseImageDimensions(testCase.URL, bytes.NewReader(file)) dimensions, err := parseImages(bytes.NewReader(file))
if testCase.ExpectError { if testCase.ExpectError {
require.NotNil(t, err) require.NotNil(t, err)
} else { } else {
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, dimensions) require.NotNil(t, dimensions)
require.Equal(t, testCase.URL, dimensions.URL)
require.Equal(t, testCase.ExpectedWidth, dimensions.Width) require.Equal(t, testCase.ExpectedWidth, dimensions.Width)
require.Equal(t, testCase.ExpectedHeight, dimensions.Height) require.Equal(t, testCase.ExpectedHeight, dimensions.Height)
} }

Просмотреть файл

@@ -11,7 +11,6 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"github.com/dyatlov/go-opengraph/opengraph"
"github.com/mattermost/mattermost-server/utils/markdown" "github.com/mattermost/mattermost-server/utils/markdown"
) )
@@ -83,18 +82,8 @@ type Post struct {
PendingPostId string `json:"pending_post_id" db:"-"` PendingPostId string `json:"pending_post_id" db:"-"`
HasReactions bool `json:"has_reactions,omitempty"` // Deprecated, do not use this field any more HasReactions bool `json:"has_reactions,omitempty"` // Deprecated, do not use this field any more
// Transient fields populated before sending posts to the client // Transient data populated before sending a post to the client
ReactionCounts ReactionCounts `json:"reaction_counts" db:"-"` Metadata *PostMetadata `json:"metadata" db:"-"`
FileInfos []*FileInfo `json:"file_infos" db:"-"`
ImageDimensions []*PostImageDimensions `json:"image_dimensions" db:"-"`
OpenGraphData []*opengraph.OpenGraph `json:"opengraph_data" db:"-"`
Emojis []*Emoji `json:"emojis" db:"-"`
}
type PostImageDimensions struct {
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
} }
type PostEphemeral struct { type PostEphemeral struct {

22
model/post_embed.go Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
const (
POST_EMBED_IMAGE PostEmbedType = "image"
POST_EMBED_MESSAGE_ATTACHMENT PostEmbedType = "message_attachment"
POST_EMBED_OPENGRAPH PostEmbedType = "opengraph"
)
type PostEmbedType string
type PostEmbed struct {
Type PostEmbedType `json:"type"`
// The URL of the embedded content. Used for image and OpenGraph embeds.
URL string `json:"url,omitempty"`
// Any additional data for the embedded content. Only used for OpenGraph embeds.
Data interface{} `json:"data,omitempty"`
}

28
model/post_metadata.go Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
package model
type PostMetadata struct {
// An array of the information required to render additional details about the contents of this post.
Embeds []*PostEmbed `json:"embeds,omitempty"`
// An arrayof the custom emojis used in the post or in reactions to the post.
Emojis []*Emoji `json:"emojis,omitempty"`
// An array of information about the file attachments on the post.
Files []*FileInfo `json:"files,omitempty"`
// A map of image URL to information about all external images in the post. This includes image embeds,
// inline Markdown images, OpenGraph images, and message attachment images, but it does not contain the dimensions
// of file attachments which are contained in PostMetadata.FileInfos.
Images map[string]*PostImage `json:"images,omitempty"`
// A map of emoji names to a count of users that reacted with the given emoji.
ReactionCounts ReactionCounts `json:"reaction_counts,omitempty"`
}
type PostImage struct {
Width int `json:"width"`
Height int `json:"height"`
}