diff --git a/internal/api/util/opengraph.go b/internal/api/util/opengraph.go index 321b0f92d..5e1bdf3d2 100644 --- a/internal/api/util/opengraph.go +++ b/internal/api/util/opengraph.go @@ -19,20 +19,21 @@ package util import ( "html" + "slices" "strconv" "strings" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" "code.superseriousbusiness.org/gotosocial/internal/text" + "code.superseriousbusiness.org/gotosocial/internal/util" ) -const maxOGDescriptionLength = 300 - // OGMeta represents supported OpenGraph Meta tags // // see eg https://ogp.me/ type OGMeta struct { - // vanilla og tags + /* Vanilla og tags */ + Title string // og:title Type string // og:type Locale string // og:locale @@ -40,26 +41,57 @@ type OGMeta struct { SiteName string // og:site_name Description string // og:description - // image tags - Image string // og:image - ImageWidth string // og:image:width - ImageHeight string // og:image:height - ImageAlt string // og:image:alt + // Zero or more media entries of type image, + // video, or audio (https://ogp.me/#array). + Media []OGMedia + + /* Article tags. */ - // article tags ArticlePublisher string // article:publisher ArticleAuthor string // article:author ArticleModifiedTime string // article:modified_time ArticlePublishedTime string // article:published_time - // profile tags + /* Profile tags. */ + ProfileUsername string // profile:username + + /* + Twitter card stuff + https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards + */ + + // Set to media URL for media posts. + TwitterSummaryLargeImage string + TwitterImageAlt string +} + +func (o *OGMeta) prependMedia(i ...OGMedia) { + if len(o.Media) == 0 { + // Set as + // only entries. + o.Media = i + } else { + // Prepend as higher + // priority entries. + o.Media = slices.Insert(o.Media, 0, i...) + } +} + +// OGMedia represents one OpenGraph media +// entry of type image, video, or audio. +type OGMedia struct { + OGType string // image/video/audio + URL string // og:${type} + MIMEType string // og:${type}:type + Width string // og:${type}:width + Height string // og:${type}:height + Alt string // og:${type}:alt } // OGBase returns an *ogMeta suitable for serving at // the base root of an instance. It also serves as a -// foundation for building account / status ogMeta on -// top of. +// foundation for building account / status ogMeta. func OGBase(instance *apimodel.InstanceV1) *OGMeta { var locale string if len(instance.Languages) > 0 { @@ -73,9 +105,14 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta { URL: instance.URI, SiteName: instance.AccountDomain, Description: ParseDescription(instance.ShortDescription), - - Image: instance.Thumbnail, - ImageAlt: instance.ThumbnailDescription, + Media: []OGMedia{ + { + OGType: "image", + URL: instance.Thumbnail, + Alt: instance.ThumbnailDescription, + MIMEType: instance.ThumbnailType, + }, + }, } return og @@ -84,67 +121,154 @@ func OGBase(instance *apimodel.InstanceV1) *OGMeta { // WithAccount uses the given account to build an ogMeta // struct specific to that account. It's suitable for serving // at account profile pages. -func (og *OGMeta) WithAccount(account *apimodel.WebAccount) *OGMeta { - og.Title = AccountTitle(account, og.SiteName) - og.Type = "profile" - og.URL = account.URL - if account.Note != "" { - og.Description = ParseDescription(account.Note) +func (o *OGMeta) WithAccount(acct *apimodel.WebAccount) *OGMeta { + o.Title = AccountTitle(acct, o.SiteName) + o.ProfileUsername = acct.Username + o.Type = "profile" + o.URL = acct.URL + if acct.Note != "" { + o.Description = ParseDescription(acct.Note) } else { - og.Description = `content="This GoToSocial user hasn't written a bio yet!"` + const desc = "This GoToSocial user hasn't written a bio yet!" + o.Description = desc } - og.Image = account.Avatar - og.ImageAlt = "Avatar for " + account.Username + // Add avatar image. + o.prependMedia(ogImgForAcct(acct)) - og.ProfileUsername = account.Username + return o +} - return og +// util funct to return OGImage using account. +func ogImgForAcct(account *apimodel.WebAccount) OGMedia { + ogMedia := OGMedia{ + OGType: "image", + URL: account.Avatar, + Alt: "Avatar for " + account.Username, + } + + if desc := account.AvatarDescription; desc != "" { + ogMedia.Alt += ": " + desc + } + + // Add extra info if not default avi. + if a := account.AvatarAttachment; a != nil { + ogMedia.MIMEType = a.MIMEType + ogMedia.Width = strconv.Itoa(a.Meta.Original.Width) + ogMedia.Height = strconv.Itoa(a.Meta.Original.Height) + } + + return ogMedia } // WithStatus uses the given status to build an ogMeta // struct specific to that status. It's suitable for serving // at status pages. -func (og *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta { - og.Title = "Post by " + AccountTitle(status.Account, og.SiteName) - og.Type = "article" +func (o *OGMeta) WithStatus(status *apimodel.WebStatus) *OGMeta { + o.Title = "Post by " + AccountTitle(status.Account, o.SiteName) + o.Type = "article" if status.Language != nil { - og.Locale = *status.Language + o.Locale = *status.Language } - og.URL = status.URL + o.URL = status.URL switch { case status.SpoilerText != "": - og.Description = ParseDescription("CW: " + status.SpoilerText) + o.Description = ParseDescription("CW: " + status.SpoilerText) case status.Text != "": - og.Description = ParseDescription(status.Text) + o.Description = ParseDescription(status.Text) default: - og.Description = og.Title + o.Description = o.Title } - if !status.Sensitive && len(status.MediaAttachments) > 0 { - a := status.MediaAttachments[0] + // Prepend account image. + o.prependMedia(ogImgForAcct(status.Account)) - og.ImageWidth = strconv.Itoa(a.Meta.Small.Width) - og.ImageHeight = strconv.Itoa(a.Meta.Small.Height) + if l := len(status.MediaAttachments); l != 0 && !status.Sensitive { - if a.PreviewURL != nil { - og.Image = *a.PreviewURL + // Take first not "unknown" + // attachment as the "main" one. + for _, a := range status.MediaAttachments { + if a.Type == "unknown" { + // Skip unknown. + continue + } + + // Start with + // common media tags. + desc := util.PtrOrZero(a.Description) + ogMedia := OGMedia{ + URL: *a.URL, + MIMEType: a.MIMEType, + Alt: desc, + } + + // Gather ogMedias for + // this attachment. + ogMedias := []OGMedia{} + + // 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 l == 1 { + o.TwitterSummaryLargeImage = *a.URL + o.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. + ogMedias = append(ogMedias, ogMedia) + + if a.Type != "image" { + // Add static/thumbnail + // for non-images. + ogMedias = append( + ogMedias, + 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), + }, + ) + } + + // Prepend gathered entries. + // + // This will cause the full-size + // entry to appear before its + // thumbnail entry (if set). + o.prependMedia(ogMedias...) + + // Done! + break } - - if a.Description != nil { - og.ImageAlt = *a.Description - } - } else { - og.Image = status.Account.Avatar - og.ImageAlt = "Avatar for " + status.Account.Username } - og.ArticlePublisher = status.Account.URL - og.ArticleAuthor = status.Account.URL - og.ArticlePublishedTime = status.CreatedAt - og.ArticleModifiedTime = status.CreatedAt + o.ArticlePublisher = status.Account.URL + o.ArticleAuthor = status.Account.URL + o.ArticlePublishedTime = status.CreatedAt + o.ArticleModifiedTime = util.PtrOrValue(status.EditedAt, status.CreatedAt) - return og + return o } // AccountTitle parses a page title from account and accountDomain @@ -159,26 +283,27 @@ func AccountTitle(account *apimodel.WebAccount, accountDomain string) string { } // ParseDescription returns a string description which is -// safe to use as a template.HTMLAttr inside templates. +// 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, `\`, "\") - i = truncate(i, maxOGDescriptionLength) - return `content="` + i + `"` + return truncate(i) } -// truncate trims given string to -// specified length (in runes). -func truncate(s string, l int) string { +// truncate trims string +// to maximum 160 runes. +func truncate(s string) string { + const truncateLen = 160 + r := []rune(s) - if len(r) < l { + if len(r) < truncateLen { // No need // to trim. return s } - return string(r[:l]) + "..." + return string(r[:truncateLen-3]) + "…" } diff --git a/internal/api/util/opengraph_test.go b/internal/api/util/opengraph_test.go index 821aabaff..dc463c041 100644 --- a/internal/api/util/opengraph_test.go +++ b/internal/api/util/opengraph_test.go @@ -18,7 +18,6 @@ package util import ( - "fmt" "testing" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" @@ -40,7 +39,7 @@ func (suite *OpenGraphTestSuite) TestParseDescription() { for _, tt := range tests { tt := tt suite.Run(tt.name, func() { - suite.Equal(fmt.Sprintf("content=\"%s\"", tt.exp), ParseDescription(tt.in)) + suite.Equal(tt.exp, ParseDescription(tt.in)) }) } } @@ -49,6 +48,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() { baseMeta := OGBase(&apimodel.InstanceV1{ AccountDomain: "example.org", Languages: []string{"en"}, + Thumbnail: "https://example.org/instance-avatar.webp", + ThumbnailType: "image/webp", }) acct := &apimodel.Account{ @@ -57,21 +58,31 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() { URL: "https://example.org/@example_account", Note: "
This is my profile, read it and weep! Weep then!
", Username: "example_account", + Avatar: "https://example.org/avatar.jpg", } accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct}) suite.EqualValues(OGMeta{ - Title: "example person!!, @example_account@example.org", - Type: "profile", - Locale: "en", - URL: "https://example.org/@example_account", - SiteName: "example.org", - Description: "content=\"This is my profile, read it and weep! Weep then!\"", - Image: "", - ImageWidth: "", - ImageHeight: "", - ImageAlt: "Avatar for example_account", + Title: "example person!!, @example_account@example.org", + Type: "profile", + Locale: "en", + URL: "https://example.org/@example_account", + SiteName: "example.org", + Description: "This is my profile, read it and weep! Weep then!", + Media: []OGMedia{ + { + OGType: "image", + Alt: "Avatar for example_account", + URL: "https://example.org/avatar.jpg", + }, + { + // Instance avatar. + OGType: "image", + URL: "https://example.org/instance-avatar.webp", + MIMEType: "image/webp", + }, + }, ArticlePublisher: "", ArticleAuthor: "", ArticleModifiedTime: "", @@ -84,6 +95,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() { baseMeta := OGBase(&apimodel.InstanceV1{ AccountDomain: "example.org", Languages: []string{"en"}, + Thumbnail: "https://example.org/instance-avatar.webp", + ThumbnailType: "image/webp", }) acct := &apimodel.Account{ @@ -92,21 +105,31 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() { URL: "https://example.org/@example_account", Note: "", // <- empty Username: "example_account", + Avatar: "https://example.org/avatar.jpg", } accountMeta := baseMeta.WithAccount(&apimodel.WebAccount{Account: acct}) suite.EqualValues(OGMeta{ - Title: "example person!!, @example_account@example.org", - Type: "profile", - Locale: "en", - URL: "https://example.org/@example_account", - SiteName: "example.org", - Description: "content=\"This GoToSocial user hasn't written a bio yet!\"", - Image: "", - ImageWidth: "", - ImageHeight: "", - ImageAlt: "Avatar for example_account", + Title: "example person!!, @example_account@example.org", + Type: "profile", + Locale: "en", + URL: "https://example.org/@example_account", + SiteName: "example.org", + Description: "This GoToSocial user hasn't written a bio yet!", + Media: []OGMedia{ + { + OGType: "image", + Alt: "Avatar for example_account", + URL: "https://example.org/avatar.jpg", + }, + { + // Instance avatar. + OGType: "image", + URL: "https://example.org/instance-avatar.webp", + MIMEType: "image/webp", + }, + }, ArticlePublisher: "", ArticleAuthor: "", ArticleModifiedTime: "", diff --git a/web/template/page_ogmeta.tmpl b/web/template/page_ogmeta.tmpl index 82bb4bbfb..8be10280d 100644 --- a/web/template/page_ogmeta.tmpl +++ b/web/template/page_ogmeta.tmpl @@ -25,14 +25,14 @@ {{- with .ogMeta }} {{- if .Locale }} - + {{- else }} {{- end }} - + {{- if .ArticlePublisher }} @@ -44,14 +44,34 @@ {{- else }} {{- end }} - -{{- if .ImageAlt }} - +{{- range $i, $m := .Media }} + +{{- if or (eq $m.OGType "video") (eq $m.OGType "audio") }} + {{- else }} {{- end }} -{{- if .ImageWidth }} - - +{{- if $m.MIMEType }} + {{- else }} {{- end }} +{{- if $m.Width }} + + +{{- else }} +{{- end }} +{{- if $m.Alt }} + +{{- else }} +{{- end }} +{{- end }} +{{- if .TwitterSummaryLargeImage }} + + +{{- if .TwitterImageAlt }} + +{{- else }} +{{- end }} +{{- else }} + +{{- end }} {{- end }} \ No newline at end of file