MM-22845: Added support for permalink previews. (#17796)

* MM-22845: Added support for permalink previews.

* MM-22845: Adds license to new file.

* MM-22845: Adds endpoint to retrieve multiple posts by id.

* MM-22845: Fix for deleted post.

* MM-22845: Adds config setting for permalink previews.

* MM-22845: Adds API test for new endpoint.

* MM-22845: Fix typo.

* MM-22845: Tests that post create or updated via App get the previewed_post prop.

* MM-22845: Tests for matching permalinks.

* MM-22845: Adds PreparePostForClient test for permalink previews.

* MM-22845: Embeds entire post in permalink metadata.

* MM-22845: Filter WS message payload of created and edited post based on permissions.

* MM-22845: Runs app layer generator.

* MM-22845: Lint check fix.

* MM-22845: Adds feature flag.

* MM-22845: Clones WS message.

* MM-22845: Removes knowledge of permalink from LinkMetadata table. Removes knowledge of user id from post embedding methods in favour of a 'sanitize' method/step.

* MM-22845: Handle nil post metadata.

* MM-22845: Switch to cloning post.

* MM-22845: Removes unused code.

* MM-22845: Refactor.

* MM-22845: Reverts whitespace change.

* MM-22845: Removes unnecessary code.

* MM-22845: Removes unnecessary function.

* MM-22845: Warn but don't error if permalinked referenced post or channel is not found.

* MM-22845: Fix for clone method.

* MM-22845: Fix for clone method.

* MM-22845: Updates translations.

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
Martin Kraft
2021-08-09 11:33:21 -04:00
коммит произвёл GitHub
родитель 56be6b539c
Коммит 21639f9c87
28 изменённых файлов: 645 добавлений и 115 удалений

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

@@ -5,11 +5,13 @@ package app
import (
"bytes"
"fmt"
"image"
"io"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -26,6 +28,7 @@ import (
type linkMetadataCache struct {
OpenGraph *opengraph.OpenGraph
PostImage *model.PostImage
Permalink *model.Permalink
}
const LinkCacheSize = 10000
@@ -138,6 +141,41 @@ func (a *App) PreparePostForClient(originalPost *model.Post, isNewPost bool, isE
return post
}
func (a *App) SanitizePostMetadataForUser(post *model.Post, userID string) (*model.Post, *model.AppError) {
if post.Metadata == nil || len(post.Metadata.Embeds) == 0 {
return post, nil
}
previewPost := post.GetPreviewPost()
if previewPost == nil {
return post, nil
}
previewedChannel, err := a.GetChannel(previewPost.Post.ChannelId)
if err != nil {
return nil, err
}
if previewedChannel != nil && !a.HasPermissionToReadChannel(userID, previewedChannel) {
post = post.Clone()
post.Metadata.Embeds[0].Data = nil
}
return post, nil
}
func (a *App) SanitizePostListMetadataForUser(postList *model.PostList, userID string) (*model.PostList, *model.AppError) {
clonedPostList := postList.Clone()
for postID, post := range clonedPostList.Posts {
sanitizedPost, err := a.SanitizePostMetadataForUser(post, userID)
if err != nil {
return nil, err
}
clonedPostList.Posts[postID] = sanitizedPost
}
return clonedPostList, nil
}
func (a *App) getFileMetadataForPost(post *model.Post, fromMaster bool) ([]*model.FileInfo, *model.AppError) {
if len(post.FileIds) == 0 {
return nil, nil
@@ -171,15 +209,24 @@ func (a *App) getEmbedForPost(post *model.Post, firstLink string, isNewPost bool
}, nil
}
if firstLink == "" || !*a.Config().ServiceSettings.EnableLinkPreviews {
if firstLink == "" {
return nil, nil
}
og, image, err := a.getLinkMetadata(firstLink, post.CreateAt, isNewPost)
// 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(firstLink, post.CreateAt, isNewPost)
if err != nil {
return nil, err
}
if !*a.Config().ServiceSettings.EnablePermalinkPreviews || !a.Config().FeatureFlags.PermalinkPreviews {
permalink = nil
}
if og != nil {
return &model.PostEmbed{
Type: model.PostEmbedOpengraph,
@@ -196,6 +243,10 @@ func (a *App) getEmbedForPost(post *model.Post, firstLink string, isNewPost bool
}, nil
}
if permalink != nil {
return &model.PostEmbed{Type: model.PostEmbedPermalink, Data: permalink.PreviewPost}, nil
}
return &model.PostEmbed{
Type: model.PostEmbedLink,
URL: firstLink,
@@ -238,7 +289,7 @@ func (a *App) getImagesForPost(post *model.Post, imageURLs []string, isNewPost b
}
for _, imageURL := range imageURLs {
if _, image, err := a.getLinkMetadata(imageURL, post.CreateAt, isNewPost); err != nil {
if _, image, _, err := a.getLinkMetadata(imageURL, post.CreateAt, isNewPost); err != nil {
mlog.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 {
@@ -393,74 +444,114 @@ func (a *App) getImagesInMessageAttachments(post *model.Post) []string {
return images
}
func (a *App) getLinkMetadata(requestURL string, timestamp int64, isNewPost bool) (*opengraph.OpenGraph, *model.PostImage, error) {
func looksLikeAPermalink(url, siteURL string) bool {
expression := fmt.Sprintf(`^(%s).*(/pl/)[a-z0-9]{26}$`, siteURL)
matched, err := regexp.MatchString(expression, strings.TrimSpace(url))
if err != nil {
mlog.Warn("error matching regex", mlog.Err(err))
}
return matched
}
func (a *App) getLinkMetadata(requestURL string, timestamp int64, isNewPost bool) (*opengraph.OpenGraph, *model.PostImage, *model.Permalink, error) {
requestURL = resolveMetadataURL(requestURL, a.GetSiteURL())
timestamp = model.FloorToNearestHour(timestamp)
// Check cache
og, image, ok := getLinkMetadataFromCache(requestURL, timestamp)
og, image, permalink, ok := getLinkMetadataFromCache(requestURL, timestamp)
if !*a.Config().ServiceSettings.EnablePermalinkPreviews || !a.Config().FeatureFlags.PermalinkPreviews {
permalink = nil
}
if ok {
return og, image, nil
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 {
cacheLinkMetadata(requestURL, timestamp, og, image)
return og, image, nil
cacheLinkMetadata(requestURL, timestamp, og, image, nil)
return og, image, nil, nil
}
}
// Make request for a web page or an image
request, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, nil, err
}
var err error
var body io.ReadCloser
var contentType string
if looksLikeAPermalink(requestURL, a.GetSiteURL()) && *a.Config().ServiceSettings.EnablePermalinkPreviews && a.Config().FeatureFlags.PermalinkPreviews {
referencedPostID := requestURL[len(requestURL)-26:]
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()))
referencedPost, appErr := a.GetSinglePost(referencedPostID)
// Ignore 'not found' errors; post could have been deleted via retention policy so we don't want to permanently log a warning.
//
// TODO: Look into saving a value in the LinkMetadat.Data field to prevent perpetually re-querying for the deleted post.
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return nil, nil, nil, appErr
}
referencedChannel, appErr := a.GetChannel(referencedPost.ChannelId)
if appErr != nil {
return nil, nil, nil, appErr
}
referencedTeam, appErr := a.GetTeam(referencedChannel.TeamId)
if appErr != nil {
return nil, nil, nil, appErr
}
permalink = &model.Permalink{PreviewPost: model.NewPreviewPost(referencedPost, referencedTeam, referencedChannel)}
} else {
request.Header.Add("Accept", "image/*")
request.Header.Add("Accept", "text/html;q=0.8")
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 res != nil {
body = res.Body
contentType = res.Header.Get("Content-Type")
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, nil, err
}
}
if body != nil {
defer func() {
io.Copy(ioutil.Discard, body)
body.Close()
}()
}
var body io.ReadCloser
var contentType string
if err == nil {
// Parse the data
og, image, err = a.parseLinkMetadata(requestURL, body, contentType)
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")
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 res != nil {
body = res.Body
contentType = res.Header.Get("Content-Type")
}
}
if body != nil {
defer func() {
io.Copy(ioutil.Discard, body)
body.Close()
}()
}
if err == nil {
// Parse the data
og, image, err = a.parseLinkMetadata(requestURL, body, contentType)
}
og = model.TruncateOpenGraph(og) // remove unwanted length of texts
a.saveLinkMetadataToDatabase(requestURL, timestamp, og, image)
}
og = model.TruncateOpenGraph(og) // remove unwanted length of texts
// Write back to cache and database, even if there was an error and the results are nil
cacheLinkMetadata(requestURL, timestamp, og, image)
cacheLinkMetadata(requestURL, timestamp, og, image, permalink)
a.saveLinkMetadataToDatabase(requestURL, timestamp, og, image)
return og, image, err
return og, image, permalink, err
}
// resolveMetadataURL resolves a given URL relative to the server's site URL.
@@ -478,14 +569,14 @@ func resolveMetadataURL(requestURL string, siteURL string) string {
return resolved.String()
}
func getLinkMetadataFromCache(requestURL string, timestamp int64) (*opengraph.OpenGraph, *model.PostImage, bool) {
func getLinkMetadataFromCache(requestURL string, timestamp int64) (*opengraph.OpenGraph, *model.PostImage, *model.Permalink, bool) {
var cached linkMetadataCache
err := linkCache.Get(strconv.FormatInt(model.GenerateLinkMetadataHash(requestURL, timestamp), 16), &cached)
if err != nil {
return nil, nil, false
return nil, nil, nil, false
}
return cached.OpenGraph, cached.PostImage, true
return cached.OpenGraph, cached.PostImage, cached.Permalink, true
}
func (a *App) getLinkMetadataFromDatabase(requestURL string, timestamp int64) (*opengraph.OpenGraph, *model.PostImage, bool) {
@@ -528,10 +619,11 @@ func (a *App) saveLinkMetadataToDatabase(requestURL string, timestamp int64, og
}
}
func cacheLinkMetadata(requestURL string, timestamp int64, og *opengraph.OpenGraph, image *model.PostImage) {
func cacheLinkMetadata(requestURL string, timestamp int64, og *opengraph.OpenGraph, image *model.PostImage, permalink *model.Permalink) {
metadata := linkMetadataCache{
OpenGraph: og,
PostImage: image,
Permalink: permalink,
}
linkCache.SetWithExpiry(strconv.FormatInt(model.GenerateLinkMetadataHash(requestURL, timestamp), 16), metadata, LinkCacheDuration)