From 0ff52b71f2c0e970b1f0d43793c019bbed93e112 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 27 Dec 2023 11:23:52 +0100 Subject: [chore] Refactor HTML templates and CSS (#2480) * [chore] Refactor HTML templates and CSS * eslint * ignore "Local" * rss tests * fiddle with OG just a tiny bit * dick around with polls a bit more so SR stops saying "clickable" * remove break * oh lord * don't lazy load avatar * fix ogmeta tests * clean up some cruft * catch remaining calls to c.HTML * fix error rendering + stack overflow in tag * allow templating attributes * fix indent * set aria-hidden on status complementary content, since it's already present in the label anyway * tidy up templating calls a little * try to make styling a bit more consistent + readable * fix up some remaining CSS issues * fix up reports --- internal/api/auth/authorize.go | 30 ++-- internal/api/auth/callback.go | 34 ++-- internal/api/auth/oob.go | 18 ++- internal/api/auth/signin.go | 18 ++- internal/api/model/status.go | 6 + internal/api/util/errorhandling.go | 20 +-- internal/api/util/opengraph.go | 184 ++++++++++++++++++++++ internal/api/util/opengraph_test.go | 116 ++++++++++++++ internal/api/util/template.go | 135 ++++++++++++++++ internal/oauth/server.go | 4 +- internal/processing/account/rss_test.go | 2 +- internal/router/router.go | 1 - internal/router/template.go | 260 ++++++++++++++++++++++++------- internal/router/template_test.go | 204 ++++++++++++++++++++++++ internal/text/emojify.go | 91 ++++++++--- internal/typeutils/internaltofrontend.go | 6 + internal/typeutils/internaltorss.go | 2 +- internal/typeutils/internaltorss_test.go | 2 +- internal/web/about.go | 42 +++-- internal/web/base.go | 50 ------ internal/web/confirmemail.go | 49 ++++-- internal/web/customcss.go | 30 ++-- internal/web/domain-blocklist.go | 49 +++--- internal/web/index.go | 67 ++++++++ internal/web/opengraph.go | 180 --------------------- internal/web/opengraph_test.go | 116 -------------- internal/web/profile.go | 45 +++--- internal/web/robots.go | 2 +- internal/web/settings-panel.go | 44 ++++-- internal/web/tag.go | 18 +-- internal/web/thread.go | 30 ++-- internal/web/web.go | 19 ++- 32 files changed, 1267 insertions(+), 607 deletions(-) create mode 100644 internal/api/util/opengraph.go create mode 100644 internal/api/util/opengraph_test.go create mode 100644 internal/api/util/template.go create mode 100644 internal/router/template_test.go delete mode 100644 internal/web/base.go create mode 100644 internal/web/index.go delete mode 100644 internal/web/opengraph.go delete mode 100644 internal/web/opengraph_test.go (limited to 'internal') 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 . + +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 . + +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: "

This is my profile, read it and weep! Weep then!

", + 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 . + +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) +} diff --git a/internal/oauth/server.go b/internal/oauth/server.go index 97e6812c5..3e4519479 100644 --- a/internal/oauth/server.go +++ b/internal/oauth/server.go @@ -56,8 +56,8 @@ const ( OOBTokenPath = "/oauth/oob" // #nosec G101 else we get a hardcoded credentials warning // HelpfulAdvice is a handy hint to users; // particularly important during the login flow - HelpfulAdvice = "If you arrived at this error during a login/oauth flow, please try clearing your session cookies and logging in again; if problems persist, make sure you're using the correct credentials" - HelpfulAdviceGrant = "If you arrived at this error during a login/oauth flow, your client is trying to use an unsupported OAuth grant type. Supported grant types are: authorization_code, client_credentials; please reach out to developer of your client" + HelpfulAdvice = "If you arrived at this error during a sign in/oauth flow, please try clearing your session cookies and signing in again; if problems persist, make sure you're using the correct credentials" + HelpfulAdviceGrant = "If you arrived at this error during a sign in/oauth flow, your client is trying to use an unsupported OAuth grant type. Supported grant types are: authorization_code, client_credentials; please reach out to developer of your client" ) // Server wraps some oauth2 server functions in an interface, exposing only what is needed diff --git a/internal/processing/account/rss_test.go b/internal/processing/account/rss_test.go index e528c27e0..6ae285f9e 100644 --- a/internal/processing/account/rss_test.go +++ b/internal/processing/account/rss_test.go @@ -39,7 +39,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { fmt.Println(feed) - suite.Equal("\n \n Posts from @admin@localhost:8080\n http://localhost:8080/@admin\n Posts from @admin@localhost:8080\n Wed, 20 Oct 2021 12:36:45 +0000\n Wed, 20 Oct 2021 12:36:45 +0000\n \n open to see some puppies\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n @admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"\n \n @admin@localhost:8080\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n Wed, 20 Oct 2021 12:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n hello world! #welcome ! first post on the instance :rainbow: !\n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n @admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"\n !]]>\n @admin@localhost:8080\n \n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n Wed, 20 Oct 2021 11:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n", feed) + suite.Equal("\n \n Posts from @admin@localhost:8080\n http://localhost:8080/@admin\n Posts from @admin@localhost:8080\n Wed, 20 Oct 2021 12:36:45 +0000\n Wed, 20 Oct 2021 12:36:45 +0000\n \n open to see some puppies\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n @admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"\n \n @admin@localhost:8080\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n Wed, 20 Oct 2021 12:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n hello world! #welcome ! first post on the instance :rainbow: !\n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n @admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"\n !]]>\n @admin@localhost:8080\n \n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n Wed, 20 Oct 2021 11:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n", feed) } func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { diff --git a/internal/router/router.go b/internal/router/router.go index f71dc97ef..b2fb7418e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -83,7 +83,6 @@ func New(ctx context.Context) (*Router, error) { // Attach functions used by HTML templating, // and load HTML templates into the engine. - LoadTemplateFunctions(engine) if err := LoadTemplates(engine); err != nil { return nil, err } diff --git a/internal/router/template.go b/internal/router/template.go index d1f6f297c..981c3fcf4 100644 --- a/internal/router/template.go +++ b/internal/router/template.go @@ -18,52 +18,121 @@ package router import ( + "bytes" "fmt" "html/template" "os" "path/filepath" "reflect" + "regexp" "strings" "time" "unsafe" "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/render" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/log" + "github.com/superseriousbusiness/gotosocial/internal/regexes" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/util" ) -const ( - justTime = "15:04" - dateYear = "Jan 02, 2006" - dateTime = "Jan 02, 15:04" - dateYearTime = "Jan 02, 2006, 15:04" - monthYear = "Jan, 2006" - badTimestamp = "bad timestamp" -) - -// LoadTemplates loads html templates for use by the given engine +// LoadTemplates loads templates found at `web-template-base-dir` +// into the Gin engine, or errors if templates cannot be loaded. +// +// The special functions "include" and "includeAttr" will be added +// to the template funcMap for use in any template. Use these "include" +// functions when you need to pass a template through a pipeline. +// Otherwise, prefer the built-in "template" function. func LoadTemplates(engine *gin.Engine) error { templateBaseDir := config.GetWebTemplateBaseDir() if templateBaseDir == "" { - return fmt.Errorf("%s cannot be empty and must be a relative or absolute path", config.WebTemplateBaseDirFlag()) + return gtserror.Newf( + "%s cannot be empty and must be a relative or absolute path", + config.WebTemplateBaseDirFlag(), + ) } - templateBaseDir, err := filepath.Abs(templateBaseDir) + templateDirAbs, err := filepath.Abs(templateBaseDir) if err != nil { - return fmt.Errorf("error getting absolute path of %s: %s", templateBaseDir, err) + return gtserror.Newf( + "error getting absolute path of web-template-base-dir %s: %w", + templateBaseDir, err, + ) + } + + indexTmplPath := filepath.Join(templateDirAbs, "index.tmpl") + if _, err := os.Stat(indexTmplPath); err != nil { + return gtserror.Newf( + "cannot find index.tmpl in web template directory %s: %w", + templateDirAbs, err, + ) + } + + // Bring base template into scope. + tmpl := template.New("base") + + // Set additional "include" functions to render + // provided template name using the base template. + funcMap["include"] = func(name string, data any) (template.HTML, error) { + var buf strings.Builder + err := tmpl.ExecuteTemplate(&buf, name, data) + + // Template was already escaped by + // ExecuteTemplate so we can trust it. + return noescape(buf.String()), err } - if _, err := os.Stat(filepath.Join(templateBaseDir, "index.tmpl")); err != nil { - return fmt.Errorf("%s doesn't seem to contain the templates; index.tmpl is missing: %w", templateBaseDir, err) + funcMap["includeAttr"] = func(name string, data any) (template.HTMLAttr, error) { + var buf strings.Builder + err := tmpl.ExecuteTemplate(&buf, name, data) + + // Template was already escaped by + // ExecuteTemplate so we can trust it. + return noescapeAttr(buf.String()), err } - engine.LoadHTMLGlob(filepath.Join(templateBaseDir, "*")) + // Load functions into the base template, and + // associate other templates with base template. + templateGlob := filepath.Join(templateDirAbs, "*") + tmpl, err = tmpl.Funcs(funcMap).ParseGlob(templateGlob) + if err != nil { + return gtserror.Newf("error loading templates: %w", err) + } + + // Almost done; teach the + // engine how to render. + engine.SetFuncMap(funcMap) + engine.HTMLRender = render.HTMLProduction{Template: tmpl} + return nil } +var funcMap = template.FuncMap{ + "add": add, + "acctInstance": acctInstance, + "demojify": demojify, + "deref": deref, + "emojify": emojify, + "escape": escape, + "increment": increment, + "indent": indent, + "indentAttr": indentAttr, + "isNil": isNil, + "outdentPre": outdentPre, + "noescapeAttr": noescapeAttr, + "noescape": noescape, + "oddOrEven": oddOrEven, + "subtract": subtract, + "timestampPrecise": timestampPrecise, + "timestamp": timestamp, + "timestampVague": timestampVague, + "visibilityIcon": visibilityIcon, +} + func oddOrEven(n int) string { if n%2 == 0 { return "even" @@ -71,21 +140,40 @@ func oddOrEven(n int) string { return "odd" } +// escape HTML escapes the given string, +// returning a trusted template. func escape(str string) template.HTML { /* #nosec G203 */ return template.HTML(template.HTMLEscapeString(str)) } +// noescape marks the given string as a +// trusted template. The provided string +// MUST have already passed through a +// template or escaping function. func noescape(str string) template.HTML { /* #nosec G203 */ return template.HTML(str) } +// noescapeAttr marks the given string as a +// trusted HTML attribute. The provided string +// MUST have already passed through a template +// or escaping function. func noescapeAttr(str string) template.HTMLAttr { /* #nosec G203 */ return template.HTMLAttr(str) } +const ( + justTime = "15:04" + dateYear = "Jan 02, 2006" + dateTime = "Jan 02, 15:04" + dateYearTime = "Jan 02, 2006, 15:04" + monthYear = "Jan, 2006" + badTimestamp = "bad timestamp" +) + func timestamp(stamp string) string { t, err := util.ParseISO8601(stamp) if err != nil { @@ -127,38 +215,55 @@ func timestampVague(stamp string) string { return t.Format(monthYear) } -type iconWithLabel struct { - faIcon string - label string -} - func visibilityIcon(visibility apimodel.Visibility) template.HTML { - var icon iconWithLabel + var ( + label string + icon string + ) switch visibility { case apimodel.VisibilityPublic: - icon = iconWithLabel{"globe", "public"} + label = "public" + icon = "globe" case apimodel.VisibilityUnlisted: - icon = iconWithLabel{"unlock", "unlisted"} + label = "unlisted" + icon = "unlock" case apimodel.VisibilityPrivate: - icon = iconWithLabel{"lock", "private"} + label = "private" + icon = "lock" case apimodel.VisibilityMutualsOnly: - icon = iconWithLabel{"handshake-o", "mutuals only"} + label = "mutuals-only" + icon = "handshake-o" case apimodel.VisibilityDirect: - icon = iconWithLabel{"envelope", "direct"} + label = "direct" + icon = "envelope" } /* #nosec G203 */ - return template.HTML(fmt.Sprintf(``, icon.label, icon.faIcon)) + return template.HTML(fmt.Sprintf( + ``, + label, icon, + )) } -// text is a template.HTML to affirm that the input of this function is already escaped -func emojify(emojis []apimodel.Emoji, inputText template.HTML) template.HTML { - out := text.Emojify(emojis, string(inputText)) +// emojify replaces emojis in the given +// html fragment with suitable tags. +// +// The provided input must have been +// escaped / templated already! +func emojify( + emojis []apimodel.Emoji, + html template.HTML, +) template.HTML { + return text.EmojifyWeb(emojis, html) +} - /* #nosec G203 */ - // (this is escaped above) - return template.HTML(out) +// demojify replaces emoji shortcodes in +// the given fragment with empty strings. +// +// Output must then be escaped as appropriate. +func demojify(input string) string { + return text.Demojify(input) } func acctInstance(acct string) string { @@ -170,10 +275,79 @@ func acctInstance(acct string) string { return "" } +// increment adds 1 +// to the given int. func increment(i int) int { return i + 1 } +// add adds n2 to n1. +func add(n1 int, n2 int) int { + return n1 + n2 +} + +// subtract subtracts n2 from n1. +func subtract(n1 int, n2 int) int { + return n1 - n2 +} + +var ( + indentRegex = regexp.MustCompile(`(?m)^`) + indentStr = " " + indentStrLen = len(indentStr) + indents = strings.Repeat(indentStr, 12) + indentPre = regexp.MustCompile(fmt.Sprintf(`(?Ums)^((?:%s)+)
.*
`, indentStr)) +) + +// indent appropriately indents the given html +// by prepending each line with the indentStr. +func indent(n int, html template.HTML) template.HTML { + out := indentRegex.ReplaceAllString( + string(html), + indents[:n*indentStrLen], + ) + return noescape(out) +} + +// indentAttr appropriately indents the given html +// attribute by prepending each line with the indentStr. +func indentAttr(n int, html template.HTMLAttr) template.HTMLAttr { + out := indentRegex.ReplaceAllString( + string(html), + indents[:n*indentStrLen], + ) + return noescapeAttr(out) +} + +// outdentPre outdents all `
` tags in the
+// given HTML so that they render correctly in code
+// blocks, even if they were indented before.
+func outdentPre(html template.HTML) template.HTML {
+	input := string(html)
+	output := regexes.ReplaceAllStringFunc(indentPre, input,
+		func(match string, buf *bytes.Buffer) string {
+			// Reuse the regex to pull out submatches.
+			matches := indentPre.FindAllStringSubmatch(match, -1)
+			if len(matches) != 1 {
+				return match
+			}
+
+			var (
+				indented = matches[0][0]
+				indent   = matches[0][1]
+			)
+
+			// Outdent everything in the inner match, add
+			// a newline at the end to make it a bit neater.
+			outdented := strings.ReplaceAll(indented, indent, "")
+
+			// Replace original match with the outdented version.
+			return strings.ReplaceAll(match, indented, outdented)
+		},
+	)
+	return noescape(output)
+}
+
 // isNil will safely check if 'v' is nil without
 // dealing with weird Go interface nil bullshit.
 func isNil(i interface{}) bool {
@@ -193,21 +367,3 @@ func deref(i any) any {
 
 	return vOf.Elem()
 }
-
-func LoadTemplateFunctions(engine *gin.Engine) {
-	engine.SetFuncMap(template.FuncMap{
-		"escape":           escape,
-		"noescape":         noescape,
-		"noescapeAttr":     noescapeAttr,
-		"oddOrEven":        oddOrEven,
-		"visibilityIcon":   visibilityIcon,
-		"timestamp":        timestamp,
-		"timestampVague":   timestampVague,
-		"timestampPrecise": timestampPrecise,
-		"emojify":          emojify,
-		"acctInstance":     acctInstance,
-		"increment":        increment,
-		"isNil":            isNil,
-		"deref":            deref,
-	})
-}
diff --git a/internal/router/template_test.go b/internal/router/template_test.go
new file mode 100644
index 000000000..19bf759e0
--- /dev/null
+++ b/internal/router/template_test.go
@@ -0,0 +1,204 @@
+// 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 .
+
+package router
+
+import (
+	"html/template"
+	"testing"
+)
+
+func TestOutdentPre(t *testing.T) {
+	const html = template.HTML(`
+        
+
+

Here's a bunch of HTML, read it and weep, weep then!

+
<section class="about-user">
+                    <div class="col-header">
+                        <h2>About</h2>
+                    </div>            
+                    <div class="fields">
+                        <h3 class="sr-only">Fields</h3>
+                        <dl>
+                            <div class="field">
+                                <dt>should you follow me?</dt>
+                                <dd>maybe!</dd>
+                            </div>
+                            <div class="field">
+                                <dt>age</dt>
+                                <dd>120</dd>
+                            </div>
+                        </dl>
+                    </div>
+                    <div class="bio">
+                        <h3 class="sr-only">Bio</h3>
+                        <p>i post about things that concern me</p>
+                    </div>
+                    <div class="sr-only" role="group">
+                        <h3 class="sr-only">Stats</h3>
+                        <span>Joined in Jun, 2022.</span>
+                        <span>8 posts.</span>
+                        <span>Followed by 1.</span>
+                        <span>Following 1.</span>
+                    </div>
+                    <div class="accountstats" aria-hidden="true">
+                        <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>
+                        <b>Posts</b><span>8</span>
+                        <b>Followed by</b><span>1</span>
+                        <b>Following</b><span>1</span>
+                    </div>
+                </section>
+                
+

There, hope you liked that!

+
+
+
+
+

Here's a bunch of HTML, read it and weep, weep then!

+
<section class="about-user">
+                    <div class="col-header">
+                        <h2>About</h2>
+                    </div>            
+                    <div class="fields">
+                        <h3 class="sr-only">Fields</h3>
+                        <dl>
+                            <div class="field">
+                                <dt>should you follow me?</dt>
+                                <dd>maybe!</dd>
+                            </div>
+                            <div class="field">
+                                <dt>age</dt>
+                                <dd>120</dd>
+                            </div>
+                        </dl>
+                    </div>
+                    <div class="bio">
+                        <h3 class="sr-only">Bio</h3>
+                        <p>i post about things that concern me</p>
+                    </div>
+                    <div class="sr-only" role="group">
+                        <h3 class="sr-only">Stats</h3>
+                        <span>Joined in Jun, 2022.</span>
+                        <span>8 posts.</span>
+                        <span>Followed by 1.</span>
+                        <span>Following 1.</span>
+                    </div>
+                    <div class="accountstats" aria-hidden="true">
+                        <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>
+                        <b>Posts</b><span>8</span>
+                        <b>Followed by</b><span>1</span>
+                        <b>Following</b><span>1</span>
+                    </div>
+                </section>
+                
+

There, hope you liked that!

+
+
+`) + + const expected = template.HTML(` +
+
+

Here's a bunch of HTML, read it and weep, weep then!

+
<section class="about-user">
+    <div class="col-header">
+        <h2>About</h2>
+    </div>            
+    <div class="fields">
+        <h3 class="sr-only">Fields</h3>
+        <dl>
+            <div class="field">
+<dt>should you follow me?</dt>
+<dd>maybe!</dd>
+            </div>
+            <div class="field">
+<dt>age</dt>
+<dd>120</dd>
+            </div>
+        </dl>
+    </div>
+    <div class="bio">
+        <h3 class="sr-only">Bio</h3>
+        <p>i post about things that concern me</p>
+    </div>
+    <div class="sr-only" role="group">
+        <h3 class="sr-only">Stats</h3>
+        <span>Joined in Jun, 2022.</span>
+        <span>8 posts.</span>
+        <span>Followed by 1.</span>
+        <span>Following 1.</span>
+    </div>
+    <div class="accountstats" aria-hidden="true">
+        <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>
+        <b>Posts</b><span>8</span>
+        <b>Followed by</b><span>1</span>
+        <b>Following</b><span>1</span>
+    </div>
+</section>
+
+

There, hope you liked that!

+
+
+
+
+

Here's a bunch of HTML, read it and weep, weep then!

+
<section class="about-user">
+    <div class="col-header">
+        <h2>About</h2>
+    </div>            
+    <div class="fields">
+        <h3 class="sr-only">Fields</h3>
+        <dl>
+            <div class="field">
+<dt>should you follow me?</dt>
+<dd>maybe!</dd>
+            </div>
+            <div class="field">
+<dt>age</dt>
+<dd>120</dd>
+            </div>
+        </dl>
+    </div>
+    <div class="bio">
+        <h3 class="sr-only">Bio</h3>
+        <p>i post about things that concern me</p>
+    </div>
+    <div class="sr-only" role="group">
+        <h3 class="sr-only">Stats</h3>
+        <span>Joined in Jun, 2022.</span>
+        <span>8 posts.</span>
+        <span>Followed by 1.</span>
+        <span>Following 1.</span>
+    </div>
+    <div class="accountstats" aria-hidden="true">
+        <b>Joined</b><time datetime="2022-06-04T13:12:00.000Z">Jun, 2022</time>
+        <b>Posts</b><span>8</span>
+        <b>Followed by</b><span>1</span>
+        <b>Following</b><span>1</span>
+    </div>
+</section>
+
+

There, hope you liked that!

+
+
+`) + + out := outdentPre(html) + if out != expected { + t.Fatalf("unexpected output:\n`%s`\n", out) + } +} diff --git a/internal/text/emojify.go b/internal/text/emojify.go index 2100d5e81..23730eaf9 100644 --- a/internal/text/emojify.go +++ b/internal/text/emojify.go @@ -20,18 +20,76 @@ package text import ( "bytes" "html" + "html/template" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/regexes" ) -// Emojify replaces shortcodes in `inputText` with the emoji in `emojis`. -// -// Callers should ensure that inputText and resulting text are escaped -// appropriately depending on what they're used for. -func Emojify(emojis []apimodel.Emoji, inputText string) string { - emojisMap := make(map[string]apimodel.Emoji, len(emojis)) +// EmojifyWeb replaces emoji shortcodes like `:example:` in the given HTML +// fragment with `` tags suitable for rendering on the web frontend. +func EmojifyWeb(emojis []apimodel.Emoji, html template.HTML) template.HTML { + out := emojify( + emojis, + string(html), + func(url, code string, buf *bytes.Buffer) { + buf.WriteString(`:`)
+			buf.WriteString(code)
+			buf.WriteString(`:`) + }, + ) + + // If input was safe, + // we can trust output. + return template.HTML(out) // #nosec G203 +} +// EmojifyRSS replaces emoji shortcodes like `:example:` in the given text +// fragment with `` tags suitable for rendering as RSS content. +func EmojifyRSS(emojis []apimodel.Emoji, text string) string { + return emojify( + emojis, + text, + func(url, code string, buf *bytes.Buffer) { + buf.WriteString(`:`)
+			buf.WriteString(code)
+			buf.WriteString(`:`) + }, + ) +} + +// Demojify replaces emoji shortcodes like `:example:` in the given text +// fragment with empty strings, essentially stripping them from the text. +// This is useful for text used in OG Meta headers. +func Demojify(text string) string { + return regexes.EmojiFinder.ReplaceAllString(text, "") +} + +func emojify( + emojis []apimodel.Emoji, + input string, + write func(url, code string, buf *bytes.Buffer), +) string { + // Build map of shortcodes. Normalize each + // shortcode by readding closing colons. + emojisMap := make(map[string]apimodel.Emoji, len(emojis)) for _, emoji := range emojis { shortcode := ":" + emoji.Shortcode + ":" emojisMap[shortcode] = emoji @@ -39,27 +97,20 @@ func Emojify(emojis []apimodel.Emoji, inputText string) string { return regexes.ReplaceAllStringFunc( regexes.EmojiFinder, - inputText, + input, func(shortcode string, buf *bytes.Buffer) string { - // Look for emoji according to this shortcode + // Look for emoji with this shortcode. emoji, ok := emojisMap[shortcode] if !ok { return shortcode } - // Escape raw emoji content - safeURL := html.EscapeString(emoji.URL) - safeCode := html.EscapeString(emoji.Shortcode) - - // Write HTML emoji repr to buffer - buf.WriteString(`:`)
-			buf.WriteString(safeCode)
-			buf.WriteString(`:`) + // Escape raw emoji content. + url := html.EscapeString(emoji.URL) + code := html.EscapeString(emoji.Shortcode) + // Write emoji repr to buffer. + write(url, code, buf) return buf.String() }, ) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 09d538a3d..9d1f7edeb 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -662,6 +662,10 @@ func (c *Converter) StatusToWebStatus( return nil, err } + // Whack a newline before and after each "pre" to make it easier to outdent it. + webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "\n
")
+	webStatus.Content = strings.ReplaceAll(webStatus.Content, "
", "
\n") + // Add additional information for template. // Assume empty langs, hope for not empty language. webStatus.LanguageTag = new(language.Language) @@ -727,6 +731,8 @@ func (c *Converter) StatusToWebStatus( a.Sensitive = webStatus.Sensitive } + webStatus.Local = *s.Local + return webStatus, nil } diff --git a/internal/typeutils/internaltorss.go b/internal/typeutils/internaltorss.go index e70b11aae..e1174caf6 100644 --- a/internal/typeutils/internaltorss.go +++ b/internal/typeutils/internaltorss.go @@ -151,7 +151,7 @@ func (c *Converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*f apiEmojis = append(apiEmojis, apiEmoji) } } - content := text.Emojify(apiEmojis, s.Content) + content := text.EmojifyRSS(apiEmojis, s.Content) return &feeds.Item{ Title: title, diff --git a/internal/typeutils/internaltorss_test.go b/internal/typeutils/internaltorss_test.go index ea6f3cc93..305015d28 100644 --- a/internal/typeutils/internaltorss_test.go +++ b/internal/typeutils/internaltorss_test.go @@ -81,7 +81,7 @@ func (suite *InternalToRSSTestSuite) TestStatusToRSSItem2() { suite.Equal("62529", item.Enclosure.Length) suite.Equal("image/jpeg", item.Enclosure.Type) suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", item.Enclosure.Url) - suite.Equal("hello world! #welcome ! first post on the instance \":rainbow:\" !", item.Content) + suite.Equal("hello world! #welcome ! first post on the instance \":rainbow:\" !", item.Content) } func (suite *InternalToRSSTestSuite) TestStatusToRSSItem3() { diff --git a/internal/web/about.go b/internal/web/about.go index 89bb13f0d..2bc558962 100644 --- a/internal/web/about.go +++ b/internal/web/about.go @@ -18,9 +18,10 @@ package web import ( - "net/http" + "context" "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -31,20 +32,35 @@ const ( ) func (m *Module) aboutGETHandler(c *gin.Context) { - instance, err := m.processor.InstanceGetV1(c.Request.Context()) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context()) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - c.HTML(http.StatusOK, "about.tmpl", gin.H{ - "instance": instance, - "languages": config.GetInstanceLanguages().DisplayStrs(), - "ogMeta": ogBase(instance), - "blocklistExposed": config.GetInstanceExposeSuspendedWeb(), - "stylesheets": []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", + // Return instance we already got from the db, + // don't try to fetch it again when erroring. + instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { + return instance, nil + } + + // We only serve text/html at this endpoint. + if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil { + apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) + return + } + + page := apiutil.WebPage{ + Template: "about.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance), + Stylesheets: []string{cssAbout}, + Extra: map[string]any{ + "showStrap": true, + "blocklistExposed": config.GetInstanceExposeSuspendedWeb(), + "languages": config.GetInstanceLanguages().DisplayStrs(), }, - "javascript": []string{distPathPrefix + "/frontend.js"}, - }) + } + + apiutil.TemplateWebPage(c, page) } diff --git a/internal/web/base.go b/internal/web/base.go deleted file mode 100644 index 5bc3c536a..000000000 --- a/internal/web/base.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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 . - -package web - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/gtserror" -) - -func (m *Module) baseHandler(c *gin.Context) { - // if a landingPageUser is set in the config, redirect to that user's profile - if landingPageUser := config.GetLandingPageUser(); landingPageUser != "" { - c.Redirect(http.StatusFound, "/@"+strings.ToLower(landingPageUser)) - return - } - - instance, err := m.processor.InstanceGetV1(c.Request.Context()) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) - return - } - - c.HTML(http.StatusOK, "index.tmpl", gin.H{ - "instance": instance, - "ogMeta": ogBase(instance), - "stylesheets": []string{ - distPathPrefix + "/index.css", - }, - }) -} diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go index b0ece58cd..f15252bf7 100644 --- a/internal/web/confirmemail.go +++ b/internal/web/confirmemail.go @@ -18,39 +18,58 @@ package web import ( + "context" "errors" "net/http" "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) func (m *Module) confirmEmailGETHandler(c *gin.Context) { - ctx := c.Request.Context() + instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context()) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Return instance we already got from the db, + // don't try to fetch it again when erroring. + instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { + return instance, nil + } - // if there's no token in the query, just serve the 404 web handler - token := c.Query(tokenParam) + // We only serve text/html at this endpoint. + if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil { + apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) + return + } + + // If there's no token in the query, + // just serve the 404 web handler. + token := c.Query("token") if token == "" { - apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGetV1) + errWithCode := gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))) + apiutil.WebErrorHandler(c, errWithCode, instanceGet) return } - user, errWithCode := m.processor.User().EmailConfirm(ctx, token) + user, errWithCode := m.processor.User().EmailConfirm(c.Request.Context(), token) if errWithCode != nil { - apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + apiutil.WebErrorHandler(c, errWithCode, instanceGet) return } - instance, err := m.processor.InstanceGetV1(ctx) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) - return + page := apiutil.WebPage{ + Template: "confirmed.tmpl", + Instance: instance, + Extra: map[string]any{ + "email": user.Email, + "username": user.Account.Username, + }, } - c.HTML(http.StatusOK, "confirmed.tmpl", gin.H{ - "instance": instance, - "email": user.Email, - "username": user.Account.Username, - }) + apiutil.TemplateWebPage(c, page) } diff --git a/internal/web/customcss.go b/internal/web/customcss.go index ef57e0033..b23ebce8e 100644 --- a/internal/web/customcss.go +++ b/internal/web/customcss.go @@ -18,9 +18,7 @@ package web import ( - "errors" "net/http" - "strings" "github.com/gin-gonic/gin" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" @@ -31,31 +29,29 @@ import ( const textCSSUTF8 = string(apiutil.TextCSS + "; charset=utf-8") func (m *Module) customCSSGETHandler(c *gin.Context) { - if !config.GetAccountsAllowCustomCSS() { - err := errors.New("accounts-allow-custom-css is not enabled on this instance") - apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) - return - } - if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil { apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) return } - // usernames on our instance will always be lowercase - username := strings.ToLower(c.Param(usernameKey)) - if username == "" { - err := errors.New("no account username specified") - apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) - return - } - - customCSS, errWithCode := m.processor.Account().GetCustomCSSForUsername(c.Request.Context(), username) + targetUsername, errWithCode := apiutil.ParseWebUsername(c.Param(apiutil.WebUsernameKey)) if errWithCode != nil { apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } + // Retrieve customCSS if enabled on the instance. + // Else use an empty string, to help with caching + // when custom CSS gets toggled on or off. + var customCSS string + if config.GetAccountsAllowCustomCSS() { + customCSS, errWithCode = m.processor.Account().GetCustomCSSForUsername(c.Request.Context(), targetUsername) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + } + c.Header(cacheControlHeader, cacheControlNoCache) c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS)) } diff --git a/internal/web/domain-blocklist.go b/internal/web/domain-blocklist.go index 8a88a0932..5d631e0f7 100644 --- a/internal/web/domain-blocklist.go +++ b/internal/web/domain-blocklist.go @@ -18,14 +18,14 @@ package web import ( + "context" "fmt" - "net/http" "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" - "github.com/superseriousbusiness/gotosocial/internal/oauth" ) const ( @@ -33,37 +33,44 @@ const ( ) func (m *Module) domainBlockListGETHandler(c *gin.Context) { - authed, err := oauth.Authed(c, false, false, false, false) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context()) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - if !config.GetInstanceExposeSuspendedWeb() && (authed.Account == nil || authed.User == nil) { - err := fmt.Errorf("this instance does not expose the list of suspended domains publicly") - apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + // Return instance we already got from the db, + // don't try to fetch it again when erroring. + instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { + return instance, nil + } + + // We only serve text/html at this endpoint. + if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil { + apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) return } - instance, err := m.processor.InstanceGetV1(c.Request.Context()) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + if !config.GetInstanceExposeSuspendedWeb() { + err := fmt.Errorf("this instance does not publicy expose its blocklist") + apiutil.WebErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), instanceGet) return } domainBlocks, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), true, false, false) if errWithCode != nil { - apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + apiutil.WebErrorHandler(c, errWithCode, instanceGet) return } - c.HTML(http.StatusOK, "domain-blocklist.tmpl", gin.H{ - "instance": instance, - "ogMeta": ogBase(instance), - "blocklist": domainBlocks, - "stylesheets": []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", - }, - "javascript": []string{distPathPrefix + "/frontend.js"}, - }) + page := apiutil.WebPage{ + Template: "domain-blocklist.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance), + Stylesheets: []string{cssFA}, + Javascript: []string{jsFrontend}, + Extra: map[string]any{"blocklist": domainBlocks}, + } + + apiutil.TemplateWebPage(c, page) } diff --git a/internal/web/index.go b/internal/web/index.go new file mode 100644 index 000000000..25960cf7f --- /dev/null +++ b/internal/web/index.go @@ -0,0 +1,67 @@ +// 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 . + +package web + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (m *Module) indexHandler(c *gin.Context) { + instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context()) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + // Return instance we already got from the db, + // don't try to fetch it again when erroring. + instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { + return instance, nil + } + + // We only serve text/html at this endpoint. + if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil { + apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) + return + } + + // If a landingPageUser is set in the config, redirect to + // that user's profile instead of rendering landing/index page. + if landingPageUser := config.GetLandingPageUser(); landingPageUser != "" { + c.Redirect(http.StatusFound, "/@"+strings.ToLower(landingPageUser)) + return + } + + page := apiutil.WebPage{ + Template: "index.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance), + Stylesheets: []string{cssAbout, cssIndex}, + Extra: map[string]any{"showStrap": true}, + } + + apiutil.TemplateWebPage(c, page) +} diff --git a/internal/web/opengraph.go b/internal/web/opengraph.go deleted file mode 100644 index 66b6c6eea..000000000 --- a/internal/web/opengraph.go +++ /dev/null @@ -1,180 +0,0 @@ -// 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 . - -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://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 = parseTitle(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 " + parseTitle(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 -} - -// 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 := text.SanitizeToPlaintext(in) - i = strings.ReplaceAll(i, "\n", " ") - i = strings.Join(strings.Fields(i), " ") - i = html.EscapeString(i) - i = strings.ReplaceAll(i, `\`, "\") - 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/opengraph_test.go b/internal/web/opengraph_test.go deleted file mode 100644 index 06e97cdce..000000000 --- a/internal/web/opengraph_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// 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 . - -package web - -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: "

This is my profile, read it and weep! Weep then!

", - 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/web/profile.go b/internal/web/profile.go index b2c3bb944..b629c98b9 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -27,7 +27,6 @@ import ( "github.com/gin-gonic/gin" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -141,28 +140,28 @@ func (m *Module) profileGETHandler(c *gin.Context) { return } - stylesheets := []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", - distPathPrefix + "/status.css", - distPathPrefix + "/profile.css", - } - if config.GetAccountsAllowCustomCSS() { - stylesheets = append(stylesheets, "/@"+targetAccount.Username+"/custom.css") - } - - c.HTML(http.StatusOK, "profile.tmpl", gin.H{ - "instance": instance, - "account": targetAccount, - "ogMeta": ogBase(instance).withAccount(targetAccount), - "rssFeed": rssFeed, - "robotsMeta": robotsMeta, - "statuses": statusResp.Items, - "statuses_next": statusResp.NextLink, - "pinned_statuses": pinnedStatuses, - "show_back_to_top": paging, - "stylesheets": stylesheets, - "javascript": []string{distPathPrefix + "/frontend.js"}, - }) + page := apiutil.WebPage{ + Template: "profile.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount), + Stylesheets: []string{ + cssFA, cssStatus, cssThread, cssProfile, + // Custom CSS for this user last in cascade. + "/@" + targetAccount.Username + "/custom.css", + }, + Javascript: []string{jsFrontend}, + Extra: map[string]any{ + "account": targetAccount, + "rssFeed": rssFeed, + "robotsMeta": robotsMeta, + "statuses": statusResp.Items, + "statuses_next": statusResp.NextLink, + "pinned_statuses": pinnedStatuses, + "show_back_to_top": paging, + }, + } + + apiutil.TemplateWebPage(c, page) } // returnAPAccount returns an ActivityPub representation of diff --git a/internal/web/robots.go b/internal/web/robots.go index aee4d1a55..92bd33014 100644 --- a/internal/web/robots.go +++ b/internal/web/robots.go @@ -71,7 +71,7 @@ Crawl-delay: 500 # API endpoints. Disallow: /api/ -# Auth/login endpoints. +# Auth/Sign in endpoints. Disallow: /auth/ Disallow: /oauth/ Disallow: /check_your_email diff --git a/internal/web/settings-panel.go b/internal/web/settings-panel.go index 615f2d265..9de30d1f1 100644 --- a/internal/web/settings-panel.go +++ b/internal/web/settings-panel.go @@ -18,30 +18,44 @@ package web import ( - "net/http" + "context" "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) func (m *Module) SettingsPanelHandler(c *gin.Context) { - instance, err := m.processor.InstanceGetV1(c.Request.Context()) - if err != nil { - apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context()) + if errWithCode != nil { + apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } - c.HTML(http.StatusOK, "frontend.tmpl", gin.H{ - "instance": instance, - "stylesheets": []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", - distPathPrefix + "/_colors.css", - distPathPrefix + "/base.css", - distPathPrefix + "/profile.css", - distPathPrefix + "/status.css", - distPathPrefix + "/settings-style.css", + // Return instance we already got from the db, + // don't try to fetch it again when erroring. + instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) { + return instance, nil + } + + // We only serve text/html at this endpoint. + if _, err := apiutil.NegotiateAccept(c, apiutil.TextHTML); err != nil { + apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), instanceGet) + return + } + + page := apiutil.WebPage{ + Template: "frontend.tmpl", + Instance: instance, + Stylesheets: []string{ + cssFA, + cssProfile, // Used for rendering stub/fake profiles. + cssStatus, // Used for rendering stub/fake statuses. + cssSettings, }, - "javascript": []string{distPathPrefix + "/settings.js"}, - }) + Javascript: []string{jsSettings}, + } + + apiutil.TemplateWebPage(c, page) } diff --git a/internal/web/tag.go b/internal/web/tag.go index 69591f114..5c3cd31a6 100644 --- a/internal/web/tag.go +++ b/internal/web/tag.go @@ -19,7 +19,6 @@ package web import ( "context" - "net/http" "github.com/gin-gonic/gin" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -56,16 +55,13 @@ func (m *Module) tagGETHandler(c *gin.Context) { return } - stylesheets := []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", - distPathPrefix + "/status.css", - distPathPrefix + "/tag.css", + page := apiutil.WebPage{ + Template: "tag.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance), + Stylesheets: []string{cssFA, cssThread, cssTag}, + Extra: map[string]any{"tagName": tagName}, } - c.HTML(http.StatusOK, "tag.tmpl", gin.H{ - "instance": instance, - "ogMeta": ogBase(instance), - "tagName": tagName, - "stylesheets": stylesheets, - }) + apiutil.TemplateWebPage(c, page) } diff --git a/internal/web/thread.go b/internal/web/thread.go index 20145cfcd..4dcd1d221 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -28,7 +28,6 @@ import ( "github.com/gin-gonic/gin" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" - "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -139,22 +138,23 @@ func (m *Module) threadGETHandler(c *gin.Context) { return } - stylesheets := []string{ - assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css", - distPathPrefix + "/status.css", - } - if config.GetAccountsAllowCustomCSS() { - stylesheets = append(stylesheets, "/@"+targetUsername+"/custom.css") + page := apiutil.WebPage{ + Template: "thread.tmpl", + Instance: instance, + OGMeta: apiutil.OGBase(instance).WithStatus(status), + Stylesheets: []string{ + cssFA, cssStatus, cssThread, + // Custom CSS for this user last in cascade. + "/@" + targetUsername + "/custom.css", + }, + Javascript: []string{jsFrontend}, + Extra: map[string]any{ + "status": status, + "context": context, + }, } - c.HTML(http.StatusOK, "thread.tmpl", gin.H{ - "instance": instance, - "status": status, - "context": context, - "ogMeta": ogBase(instance).withStatus(status), - "stylesheets": stylesheets, - "javascript": []string{distPathPrefix + "/frontend.js"}, - }) + apiutil.TemplateWebPage(c, page) } // returnAPStatus returns an ActivityPub representation of target status, diff --git a/internal/web/web.go b/internal/web/web.go index 86e74d6f8..5e57d08c6 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -37,7 +37,7 @@ import ( const ( confirmEmailPath = "/" + uris.ConfirmEmailPath - profileGroupPath = "/@:" + usernameKey + profileGroupPath = "/@:username" statusPath = "/statuses/:" + apiutil.WebStatusIDKey // leave out the '/@:username' prefix as this will be served within the profile group tagsPath = "/tags/:" + apiutil.TagNameKey customCSSPath = profileGroupPath + "/custom.css" @@ -49,15 +49,24 @@ const ( userPanelPath = settingsPathPrefix + "/user" adminPanelPath = settingsPathPrefix + "/admin" - tokenParam = "token" - usernameKey = "username" - cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives ifModifiedSinceHeader = "If-Modified-Since" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since ifNoneMatchHeader = "If-None-Match" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match eTagHeader = "ETag" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified + + cssFA = assetsPathPrefix + "/Fork-Awesome/css/fork-awesome.min.css" + cssAbout = distPathPrefix + "/about.css" + cssIndex = distPathPrefix + "/index.css" + cssStatus = distPathPrefix + "/status.css" + cssThread = distPathPrefix + "/thread.css" + cssProfile = distPathPrefix + "/profile.css" + cssSettings = distPathPrefix + "/settings-style.css" + cssTag = distPathPrefix + "/tag.css" + + jsFrontend = distPathPrefix + "/frontend.js" // Progressive enhancement frontend JS. + jsSettings = distPathPrefix + "/settings.js" // Settings panel React application. ) type Module struct { @@ -99,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) { profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler) // Attach individual web handlers which require no specific middlewares - r.AttachHandler(http.MethodGet, "/", m.baseHandler) // front-page + r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler) r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) -- cgit v1.2.3