gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit d09ddb4769b503cd59edfc97353af1fd16e4c2ad
parent a872ddebe67c7b76cbb78667224b393a847834ac
Author: f0x52 <f0x@cthu.lu>
Date:   Wed,  7 Sep 2022 16:53:12 +0200

[feature] opengraph meta tags (#806)

* f0x gitignore additions

* better meta title and descriptions

* user avatar icon for thread and profile meta tags

* use proper tag for image

* whitespace

* add noescapeAttr template function

* use ogMeta struct for opengraph

* maxOGDescriptionLength = 300

Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
Diffstat:
M.gitignore | 8+++++---
Minternal/router/template.go | 6++++++
Minternal/web/base.go | 1+
Ainternal/web/opengraph.go | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/profile.go | 1+
Minternal/web/thread.go | 1+
Mweb/template/header.tmpl | 20+++++++++++++++-----
7 files changed, 180 insertions(+), 8 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -25,5 +25,7 @@ example/docker-compose/docker-volume # excludes debug build cmd/gotosocial/__debug_bin -# ignore f0x' nix-shell -shell.nix -\ No newline at end of file +# ignore f0x' nix-shell, direnv +shell.nix +.direnv +.envrc +\ No newline at end of file diff --git a/internal/router/template.go b/internal/router/template.go @@ -70,6 +70,11 @@ func noescape(str string) template.HTML { return template.HTML(str) } +func noescapeAttr(str string) template.HTMLAttr { + /* #nosec G203 */ + return template.HTMLAttr(str) +} + func timestamp(stamp string) string { t, _ := time.Parse(time.RFC3339, stamp) return t.Format("January 2, 2006, 15:04:05") @@ -151,6 +156,7 @@ func LoadTemplateFunctions(engine *gin.Engine) { engine.SetFuncMap(template.FuncMap{ "escape": escape, "noescape": noescape, + "noescapeAttr": noescapeAttr, "oddOrEven": oddOrEven, "visibilityIcon": visibilityIcon, "timestamp": timestamp, diff --git a/internal/web/base.go b/internal/web/base.go @@ -37,5 +37,6 @@ func (m *Module) baseHandler(c *gin.Context) { c.HTML(http.StatusOK, "index.tmpl", gin.H{ "instance": instance, + "ogMeta": ogBase(instance), }) } diff --git a/internal/web/opengraph.go b/internal/web/opengraph.go @@ -0,0 +1,151 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "html" + "strconv" + "strings" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/text" +) + +const maxOGDescriptionLength = 300 + +// ogMeta represents supported OpenGraph Meta tags +// +// see eg https://developer.yoast.com/features/opengraph/functional-specification/ +type ogMeta struct { + // vanilla og tags + + Locale string // og:locale + ResourceType string // og:type + Title string // og:title + URL string // og:url + SiteName string // og:site_name + Description string // og:description + Image string // og:image + ImageWidth string // og:image:width + ImageHeight string // og:image:height + + // article tags + + ArticlePublisher string // article:publisher + ArticleAuthor string // article:author + ArticleModifiedTime string // article:modified_time + ArticlePublishedTime string // article:published_time +} + +// 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. +func ogBase(instance *apimodel.Instance) *ogMeta { + var locale string + if len(instance.Languages) > 0 { + locale = instance.Languages[0] + } + + og := &ogMeta{ + Locale: locale, + ResourceType: "website", + Title: text.SanitizePlaintext(instance.Title) + " - GoToSocial", + URL: instance.URI, + SiteName: instance.AccountDomain, + Description: parseDescription(instance.ShortDescription), + Image: instance.Thumbnail, + } + + return og +} + +// 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.Account) *ogMeta { + og.ResourceType = "profile" + og.Title = parseTitle(account, og.SiteName) + og.URL = account.URL + og.Description = parseDescription(account.Note) + og.Image = account.Avatar + return og +} + +// 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.Status) *ogMeta { + if !status.Sensitive && len(status.MediaAttachments) > 0 { + a := status.MediaAttachments[0] + og.Image = a.PreviewURL + og.ImageWidth = strconv.Itoa(a.Meta.Small.Width) + og.ImageHeight = strconv.Itoa(a.Meta.Small.Height) + } else { + og.Image = status.Account.Avatar + } + + if status.SpoilerText != "" { + og.Description = parseDescription("CW: " + status.SpoilerText) + } else { + og.Description = parseDescription(status.Text) + } + + og.Locale = status.Language + og.ResourceType = "article" + og.Title = "Post by " + parseTitle(status.Account, og.SiteName) + og.URL = status.URL + og.ArticlePublisher = status.Account.URL + og.ArticleAuthor = status.Account.URL + og.ArticlePublishedTime = status.CreatedAt + og.ArticleModifiedTime = status.CreatedAt + return og +} + +// parseTitle parses a page title from account and accountDomain +func parseTitle(account *apimodel.Account, accountDomain string) string { + user := "@" + account.Acct + "@" + accountDomain + + if len(account.DisplayName) == 0 { + return user + } + + return account.DisplayName + " (" + user + ")" +} + +// parseDescription returns a string description which is +// safe to use as a template.HTMLAttr inside templates. +func parseDescription(in string) string { + i := html.UnescapeString(in) + i = text.SanitizePlaintext(i) + i = strings.ReplaceAll(i, "\"", "'") + i = strings.ReplaceAll(i, `\`, "") + i = strings.ReplaceAll(i, "\n", " ") + i = trim(i, maxOGDescriptionLength) + return `content="` + i + `"` +} + +// trim strings trim s to specified length +func trim(s string, length int) string { + if len(s) < length { + return s + } + + return s[:length] +} diff --git a/internal/web/profile.go b/internal/web/profile.go @@ -102,6 +102,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { c.HTML(http.StatusOK, "profile.tmpl", gin.H{ "instance": instance, "account": account, + "ogMeta": ogBase(instance).withAccount(account), "statuses": statusResp.Items, "statuses_next": statusResp.NextLink, "show_back_to_top": showBackToTop, diff --git a/internal/web/thread.go b/internal/web/thread.go @@ -108,6 +108,7 @@ func (m *Module) threadGETHandler(c *gin.Context) { "instance": instance, "status": status, "context": context, + "ogMeta": ogBase(instance).withStatus(status), "stylesheets": []string{ "/assets/Fork-Awesome/css/fork-awesome.min.css", "/assets/dist/status.css", diff --git a/web/template/header.tmpl b/web/template/header.tmpl @@ -6,14 +6,24 @@ <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta name="og:title" content="{{.instance.Title}} - GoToSocial"> - <meta name="og:description" content="{{.instance.ShortDescription}}"> + {{ if .ogMeta }}{{ if .ogMeta.Locale }}<meta name="og:locale" content="{{ .ogMeta.Locale }}"> + {{ end }}<meta name="og:type" content="{{ .ogMeta.ResourceType }}"> + <meta name="og:title" content="{{ .ogMeta.Title }}"> + <meta name="og:url" content="{{ .ogMeta.URL }}"> + <meta name="og:site_name" content="{{ .ogMeta.SiteName }}"> + <meta name="og:description" {{ .ogMeta.Description | noescapeAttr }}> + {{ if .ogMeta.ArticlePublisher }}<meta name="og:article:publisher" content="{{ .ogMeta.ArticlePublisher }}"> + <meta name="og:article:author" content="{{ .ogMeta.ArticleAuthor }}"> + <meta name="og:article:modified_time" content="{{ .ogMeta.ArticleModifiedTime }}"> + <meta name="og:article:published_time" content="{{ .ogMeta.ArticlePublishedTime }}"> + {{ end }}<meta name="og:image" content="{{ .ogMeta.Image }}"> + {{ if .ogMeta.ImageWidth }}<meta name="og:image:width" content="{{ .ogMeta.ImageWidth }}"> + <meta name="og:image:height" content="{{ .ogMeta.ImageHeight }}"> + {{ end }}{{ end }}<link rel="shortcut icon" href="/assets/logo.png" type="image/png"> <link rel="stylesheet" href="/assets/dist/_colors.css"> <link rel="stylesheet" href="/assets/dist/base.css"> {{range .stylesheets}}<link rel="stylesheet" href="{{.}}"> - {{end}} - <link rel="shortcut icon" href="/assets/logo.png" type="image/png"> - <title>{{.instance.Title}} - GoToSocial</title> + {{end}}<title>{{ if .ogMeta }}{{ .ogMeta.Title }}{{ else }}{{.instance.Title}} - GoToSocial{{ end }}</title> </head> <body> <div class="page">