diff options
Diffstat (limited to 'internal/api')
-rw-r--r-- | internal/api/auth/authorize.go | 30 | ||||
-rw-r--r-- | internal/api/auth/callback.go | 34 | ||||
-rw-r--r-- | internal/api/auth/oob.go | 18 | ||||
-rw-r--r-- | internal/api/auth/signin.go | 18 | ||||
-rw-r--r-- | internal/api/model/status.go | 6 | ||||
-rw-r--r-- | internal/api/util/errorhandling.go | 20 | ||||
-rw-r--r-- | internal/api/util/opengraph.go | 184 | ||||
-rw-r--r-- | internal/api/util/opengraph_test.go | 116 | ||||
-rw-r--r-- | internal/api/util/template.go | 135 |
9 files changed, 514 insertions, 47 deletions
diff --git a/internal/api/auth/authorize.go b/internal/api/auth/authorize.go index 4977ae4f2..e4694de57 100644 --- a/internal/api/auth/authorize.go +++ b/internal/api/auth/authorize.go @@ -144,17 +144,25 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { return } - // the authorize template will display a form to the user where they can get some information - // about the app that's trying to authorize, and the scope of the request. - // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler - c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ - "appname": app.Name, - "appwebsite": app.Website, - "redirect": redirect, - "scope": scope, - "user": acct.Username, - "instance": instance, - }) + // The authorize template will display a form + // to the user where they can see some info + // about the app that's trying to authorize, + // and the scope of the request. They can then + // approve it if it looks OK to them, which + // will POST to the AuthorizePOSTHandler. + page := apiutil.WebPage{ + Template: "authorize.tmpl", + Instance: instance, + Extra: map[string]any{ + "appname": app.Name, + "appwebsite": app.Website, + "redirect": redirect, + "scope": scope, + "user": acct.Username, + }, + } + + apiutil.TemplateWebPage(c, page) } // AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize diff --git a/internal/api/auth/callback.go b/internal/api/auth/callback.go index 97b3ae279..d0fa78322 100644 --- a/internal/api/auth/callback.go +++ b/internal/api/auth/callback.go @@ -143,11 +143,17 @@ func (m *Module) CallbackGETHandler(c *gin.Context) { apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) return } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": claims.Name, - "preferredUsername": claims.PreferredUsername, - }) + + page := apiutil.WebPage{ + Template: "finalize.tmpl", + Instance: instance, + Extra: map[string]any{ + "name": claims.Name, + "preferredUsername": claims.PreferredUsername, + }, + } + + apiutil.TemplateWebPage(c, page) return } s.Set(sessionUserID, user.ID) @@ -177,12 +183,18 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - c.HTML(http.StatusOK, "finalize.tmpl", gin.H{ - "instance": instance, - "name": form.Name, - "preferredUsername": form.Username, - "error": err, - }) + + page := apiutil.WebPage{ + Template: "finalize.tmpl", + Instance: instance, + Extra: map[string]any{ + "name": form.Name, + "preferredUsername": form.Username, + "error": err, + }, + } + + apiutil.TemplateWebPage(c, page) } // check if the username conforms to the spec diff --git a/internal/api/auth/oob.go b/internal/api/auth/oob.go index 5953524ab..8c7b1f2a5 100644 --- a/internal/api/auth/oob.go +++ b/internal/api/auth/oob.go @@ -21,7 +21,6 @@ import ( "context" "errors" "fmt" - "net/http" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -101,10 +100,15 @@ func (m *Module) OobHandler(c *gin.Context) { // we're done with the session now, so just clear it out m.clearSession(s) - c.HTML(http.StatusOK, "oob.tmpl", gin.H{ - "instance": instance, - "user": acct.Username, - "oobToken": oobToken, - "scope": scope, - }) + page := apiutil.WebPage{ + Template: "oob.tmpl", + Instance: instance, + Extra: map[string]any{ + "user": acct.Username, + "oobToken": oobToken, + "scope": scope, + }, + } + + apiutil.TemplateWebPage(c, page) } diff --git a/internal/api/auth/signin.go b/internal/api/auth/signin.go index a6b503a83..a8713d05f 100644 --- a/internal/api/auth/signin.go +++ b/internal/api/auth/signin.go @@ -32,8 +32,8 @@ import ( "golang.org/x/crypto/bcrypt" ) -// login just wraps a form-submitted username (we want an email) and password -type login struct { +// signIn just wraps a form-submitted username (we want an email) and password +type signIn struct { Email string `form:"username"` Password string `form:"password"` } @@ -55,10 +55,12 @@ func (m *Module) SignInGETHandler(c *gin.Context) { return } - // no idp provider, use our own funky little sign in page - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{ - "instance": instance, - }) + page := apiutil.WebPage{ + Template: "sign-in.tmpl", + Instance: instance, + } + + apiutil.TemplateWebPage(c, page) return } @@ -83,7 +85,7 @@ func (m *Module) SignInGETHandler(c *gin.Context) { func (m *Module) SignInPOSTHandler(c *gin.Context) { s := sessions.Default(c) - form := &login{} + form := &signIn{} if err := c.ShouldBind(form); err != nil { m.clearSession(s) apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1) @@ -129,7 +131,7 @@ func (m *Module) ValidatePassword(ctx context.Context, email string, password st } if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { - err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) + err := fmt.Errorf("password hash didn't match for user %s during sign in attempt: %s", user.Email, err) return incorrectPassword(err) } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 128cd65bb..8ca41c767 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -116,6 +116,12 @@ type Status struct { // // swagger:ignore WebPollOptions []WebPollOption `json:"-"` + + // Status is from a local account. + // Always false for non-web statuses. + // + // swagger:ignore + Local bool `json:"-"` } /* diff --git a/internal/api/util/errorhandling.go b/internal/api/util/errorhandling.go index 8bb251040..848beff5b 100644 --- a/internal/api/util/errorhandling.go +++ b/internal/api/util/errorhandling.go @@ -50,10 +50,10 @@ func NotFoundHandler(c *gin.Context, instanceGet func(ctx context.Context) (*api panic(err) } - c.HTML(http.StatusNotFound, "404.tmpl", gin.H{ - "instance": instance, - "requestID": gtscontext.RequestID(ctx), - }) + template404Page(c, + instance, + gtscontext.RequestID(ctx), + ) default: JSON(c, http.StatusNotFound, map[string]string{ "error": errWithCode.Safe(), @@ -73,12 +73,12 @@ func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context) ( panic(err) } - c.HTML(errWithCode.Code(), "error.tmpl", gin.H{ - "instance": instance, - "code": errWithCode.Code(), - "error": errWithCode.Safe(), - "requestID": gtscontext.RequestID(ctx), - }) + templateErrorPage(c, + instance, + errWithCode.Code(), + errWithCode.Safe(), + gtscontext.RequestID(ctx), + ) default: JSON(c, errWithCode.Code(), map[string]string{ "error": errWithCode.Safe(), diff --git a/internal/api/util/opengraph.go b/internal/api/util/opengraph.go new file mode 100644 index 000000000..185dc8132 --- /dev/null +++ b/internal/api/util/opengraph.go @@ -0,0 +1,184 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 util + +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://ogp.me/ +type OGMeta struct { + // vanilla og tags + Title string // og:title + Type string // og:type + Locale string // og:locale + URL string // og:url + 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 + + // article tags + ArticlePublisher string // article:publisher + ArticleAuthor string // article:author + ArticleModifiedTime string // article:modified_time + ArticlePublishedTime string // article:published_time + + // profile tags + ProfileUsername string // profile:username +} + +// 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.InstanceV1) *OGMeta { + var locale string + if len(instance.Languages) > 0 { + locale = instance.Languages[0] + } + + og := &OGMeta{ + Title: text.SanitizeToPlaintext(instance.Title) + " - GoToSocial", + Type: "website", + Locale: locale, + URL: instance.URI, + SiteName: instance.AccountDomain, + Description: ParseDescription(instance.ShortDescription), + + Image: instance.Thumbnail, + ImageAlt: instance.ThumbnailDescription, + } + + 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.Title = AccountTitle(account, og.SiteName) + og.Type = "profile" + og.URL = account.URL + if account.Note != "" { + og.Description = ParseDescription(account.Note) + } else { + og.Description = `content="This GoToSocial user hasn't written a bio yet!"` + } + + og.Image = account.Avatar + og.ImageAlt = "Avatar for " + account.Username + + og.ProfileUsername = account.Username + + 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 { + og.Title = "Post by " + AccountTitle(status.Account, og.SiteName) + og.Type = "article" + if status.Language != nil { + og.Locale = *status.Language + } + og.URL = status.URL + switch { + case status.SpoilerText != "": + og.Description = ParseDescription("CW: " + status.SpoilerText) + case status.Text != "": + og.Description = ParseDescription(status.Text) + default: + og.Description = og.Title + } + + if !status.Sensitive && len(status.MediaAttachments) > 0 { + a := status.MediaAttachments[0] + + og.ImageWidth = strconv.Itoa(a.Meta.Small.Width) + og.ImageHeight = strconv.Itoa(a.Meta.Small.Height) + + if a.PreviewURL != nil { + og.Image = *a.PreviewURL + } + + 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 + + return og +} + +// AccountTitle parses a page title from account and accountDomain +func AccountTitle(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 := text.SanitizeToPlaintext(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 + `"` +} + +// truncate trims given string to +// specified length (in runes). +func truncate(s string, l int) string { + r := []rune(s) + if len(r) < l { + // No need + // to trim. + return s + } + + return string(r[:l]) + "..." +} diff --git a/internal/api/util/opengraph_test.go b/internal/api/util/opengraph_test.go new file mode 100644 index 000000000..2ecd6a740 --- /dev/null +++ b/internal/api/util/opengraph_test.go @@ -0,0 +1,116 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 util + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +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(fmt.Sprintf("content=\"%s\"", tt.exp), ParseDescription(tt.in)) + }) + } +} + +func (suite *OpenGraphTestSuite) TestWithAccountWithNote() { + baseMeta := OGBase(&apimodel.InstanceV1{ + AccountDomain: "example.org", + Languages: []string{"en"}, + }) + + accountMeta := baseMeta.WithAccount(&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>", + Username: "example_account", + }) + + 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", + ArticlePublisher: "", + ArticleAuthor: "", + ArticleModifiedTime: "", + ArticlePublishedTime: "", + ProfileUsername: "example_account", + }, *accountMeta) +} + +func (suite *OpenGraphTestSuite) TestWithAccountNoNote() { + baseMeta := OGBase(&apimodel.InstanceV1{ + AccountDomain: "example.org", + Languages: []string{"en"}, + }) + + accountMeta := baseMeta.WithAccount(&apimodel.Account{ + Acct: "example_account", + DisplayName: "example person!!", + URL: "https://example.org/@example_account", + Note: "", // <- empty + Username: "example_account", + }) + + 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", + ArticlePublisher: "", + ArticleAuthor: "", + ArticleModifiedTime: "", + ArticlePublishedTime: "", + ProfileUsername: "example_account", + }, *accountMeta) +} + +func TestOpenGraphTestSuite(t *testing.T) { + suite.Run(t, &OpenGraphTestSuite{}) +} diff --git a/internal/api/util/template.go b/internal/api/util/template.go new file mode 100644 index 000000000..b8c710c3c --- /dev/null +++ b/internal/api/util/template.go @@ -0,0 +1,135 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 util + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" +) + +// WebPage encapsulates variables for +// rendering an HTML template within +// a standard GtS "page" template. +type WebPage struct { + // Name of the template for rendering + // the page. Eg., "example.tmpl". + Template string + + // Instance model for rendering header, + // footer, and "about" information. + Instance *apimodel.InstanceV1 + + // OGMeta for rendering page + // "meta:og*" tags. Can be nil. + OGMeta *OGMeta + + // Paths to CSS files to add to + // the page as "stylesheet" entries. + // Can be nil. + Stylesheets []string + + // Paths to JS files to add to + // the page as "script" entries. + // Can be nil. + Javascript []string + + // Extra parameters to pass to + // the template for rendering, + // eg., "account": *Account etc. + // Can be nil. + Extra map[string]any +} + +// TemplateWebPage renders the given HTML template and +// page params within the standard GtS "page" template. +// +// ogMeta, stylesheets, javascript, and any extra +// properties will be provided to the template if +// set, but can all be nil. +func TemplateWebPage( + c *gin.Context, + page WebPage, +) { + obj := map[string]any{ + "instance": page.Instance, + "ogMeta": page.OGMeta, + "stylesheets": page.Stylesheets, + "javascript": page.Javascript, + } + + for k, v := range page.Extra { + obj[k] = v + } + + templatePage(c, page.Template, http.StatusOK, obj) +} + +// templateErrorPage renders the given +// HTTP code, error, and request ID +// within the standard error template. +func templateErrorPage( + c *gin.Context, + instance *apimodel.InstanceV1, + code int, + err string, + requestID string, +) { + const errorTmpl = "error.tmpl" + + obj := map[string]any{ + "instance": instance, + "code": code, + "error": err, + "requestID": requestID, + } + + templatePage(c, errorTmpl, code, obj) +} + +// template404Page renders +// a standard 404 page. +func template404Page( + c *gin.Context, + instance *apimodel.InstanceV1, + requestID string, +) { + const notFoundTmpl = "404.tmpl" + + obj := map[string]any{ + "instance": instance, + "requestID": requestID, + } + + templatePage(c, notFoundTmpl, http.StatusNotFound, obj) +} + +// render the given template inside +// "page.tmpl" with the provided +// code and template object. +func templatePage( + c *gin.Context, + template string, + code int, + obj map[string]any, +) { + const pageTmpl = "page.tmpl" + obj["pageContent"] = template + c.HTML(code, pageTmpl, obj) +} |