[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
This commit is contained in:
tobi
2023-12-27 11:23:52 +01:00
committed by GitHub
parent 97a1fd9a29
commit 0ff52b71f2
77 changed files with 3262 additions and 1736 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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:"-"`
}
/*

View File

@@ -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(),

View File

@@ -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, `\`, "&bsol;")
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]) + "..."
}

View File

@@ -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 &#39;&bsol;e]8;;http://example.com&bsol;e&bsol;This is a link&bsol;e]8;;&bsol;e&#39;`},
{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{})
}

View File

@@ -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)
}