[feature] Preserve whitespace in opengraph meta description elements (#4658)
# Description > If this is a code change, please include a summary of what you've coded, and link to the issue(s) it closes/implements. > > If this is a documentation change, please briefly describe what you've changed and why. More dicking about with opengraph meta stuff. This one preserves whitespace and newlines and indentation and stuff inside of ogmeta description tags. Should make multi-line posts look a LOT better when linked in discord etc.  Also closes https://codeberg.org/superseriousbusiness/gotosocial/issues/2452 for all intents + purposes, since text inside description tags is now converted to text, and go templating escapes them instead of them being escaped and *then* truncated. ## Checklist Please put an x inside each checkbox to indicate that you've read and followed it: `[ ]` -> `[x]` If this is a documentation change, only the first two checkboxes must be filled (you can delete the others if you want). - [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md). - [x] I/we have not used so-called 'AI' to create the proposed changes. - [x] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat. - [x] I/we have performed a self-review of added code. - [x] I/we have written code that is legible and maintainable by others. - [x] I/we have commented the added code, particularly in hard-to-understand areas. - [x] I/we have made any necessary changes to documentation. - [x] I/we have added tests that cover new code. - [x] I/we have run tests and they pass locally with the changes. - [x] I/we have run `go fmt ./...` and `golangci-lint run`. Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4658 Co-authored-by: tobi <tobi.smethurst@protonmail.com> Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
+189
-124
@@ -18,10 +18,9 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"html"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
|
||||
"code.superseriousbusiness.org/gotosocial/internal/text"
|
||||
@@ -81,6 +80,8 @@ type OGMedia struct {
|
||||
// the base root of an instance. It also serves as a
|
||||
// foundation for building account / status ogMeta.
|
||||
func OGBase(instance *apimodel.InstanceV1) *OGMeta {
|
||||
// Take first
|
||||
// lang as locale.
|
||||
var locale string
|
||||
if len(instance.Languages) > 0 {
|
||||
locale = instance.Languages[0]
|
||||
@@ -92,7 +93,7 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta {
|
||||
Locale: locale,
|
||||
URL: instance.URI,
|
||||
SiteName: instance.AccountDomain,
|
||||
Description: ParseDescription(instance.ShortDescription),
|
||||
Description: toDescription(instance.ShortDescription),
|
||||
Media: []OGMedia{
|
||||
{
|
||||
OGType: "image",
|
||||
@@ -106,24 +107,51 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta {
|
||||
return og
|
||||
}
|
||||
|
||||
// WithAccount uses the given account to build
|
||||
// an ogMeta struct specific to that account.
|
||||
// OGAccount builds an ogMeta struct for the given account.
|
||||
// It's suitable for serving at account profile pages.
|
||||
func (o *OGMeta) WithAccount(acct *apimodel.WebAccount) *OGMeta {
|
||||
o.Title = AccountTitle(acct, o.SiteName)
|
||||
o.ProfileUsername = acct.Username + "@" + o.SiteName
|
||||
o.Type = "profile"
|
||||
o.URL = acct.URL
|
||||
if acct.Note != "" {
|
||||
o.Description = ParseDescription(acct.Note)
|
||||
} else {
|
||||
const desc = "This GoToSocial user hasn't written a bio yet!"
|
||||
o.Description = desc
|
||||
func OGAccount(
|
||||
instance *apimodel.InstanceV1,
|
||||
acct *apimodel.WebAccount,
|
||||
) *OGMeta {
|
||||
// Set title to something like
|
||||
// "Display Name (@username@account.domain)"
|
||||
accountdomain := instance.AccountDomain
|
||||
title := AccountTitle(acct, accountdomain)
|
||||
|
||||
// Take first
|
||||
// lang as locale.
|
||||
var locale string
|
||||
if len(instance.Languages) > 0 {
|
||||
locale = instance.Languages[0]
|
||||
}
|
||||
|
||||
// Set avatar image.
|
||||
o.Media = []OGMedia{ogImgForAcct(acct)}
|
||||
return o
|
||||
// Create description
|
||||
// from note (if set).
|
||||
var description string
|
||||
if acct.Note != "" {
|
||||
description = toDescription(acct.Note)
|
||||
} else {
|
||||
const emptyDesc = "This GoToSocial user hasn't written a bio yet!"
|
||||
description = emptyDesc
|
||||
}
|
||||
|
||||
// Parse image from
|
||||
// account avatar (if set).
|
||||
media := []OGMedia{ogImgForAcct(acct)}
|
||||
|
||||
// ProfileUsername in format `someone@example.org`.
|
||||
profileUsername := acct.Username + "@" + accountdomain
|
||||
|
||||
return &OGMeta{
|
||||
Title: title,
|
||||
Type: "profile",
|
||||
Locale: locale,
|
||||
URL: acct.URL,
|
||||
SiteName: accountdomain,
|
||||
Description: truncate(description),
|
||||
Media: media,
|
||||
ProfileUsername: profileUsername,
|
||||
}
|
||||
}
|
||||
|
||||
// util funct to return OGImage using account.
|
||||
@@ -176,30 +204,35 @@ func ogImgForAcct(account *apimodel.WebAccount) OGMedia {
|
||||
return ogMedia
|
||||
}
|
||||
|
||||
// WithStatus uses the given status to build
|
||||
// and ogMeta struct specific to that status.
|
||||
// It's suitable for serving at status pages.
|
||||
func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
|
||||
// OGStatus builds an ogMeta struct for
|
||||
// the given status by the given account.
|
||||
// It's suitable for serving at thread pages.
|
||||
func OGStatus(
|
||||
instance *apimodel.InstanceV1,
|
||||
acct *apimodel.WebAccount,
|
||||
status *apimodel.WebStatus,
|
||||
) *OGMeta {
|
||||
// Set title to something like
|
||||
// "Display Name (@username@account.domain)"
|
||||
o.Title = AccountTitle(status.Account, o.SiteName)
|
||||
accountdomain := instance.AccountDomain
|
||||
title := AccountTitle(acct, accountdomain)
|
||||
|
||||
// It's a post not an article
|
||||
// but this is all we have.
|
||||
o.Type = "article"
|
||||
if status.Language != nil {
|
||||
o.Locale = *status.Language
|
||||
// Take first
|
||||
// lang as locale.
|
||||
var locale string
|
||||
if len(instance.Languages) > 0 {
|
||||
locale = instance.Languages[0]
|
||||
}
|
||||
|
||||
// Self-explanatory.
|
||||
o.URL = status.URL
|
||||
|
||||
// Derive description based on
|
||||
// sensitivity + media attachments.
|
||||
attachLen := len(status.MediaAttachments)
|
||||
attachSet := attachLen != 0
|
||||
cwSet := status.SpoilerText != ""
|
||||
contentSet := status.Text != ""
|
||||
var (
|
||||
description string
|
||||
attachLen = len(status.MediaAttachments)
|
||||
attachSet = attachLen != 0
|
||||
cwSet = status.SpoilerContent != ""
|
||||
contentSet = status.Content != ""
|
||||
)
|
||||
|
||||
switch {
|
||||
|
||||
@@ -208,10 +241,11 @@ func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
|
||||
// we should not use the post content
|
||||
// at all in the description.
|
||||
case cwSet:
|
||||
content := toDescription(status.SpoilerContent)
|
||||
if attachSet {
|
||||
o.Description = ParseDescription("Sensitive content [" + mediaCount(attachLen) + "]" + ": " + status.SpoilerText)
|
||||
description = "Sensitive content [" + mediaCount(attachLen) + "]" + ": " + content
|
||||
} else {
|
||||
o.Description = ParseDescription("Sensitive content: " + status.SpoilerText)
|
||||
description = "Sensitive content: " + content
|
||||
}
|
||||
|
||||
// There's no content warning set but
|
||||
@@ -220,120 +254,136 @@ func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta {
|
||||
// status content in the description
|
||||
// but warn that it's sensitive.
|
||||
case status.Sensitive && contentSet:
|
||||
content := toDescription(status.Content)
|
||||
if attachSet {
|
||||
o.Description = ParseDescription("Sensitive content [" + mediaCount(attachLen) + "]" + ": " + status.Text)
|
||||
description = "Sensitive content [" + mediaCount(attachLen) + "]" + ": " + content
|
||||
} else {
|
||||
o.Description = ParseDescription("Sensitive content: " + status.Text)
|
||||
description = "Sensitive content: " + content
|
||||
}
|
||||
|
||||
// There's no content warning set
|
||||
// and no text content set, but
|
||||
// there are sensitive attachments.
|
||||
case status.Sensitive && attachSet:
|
||||
o.Description = "Sensitive media: " + mediaCount(attachLen)
|
||||
description = "Sensitive media [" + mediaCount(attachLen) + "]"
|
||||
|
||||
// Status isn't sensitive and there's
|
||||
// no content warning set, use the
|
||||
// post content in the description.
|
||||
case !status.Sensitive && contentSet:
|
||||
content := toDescription(status.Content)
|
||||
if attachSet {
|
||||
o.Description = ParseDescription("[" + mediaCount(attachLen) + "] " + status.Text)
|
||||
description = "[" + mediaCount(attachLen) + "] " + content
|
||||
} else {
|
||||
o.Description = ParseDescription(status.Text)
|
||||
description = content
|
||||
}
|
||||
|
||||
// Status isn't sensitive and there's
|
||||
// no content warning or content set.
|
||||
case !status.Sensitive && !contentSet:
|
||||
if attachSet {
|
||||
o.Description = mediaCount(attachLen)
|
||||
description = mediaCount(attachLen)
|
||||
} else {
|
||||
o.Description = ParseDescription("Post by " + o.Title)
|
||||
description = "Post by " + title
|
||||
}
|
||||
|
||||
// Fall back to
|
||||
// account title.
|
||||
default:
|
||||
o.Description = o.Title
|
||||
description = title
|
||||
}
|
||||
|
||||
o.ArticlePublisher = status.Account.URL
|
||||
o.ArticleAuthor = status.Account.URL
|
||||
o.ArticlePublishedTime = status.CreatedAt
|
||||
o.ArticleModifiedTime = util.PtrOrValue(status.EditedAt, status.CreatedAt)
|
||||
// Prepare status media.
|
||||
var (
|
||||
media []OGMedia
|
||||
twitterSummaryLargeImage string
|
||||
twitterImageAlt string
|
||||
)
|
||||
|
||||
// Clear any existing medias.
|
||||
o.Media = []OGMedia{}
|
||||
|
||||
// If media is sensitive then
|
||||
// don't append it to preview.
|
||||
if status.Sensitive {
|
||||
return o
|
||||
}
|
||||
|
||||
// Add image / media previews.
|
||||
for _, a := range status.MediaAttachments {
|
||||
if a.Type == "unknown" {
|
||||
// Skip unknown.
|
||||
continue
|
||||
}
|
||||
|
||||
// Start building entry
|
||||
// with common media tags.
|
||||
desc := util.PtrOrZero(a.Description)
|
||||
ogMedia := OGMedia{
|
||||
URL: *a.URL,
|
||||
MIMEType: a.MIMEType,
|
||||
Alt: desc,
|
||||
}
|
||||
|
||||
// Add further tags
|
||||
// depending on type.
|
||||
switch a.Type {
|
||||
|
||||
case "image":
|
||||
ogMedia.OGType = "image"
|
||||
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
|
||||
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
|
||||
|
||||
// If this image is the only piece of media,
|
||||
// set TwitterSummaryLargeImage to indicate
|
||||
// that a large image summary is preferred.
|
||||
if attachLen == 1 {
|
||||
o.TwitterSummaryLargeImage = *a.URL
|
||||
o.TwitterImageAlt = desc
|
||||
// Only append media to
|
||||
// preview if not sensitive.
|
||||
if !status.Sensitive {
|
||||
for _, a := range status.MediaAttachments {
|
||||
if a.Type == "unknown" {
|
||||
// Skip unknown.
|
||||
continue
|
||||
}
|
||||
|
||||
case "audio":
|
||||
ogMedia.OGType = "audio"
|
||||
// Start building entry
|
||||
// with common media tags.
|
||||
desc := util.PtrOrZero(a.Description)
|
||||
ogMedia := OGMedia{
|
||||
URL: *a.URL,
|
||||
MIMEType: a.MIMEType,
|
||||
Alt: desc,
|
||||
}
|
||||
|
||||
case "video", "gifv":
|
||||
ogMedia.OGType = "video"
|
||||
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
|
||||
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
|
||||
}
|
||||
// Add further tags
|
||||
// depending on type.
|
||||
switch a.Type {
|
||||
|
||||
// Add this to our gathered entries.
|
||||
o.Media = append(o.Media, ogMedia)
|
||||
case "image":
|
||||
ogMedia.OGType = "image"
|
||||
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
|
||||
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
|
||||
|
||||
// Include static/thumb for non-visual files
|
||||
// (eg., audios) if they have a preview url set.
|
||||
if a.Type != "image" && a.PreviewURL != nil {
|
||||
o.Media = append(
|
||||
o.Media,
|
||||
OGMedia{
|
||||
OGType: "image",
|
||||
URL: *a.PreviewURL,
|
||||
MIMEType: a.PreviewMIMEType,
|
||||
Width: strconv.Itoa(a.Meta.Small.Width),
|
||||
Height: strconv.Itoa(a.Meta.Small.Height),
|
||||
Alt: util.PtrOrZero(a.Description),
|
||||
},
|
||||
)
|
||||
// If this image is the only piece of media,
|
||||
// set TwitterSummaryLargeImage to indicate
|
||||
// that a large image summary is preferred.
|
||||
if attachLen == 1 {
|
||||
twitterSummaryLargeImage = *a.URL
|
||||
twitterImageAlt = desc
|
||||
}
|
||||
|
||||
case "audio":
|
||||
ogMedia.OGType = "audio"
|
||||
|
||||
case "video", "gifv":
|
||||
ogMedia.OGType = "video"
|
||||
ogMedia.Width = strconv.Itoa(a.Meta.Original.Width)
|
||||
ogMedia.Height = strconv.Itoa(a.Meta.Original.Height)
|
||||
}
|
||||
|
||||
// Add this to our gathered entries.
|
||||
media = append(media, ogMedia)
|
||||
|
||||
// Include static/thumb for non-visual files
|
||||
// (eg., audios) if they have a preview url set.
|
||||
if a.Type != "image" && a.PreviewURL != nil {
|
||||
media = append(
|
||||
media,
|
||||
OGMedia{
|
||||
OGType: "image",
|
||||
URL: *a.PreviewURL,
|
||||
MIMEType: a.PreviewMIMEType,
|
||||
Width: strconv.Itoa(a.Meta.Small.Width),
|
||||
Height: strconv.Itoa(a.Meta.Small.Height),
|
||||
Alt: util.PtrOrZero(a.Description),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return o
|
||||
// ProfileUsername in format `someone@example.org`.
|
||||
profileUsername := acct.Username + "@" + accountdomain
|
||||
|
||||
return &OGMeta{
|
||||
Title: title,
|
||||
Type: "article",
|
||||
Locale: locale,
|
||||
URL: status.URL,
|
||||
SiteName: accountdomain,
|
||||
Description: truncate(description),
|
||||
Media: media,
|
||||
ArticlePublisher: status.Account.URL,
|
||||
ArticleAuthor: status.Account.URL,
|
||||
ArticlePublishedTime: status.CreatedAt,
|
||||
ArticleModifiedTime: util.PtrOrValue(status.EditedAt, status.CreatedAt),
|
||||
ProfileUsername: profileUsername,
|
||||
TwitterSummaryLargeImage: twitterSummaryLargeImage,
|
||||
TwitterImageAlt: twitterImageAlt,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountTitle parses a page title
|
||||
@@ -352,21 +402,36 @@ func AccountTitle(
|
||||
return displayName + " (" + nameString + ")"
|
||||
}
|
||||
|
||||
// ParseDescription returns a string description which is
|
||||
// safe to use as the content of a `content="..."` attribute.
|
||||
func ParseDescription(in string) string {
|
||||
i := text.StripHTMLFromText(in)
|
||||
i = strings.ReplaceAll(i, "\n", " ")
|
||||
i = strings.Join(strings.Fields(i), " ")
|
||||
i = html.EscapeString(i)
|
||||
i = strings.ReplaceAll(i, `\`, "\")
|
||||
return truncate(i)
|
||||
// Finds any links unnested
|
||||
// by text.ParseHTMLToPlain.
|
||||
var unnestedURLsRegexp = regexp.MustCompile(`(?U) <(?:http|https):\/\/.+\..+>`)
|
||||
|
||||
// toDescription converts given HTML string to
|
||||
// an appropriate string to use as "description"
|
||||
// content inside an opengraph <meta> tag.
|
||||
func toDescription(html string) string {
|
||||
// Parse html string to plaintext.
|
||||
plain := text.ParseHTMLToPlain(html)
|
||||
|
||||
// Remove any unnested URLs as they look ugly
|
||||
// when rendered inside an opengraph description.
|
||||
//
|
||||
// Eg., replace
|
||||
// `#boobs <https://example.org/tags/boobs>`
|
||||
// with just
|
||||
// `#boobs`.
|
||||
plain = unnestedURLsRegexp.ReplaceAllString(plain, "")
|
||||
|
||||
// Truncate to 2000 chars,
|
||||
// anything longer than
|
||||
// that is a bloody essay.
|
||||
return truncate(plain)
|
||||
}
|
||||
|
||||
// truncate trims string
|
||||
// to maximum 300 runes.
|
||||
// to maximum 2000 runes.
|
||||
func truncate(s string) string {
|
||||
const truncateLen = 300
|
||||
const truncateLen = 2000
|
||||
|
||||
r := []rune(s)
|
||||
if len(r) < truncateLen {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
|
||||
"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
|
||||
"code.superseriousbusiness.org/gotosocial/internal/typeutils"
|
||||
"code.superseriousbusiness.org/gotosocial/internal/util"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
@@ -31,40 +30,24 @@ type OpenGraphTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *OpenGraphTestSuite) TestParseDescription() {
|
||||
tests := []struct {
|
||||
name, in, exp string
|
||||
}{
|
||||
{name: "shellcmd", in: `echo '\e]8;;http://example.com\e\This is a link\e]8;;\e'`, exp: `echo '\e]8;;http://example.com\e\This is a link\e]8;;\e'`},
|
||||
{name: "newlines", in: "test\n\ntest\ntest", exp: "test test test"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
suite.Run(tt.name, func() {
|
||||
suite.Equal(tt.exp, ParseDescription(tt.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
|
||||
baseMeta := OGBase(&apimodel.InstanceV1{
|
||||
instance := &apimodel.InstanceV1{
|
||||
AccountDomain: "example.org",
|
||||
Languages: []string{"en"},
|
||||
Thumbnail: "https://example.org/instance-avatar.webp",
|
||||
ThumbnailType: "image/webp",
|
||||
})
|
||||
}
|
||||
|
||||
acct := &apimodel.Account{
|
||||
Acct: "example_account",
|
||||
DisplayName: "example person!!",
|
||||
URL: "https://example.org/@example_account",
|
||||
Note: "<p>This is my profile, read it and weep! Weep then!</p>",
|
||||
Note: "<p>This is my profile, read it and weep!<br/>Weep then!</p>",
|
||||
Username: "example_account",
|
||||
Avatar: "https://example.org/avatar.jpg",
|
||||
}
|
||||
|
||||
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
|
||||
accountMeta := OGAccount(instance, &apimodel.WebAccount{Account: acct})
|
||||
|
||||
suite.EqualValues(OGMeta{
|
||||
Title: "example person!! (@example_account@example.org)",
|
||||
@@ -72,7 +55,7 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
|
||||
Locale: "en",
|
||||
URL: "https://example.org/@example_account",
|
||||
SiteName: "example.org",
|
||||
Description: "This is my profile, read it and weep! Weep then!",
|
||||
Description: "This is my profile, read it and weep!\nWeep then!",
|
||||
Media: []OGMedia{
|
||||
{
|
||||
OGType: "image",
|
||||
@@ -89,12 +72,12 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
|
||||
}
|
||||
|
||||
func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
|
||||
baseMeta := OGBase(&apimodel.InstanceV1{
|
||||
instance := &apimodel.InstanceV1{
|
||||
AccountDomain: "example.org",
|
||||
Languages: []string{"en"},
|
||||
Thumbnail: "https://example.org/instance-avatar.webp",
|
||||
ThumbnailType: "image/webp",
|
||||
})
|
||||
}
|
||||
|
||||
acct := &apimodel.Account{
|
||||
Acct: "example_account",
|
||||
@@ -105,7 +88,7 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
|
||||
Avatar: "https://example.org/avatar.jpg",
|
||||
}
|
||||
|
||||
accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct})
|
||||
accountMeta := OGAccount(instance, &apimodel.WebAccount{Account: acct})
|
||||
|
||||
suite.EqualValues(OGMeta{
|
||||
Title: "example person!! (@example_account@example.org)",
|
||||
@@ -130,38 +113,29 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
|
||||
}
|
||||
|
||||
func (suite *OpenGraphTestSuite) TestWithStatus() {
|
||||
baseMeta := OGBase(&apimodel.InstanceV1{
|
||||
instance := &apimodel.InstanceV1{
|
||||
AccountDomain: "example.org",
|
||||
Languages: []string{"en"},
|
||||
Thumbnail: "https://example.org/instance-avatar.webp",
|
||||
ThumbnailType: "image/webp",
|
||||
})
|
||||
}
|
||||
|
||||
acct := &apimodel.Account{
|
||||
Acct: "example_account",
|
||||
DisplayName: "example person!!",
|
||||
URL: "https://example.org/@example_account",
|
||||
Note: "", // <- empty
|
||||
Username: "example_account",
|
||||
Avatar: "https://example.org/avatar.jpg",
|
||||
}
|
||||
|
||||
apiStatus := &apimodel.Status{
|
||||
ID: "12345",
|
||||
CreatedAt: "2025-01-18T00:00:00+00:00",
|
||||
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
|
||||
Sensitive: false,
|
||||
SpoilerText: "",
|
||||
Visibility: typeutils.VisToAPIVis(gtsmodel.VisibilityPublic),
|
||||
LocalOnly: false,
|
||||
Language: util.Ptr("en"),
|
||||
URI: "https://example.org/statuses/12345",
|
||||
URL: "https://example.org/@example_account/12345",
|
||||
Content: "<b>test status</b>",
|
||||
Account: acct,
|
||||
MediaAttachments: []*apimodel.Attachment{},
|
||||
Text: "**test status**",
|
||||
ContentType: apimodel.StatusContentTypeMarkdown,
|
||||
ID: "12345",
|
||||
CreatedAt: "2025-01-18T00:00:00+00:00",
|
||||
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
|
||||
URI: "https://example.org/statuses/12345",
|
||||
URL: "https://example.org/@example_account/12345",
|
||||
Content: "<p><b>test status</b><p><p>here's another line</p>",
|
||||
Account: acct,
|
||||
}
|
||||
|
||||
status := &apimodel.WebStatus{
|
||||
@@ -175,7 +149,7 @@ func (suite *OpenGraphTestSuite) TestWithStatus() {
|
||||
},
|
||||
}
|
||||
|
||||
statusMeta := baseMeta.WithStatus(status)
|
||||
statusMeta := OGStatus(instance, status.Account, status)
|
||||
|
||||
suite.EqualValues(OGMeta{
|
||||
Title: "example person!! (@example_account@example.org)",
|
||||
@@ -183,103 +157,78 @@ func (suite *OpenGraphTestSuite) TestWithStatus() {
|
||||
Locale: "en",
|
||||
URL: "https://example.org/@example_account/12345",
|
||||
SiteName: "example.org",
|
||||
Description: "**test status**",
|
||||
Media: []OGMedia{},
|
||||
Description: "test status\n\nhere's another line",
|
||||
ArticlePublisher: "https://example.org/@example_account",
|
||||
ArticleAuthor: "https://example.org/@example_account",
|
||||
ArticleModifiedTime: "2025-01-18T11:00:00+00:00",
|
||||
ArticlePublishedTime: "2025-01-18T00:00:00+00:00",
|
||||
ProfileUsername: "",
|
||||
ProfileUsername: "example_account@example.org",
|
||||
}, *statusMeta)
|
||||
}
|
||||
|
||||
func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
|
||||
baseMeta := OGBase(&apimodel.InstanceV1{
|
||||
instance := &apimodel.InstanceV1{
|
||||
AccountDomain: "example.org",
|
||||
Languages: []string{"en"},
|
||||
Thumbnail: "https://example.org/instance-avatar.webp",
|
||||
ThumbnailType: "image/webp",
|
||||
})
|
||||
}
|
||||
|
||||
acct := &apimodel.Account{
|
||||
Acct: "example_account",
|
||||
DisplayName: "example person!!",
|
||||
URL: "https://example.org/@example_account",
|
||||
Note: "", // <- empty
|
||||
Username: "example_account",
|
||||
Avatar: "https://example.org/avatar.jpg",
|
||||
}
|
||||
|
||||
imageAttachment := &apimodel.Attachment{
|
||||
ID: "00IMAGE00",
|
||||
Type: "image",
|
||||
URL: util.Ptr("https://example.org/@example_account/12345/example.png"),
|
||||
TextURL: util.Ptr("https://example.org/@example_account/12345/example.png"),
|
||||
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example.png"),
|
||||
RemoteURL: nil,
|
||||
PreviewRemoteURL: nil,
|
||||
ID: "00IMAGE00",
|
||||
Type: "image",
|
||||
URL: util.Ptr("https://example.org/@example_account/12345/example.png"),
|
||||
TextURL: util.Ptr("https://example.org/@example_account/12345/example.png"),
|
||||
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example.png"),
|
||||
Meta: &apimodel.MediaMeta{
|
||||
Original: apimodel.MediaDimensions{
|
||||
Width: 1920,
|
||||
Height: 1080,
|
||||
Size: "1920x1080",
|
||||
Aspect: 1920.0 / 1080,
|
||||
},
|
||||
Small: apimodel.MediaDimensions{
|
||||
Width: 320,
|
||||
Height: 240,
|
||||
Size: "320x240",
|
||||
Aspect: 320.0 / 240,
|
||||
},
|
||||
Focus: nil,
|
||||
},
|
||||
Description: util.Ptr("an example image"),
|
||||
Blurhash: util.Ptr("LKE3VIw}0KD%a2o{M|t7NFWps:t7"), // <- from testmodels
|
||||
}
|
||||
|
||||
anotherImageAttachment := &apimodel.Attachment{
|
||||
ID: "00IMAGE11",
|
||||
Type: "image",
|
||||
URL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
|
||||
TextURL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
|
||||
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example2.png"),
|
||||
RemoteURL: nil,
|
||||
PreviewRemoteURL: nil,
|
||||
ID: "00IMAGE11",
|
||||
Type: "image",
|
||||
URL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
|
||||
TextURL: util.Ptr("https://example.org/@example_account/12345/example2.png"),
|
||||
PreviewURL: util.Ptr("https://example.org/@example_account/12345/small/example2.png"),
|
||||
Meta: &apimodel.MediaMeta{
|
||||
Original: apimodel.MediaDimensions{
|
||||
Width: 1000,
|
||||
Height: 1000,
|
||||
Size: "1000x1000",
|
||||
Aspect: 1,
|
||||
},
|
||||
Small: apimodel.MediaDimensions{
|
||||
Width: 200,
|
||||
Height: 200,
|
||||
Size: "200x200",
|
||||
Aspect: 1,
|
||||
},
|
||||
Focus: nil,
|
||||
},
|
||||
Description: util.Ptr("another example image"),
|
||||
Blurhash: util.Ptr("LNABP8o#Dge,S6M}axxVEQjYxWbH"), // <- from testmodels
|
||||
}
|
||||
|
||||
apiStatus := &apimodel.Status{
|
||||
ID: "12345",
|
||||
CreatedAt: "2025-01-18T00:00:00+00:00",
|
||||
EditedAt: util.Ptr("2025-01-18T11:00:00+00:00"),
|
||||
Sensitive: false,
|
||||
SpoilerText: "",
|
||||
Visibility: typeutils.VisToAPIVis(gtsmodel.VisibilityPublic),
|
||||
LocalOnly: false,
|
||||
Language: util.Ptr("en"),
|
||||
URI: "https://example.org/statuses/12345",
|
||||
URL: "https://example.org/@example_account/12345",
|
||||
Content: "<b>test status</b>",
|
||||
Content: "<p>test status <span class=\"h-card\"><a href=\"https://example.org/c/mutual_aid\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>mutual_aid@example.org</span></a></span> <a href=\"https://example.org/tags/MutualAidRequest\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>MutualAidRequest</span></a></p>",
|
||||
Account: acct,
|
||||
MediaAttachments: []*apimodel.Attachment{imageAttachment, anotherImageAttachment},
|
||||
Text: "**test status**",
|
||||
ContentType: apimodel.StatusContentTypeMarkdown,
|
||||
}
|
||||
|
||||
webAttachment := &apimodel.WebAttachment{
|
||||
@@ -300,17 +249,11 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
|
||||
|
||||
status := &apimodel.WebStatus{
|
||||
Status: apiStatus,
|
||||
SpoilerContent: "", // <- empty
|
||||
MediaAttachments: []*apimodel.WebAttachment{webAttachment, anotherWebAttachment},
|
||||
Account: &apimodel.WebAccount{
|
||||
Account: acct,
|
||||
AvatarAttachment: nil,
|
||||
HeaderAttachment: nil,
|
||||
WebLayout: gtsmodel.WebLayoutMicroblog.String(),
|
||||
},
|
||||
Account: &apimodel.WebAccount{Account: acct},
|
||||
}
|
||||
|
||||
statusMeta := baseMeta.WithStatus(status)
|
||||
statusMeta := OGStatus(instance, status.Account, status)
|
||||
|
||||
suite.EqualValues(OGMeta{
|
||||
Title: "example person!! (@example_account@example.org)",
|
||||
@@ -318,7 +261,7 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
|
||||
Locale: "en",
|
||||
URL: "https://example.org/@example_account/12345",
|
||||
SiteName: "example.org",
|
||||
Description: "[2 media attachments] **test status**",
|
||||
Description: "[2 media attachments] test status @mutual_aid@example.org #MutualAidRequest",
|
||||
Media: []OGMedia{
|
||||
{
|
||||
OGType: "image",
|
||||
@@ -341,7 +284,7 @@ func (suite *OpenGraphTestSuite) TestWithStatusWithImage() {
|
||||
ArticleAuthor: "https://example.org/@example_account",
|
||||
ArticleModifiedTime: "2025-01-18T11:00:00+00:00",
|
||||
ArticlePublishedTime: "2025-01-18T00:00:00+00:00",
|
||||
ProfileUsername: "",
|
||||
ProfileUsername: "example_account@example.org",
|
||||
}, *statusMeta)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ var funcMap = template.FuncMap{
|
||||
"indentAttr": indentAttr,
|
||||
"isNil": isNil,
|
||||
"outdentPreformatted": outdentPreformatted,
|
||||
"outdentOGMeta": outdentOGMeta,
|
||||
"noescapeAttr": noescapeAttr,
|
||||
"noescape": noescape,
|
||||
"oddOrEven": oddOrEven,
|
||||
@@ -312,6 +313,23 @@ var (
|
||||
// Find content of alt or title attributes.
|
||||
indentAltOrTitle = regexp.MustCompile(`(?Ums)\b(?:alt|title)="(.*)"(?:\b|>|$)`)
|
||||
|
||||
// One indent level is four spaces,
|
||||
// so the start of an element inside
|
||||
// <head> will be indented 8 spaces, eg:
|
||||
//
|
||||
// <!DOCTYPE html>
|
||||
// <html lang="en">
|
||||
// <head>
|
||||
// <meta property="og:description"> <-- the thing we're looking for
|
||||
headContentIndent = strings.Repeat(indentStr, 2)
|
||||
// Find content of <meta> elements for
|
||||
// `description` and `og:description`.
|
||||
indentHeadOGDescription = regexp.MustCompile(
|
||||
`(?Ums)^` +
|
||||
headContentIndent +
|
||||
`<meta (?:property="og:description"|name="description") content="(.*)">$`,
|
||||
)
|
||||
|
||||
// Map of lazily-compiled replaceIndent
|
||||
// regexes, keyed by the indent they
|
||||
// replace, to avoid recompilation.
|
||||
@@ -340,6 +358,57 @@ func indentAttr(n int, html template.HTMLAttr) template.HTMLAttr {
|
||||
return noescapeAttr(out)
|
||||
}
|
||||
|
||||
// outdentOGMeta outdents all preformatted text
|
||||
// inside of "description" and "og:description"
|
||||
// <meta> elements in the given html fragment.
|
||||
func outdentOGMeta(html template.HTML) template.HTML {
|
||||
output := regexes.ReplaceAllStringFunc(indentHeadOGDescription, string(html),
|
||||
func(match string, buf *bytes.Buffer) string {
|
||||
// Reuse the regex to pull out submatches.
|
||||
matches := indentHeadOGDescription.FindAllStringSubmatch(match, -1)
|
||||
|
||||
// Ensure matches
|
||||
// expected length.
|
||||
if len(matches) != 1 {
|
||||
return match
|
||||
}
|
||||
|
||||
// Ensure inner matches
|
||||
// expected length.
|
||||
innerMatches := matches[0]
|
||||
if len(innerMatches) != 2 {
|
||||
return match
|
||||
}
|
||||
|
||||
// We know the length of indent
|
||||
// before elements inside head>
|
||||
// beforehand, it's two levels.
|
||||
indent := headContentIndent
|
||||
|
||||
// Load or create + store the
|
||||
// regex to replace this indent,
|
||||
// avoiding recompilation.
|
||||
var replaceIndent *regexp.Regexp
|
||||
if replaceIndentI, ok := replaceIndents.Load(indent); ok {
|
||||
// Got regex for this indent.
|
||||
replaceIndent = replaceIndentI.(*regexp.Regexp)
|
||||
} else {
|
||||
// No regex stored for
|
||||
// this indent yet, store it.
|
||||
replaceIndent = regexp.MustCompile(`(?m)^` + indent)
|
||||
replaceIndents.Store(indent, replaceIndent)
|
||||
}
|
||||
|
||||
// Keep the initial indent before the element,
|
||||
// but remove all occurrences of the indent
|
||||
// at the start of each line inside the match.
|
||||
return indent + replaceIndent.ReplaceAllString(match, "")
|
||||
},
|
||||
)
|
||||
|
||||
return noescape(output)
|
||||
}
|
||||
|
||||
// outdentPreformatted outdents all preformatted text in
|
||||
// the given HTML, ie., in `alt` and `title` attributes,
|
||||
// and between `<pre>` tags, so that it renders correctly,
|
||||
|
||||
@@ -236,3 +236,34 @@ With her hands on her hips looking annoyed she says "That sign won't sto
|
||||
t.Fatalf("unexpected output:\n`%s`\n", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutdentOGMeta(t *testing.T) {
|
||||
const html = template.HTML(`<html lang="en">
|
||||
<head>
|
||||
<meta property="og:description" content="here is
|
||||
a
|
||||
|
||||
multiline toot
|
||||
with some
|
||||
significant whitespace!
|
||||
|
||||
<3 <3 <3">
|
||||
</head>`)
|
||||
|
||||
const expected = template.HTML(`<html lang="en">
|
||||
<head>
|
||||
<meta property="og:description" content="here is
|
||||
a
|
||||
|
||||
multiline toot
|
||||
with some
|
||||
significant whitespace!
|
||||
|
||||
<3 <3 <3">
|
||||
</head>`)
|
||||
|
||||
out := outdentOGMeta(html)
|
||||
if out != expected {
|
||||
t.Fatalf("unexpected output:\n`%s`\n", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (m *Module) profileMicroblog(c *gin.Context, p *profile) {
|
||||
page := apiutil.WebPage{
|
||||
Template: "profile.tmpl",
|
||||
Instance: p.instance,
|
||||
OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account),
|
||||
OGMeta: apiutil.OGAccount(p.instance, p.account),
|
||||
Stylesheets: stylesheets,
|
||||
Javascript: []apiutil.JavascriptEntry{
|
||||
{
|
||||
@@ -317,7 +317,7 @@ func (m *Module) profileGallery(c *gin.Context, p *profile) {
|
||||
page := apiutil.WebPage{
|
||||
Template: "profile-gallery.tmpl",
|
||||
Instance: p.instance,
|
||||
OGMeta: apiutil.OGBase(p.instance).WithAccount(p.account),
|
||||
OGMeta: apiutil.OGAccount(p.instance, p.account),
|
||||
Stylesheets: stylesheets,
|
||||
Javascript: []apiutil.JavascriptEntry{
|
||||
{
|
||||
|
||||
@@ -143,7 +143,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
|
||||
page := apiutil.WebPage{
|
||||
Template: "thread.tmpl",
|
||||
Instance: instance,
|
||||
OGMeta: apiutil.OGBase(instance).WithStatus(context.Status),
|
||||
OGMeta: apiutil.OGStatus(instance, acct, context.Status),
|
||||
Stylesheets: stylesheets,
|
||||
Javascript: []apiutil.JavascriptEntry{
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ image/webp
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="{{- if .robotsMeta -}}{{- .robotsMeta -}}{{- else -}}noindex, nofollow, noai, noimageai{{- end -}}">
|
||||
{{- if .ogMeta }}
|
||||
{{- include "page_ogmeta.tmpl" . | indent 2 }}
|
||||
{{- include "page_ogmeta.tmpl" . | indent 2 | outdentOGMeta }}
|
||||
{{- else }}
|
||||
{{- end }}
|
||||
{{- if .rssFeed }}
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
<meta property="og:title" content="{{- demojify .Title | noescape -}}">
|
||||
<meta property="og:url" content="{{- .URL -}}">
|
||||
<meta property="og:site_name" content="{{- .SiteName -}}">
|
||||
<meta property="og:description" content="{{- demojify .Description | noescape -}}">
|
||||
<meta property="og:description" content="{{- .Description -}}">
|
||||
<meta name="description" content="{{- .Description -}}">
|
||||
{{- if .ArticlePublisher }}
|
||||
<meta property="og:article:publisher" content="{{ .ArticlePublisher }}">
|
||||
<meta property="og:article:author" content="{{ .ArticleAuthor }}">
|
||||
<meta property="og:modified_time" content="{{ .ArticleModifiedTime }}">
|
||||
<meta property="og:article:modified_time" content="{{ .ArticleModifiedTime }}">
|
||||
<meta property="og:published_time" content="{{ .ArticlePublishedTime }}">
|
||||
<meta property="og:article:published_time" content="{{ .ArticlePublishedTime }}">
|
||||
{{- else }}
|
||||
{{- end }}
|
||||
|
||||
Reference in New Issue
Block a user