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
---
.goreleaser.yml | 2 +
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 +-
testrig/gin.go | 1 -
testrig/router.go | 1 -
web/source/.eslintignore | 4 +-
web/source/css/_colors.css | 8 +-
web/source/css/about.css | 39 ++
web/source/css/base.css | 560 +++++++++++-------------
web/source/css/index.css | 89 +++-
web/source/css/page.css | 107 +++++
web/source/css/prism.css | 5 +
web/source/css/profile.css | 220 +++++-----
web/source/css/status.css | 252 ++++++-----
web/source/css/thread.css | 56 +++
web/source/frontend/index.js | 4 +
web/source/frontend/prism.js | 42 ++
web/source/settings/admin/reports/detail.jsx | 42 +-
web/source/settings/admin/reports/index.jsx | 2 +-
web/source/settings/components/fake-profile.jsx | 29 +-
web/source/settings/components/fake-toot.jsx | 33 +-
web/source/settings/style.css | 89 +++-
web/template/404.tmpl | 40 +-
web/template/about.tmpl | 220 ++++++----
web/template/authorize.tmpl | 44 +-
web/template/confirmed.tmpl | 13 +-
web/template/domain-blocklist.tmpl | 62 +--
web/template/error.tmpl | 22 +-
web/template/finalize.tmpl | 59 ++-
web/template/footer.tmpl | 46 --
web/template/frontend.tmpl | 7 +-
web/template/header.tmpl | 122 ------
web/template/index.tmpl | 74 +---
web/template/index_apps.tmpl | 115 +++++
web/template/oob.tmpl | 14 +-
web/template/page.tmpl | 85 ++++
web/template/page_footer.tmpl | 67 +++
web/template/page_header.tmpl | 72 +++
web/template/page_ogmeta.tmpl | 57 +++
web/template/page_stylesheets.tmpl | 41 ++
web/template/profile.tmpl | 242 +++++-----
web/template/profile_fields.tmpl | 32 ++
web/template/sign-in.tmpl | 10 +-
web/template/status.tmpl | 154 +++----
web/template/status_attachments.tmpl | 184 +++++---
web/template/status_attributes.tmpl | 55 +++
web/template/status_header.tmpl | 56 +++
web/template/status_info.tmpl | 74 ++++
web/template/status_poll.tmpl | 105 +++--
web/template/tag.tmpl | 16 +-
web/template/thread.tmpl | 59 ++-
81 files changed, 3566 insertions(+), 2040 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
create mode 100644 web/source/css/about.css
create mode 100644 web/source/css/page.css
create mode 100644 web/source/css/prism.css
create mode 100644 web/source/css/thread.css
create mode 100644 web/source/frontend/prism.js
delete mode 100644 web/template/footer.tmpl
delete mode 100644 web/template/header.tmpl
create mode 100644 web/template/index_apps.tmpl
create mode 100644 web/template/page.tmpl
create mode 100644 web/template/page_footer.tmpl
create mode 100644 web/template/page_header.tmpl
create mode 100644 web/template/page_ogmeta.tmpl
create mode 100644 web/template/page_stylesheets.tmpl
create mode 100644 web/template/profile_fields.tmpl
create mode 100644 web/template/status_attributes.tmpl
create mode 100644 web/template/status_header.tmpl
create mode 100644 web/template/status_info.tmpl
diff --git a/.goreleaser.yml b/.goreleaser.yml
index a49bb32e8..8be51ded4 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -27,6 +27,8 @@ builds:
- static_build
- kvformat
- timetzdata
+ - >-
+ {{ if and (index .Env "DEBUG") (.Env.DEBUG) }}debugenv{{ end }}
env:
- CGO_ENABLED=0
goos:
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!
+`)
+
+ 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(``)
+ },
+ )
+
+ // 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(``)
+ },
+ )
+}
+
+// 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(``)
+ // 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, "
- GoToSocial only serves Public statuses via the web.
- If you reached this page by clicking on a status link,
- it's possible that the status is not Public, has been
- deleted by the author, you don't have permission to see
- it, or it just doesn't exist at all.
-
-
- If you believe this 404 was an error, you can contact
- the instance admin. Provide them with the following request
- Request ID: {{.requestID}}.
-
-
+
+
404: Not Found
+
+ GoToSocial only serves Public statuses via the web.
+
+
+ If you reached this page by clicking on a status link,
+ it's likely that the status is not Public. You can try
+ entering the status URL in your client's search bar,
+ to view the status from your account. If that doesn't
+ work, it's possible that the status has been deleted by
+ the author, you don't have permission to view it, or it
+ doesn't exist at all.
+
+
+ If you believe this 404 was an error, you can contact
+ the instance admin. Provide them with the following
+ request ID: {{- .requestID -}}.
+
+
-
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- end }}
\ No newline at end of file
diff --git a/web/template/about.tmpl b/web/template/about.tmpl
index 6579f492f..a23dfa953 100644
--- a/web/template/about.tmpl
+++ b/web/template/about.tmpl
@@ -17,105 +17,133 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
-
-
-
No description has yet been set for this instance.
+{{- end }}
+{{- end -}}
-
-
Languages
-
- {{ if .languages }}
- This instance prefers the following languages:
-
- {{range .languages}}
-
{{.}}
- {{end}}
-
- {{ else }}
- This instance does not have any preferred languages.
- {{ end }}
-
-
+{{- define "registrationLimits" -}}
+{{- if .instance.Registrations -}}
+ Registration is enabled; new signups can be submitted to this instance.
+ {{- if .instance.ApprovalRequired -}}
+ Admin approval is required for new registrations.
+ {{- else -}}
+ Admin approval is not required for registrations; new signups will be automatically approved (pending email confirmation).
+ {{- end -}}
+{{- else -}}
+ Registration is disabled; new signups are currently closed for this instance.
+{{- end -}}
+{{- end -}}
-
+{{- define "customCSSLimits" -}}
+{{- if .instance.Configuration.Accounts.AllowCustomCSS -}}
+Users are allowed to set Custom CSS for their profiles.
+{{- else -}}
+Custom CSS is not enabled for user profiles.
+{{- end -}}
+{{- end -}}
-
-
Rules
-
- {{range .instance.Rules}}
-
{{.Text}}
- {{end}}
-
-
+{{- define "statusLimits" -}}
+Statuses can contain up to
+{{- .instance.Configuration.Statuses.MaxCharacters }} characters, and
+{{- .instance.Configuration.Statuses.MaxMediaAttachments }} media attachments.
+{{- end -}}
-
-
Features
-
-
- Registration is
- {{if .instance.Registrations}}
- enabled{{if .instance.ApprovalRequired}}, but requires admin approval{{end}}.
- {{else}}
- disabled.
- {{end}}
-
- Users are allowed to set Custom CSS for their profiles.
-
- {{end}}
-
- Toots can contain up to {{.instance.Configuration.Statuses.MaxCharacters}} characters and
- {{.instance.Configuration.Statuses.MaxMediaAttachments}} media attachments.
-
-
- Polls can have up to {{.instance.Configuration.Polls.MaxOptions}} options, with
- {{.instance.Configuration.Polls.MaxCharactersPerOption}} characters each.
-
-
-
+{{- define "pollLimits" -}}
+Polls can have up to
+{{- .instance.Configuration.Polls.MaxOptions }} options, with
+{{- .instance.Configuration.Polls.MaxCharactersPerOption }} characters per option.
+{{- end -}}
-
-
Moderated servers
-
- ActivityPub instances exchange (federate) data with other instances, including accounts and toots.
- This can be prevented for specific domains by suspending them. None of their content is stored,
- and interaction with their users is blocked both ways.
- {{if .blocklistExposed}}
- View the list of suspended domains
- {{else}}
- This instance does not publically share this list.
- {{end}}
-
This instance has not yet set a contact email address.
+ {{- end }}
+
+
+
Languages
+ {{- if .languages }}
+
This instance prefers the following languages:
+
+ {{- range .languages }}
+
{{- . -}}
+ {{- end }}
+
+ {{- else }}
+
This instance does not have any preferred languages.
+ {{- end }}
+
+
+
Instance Rules
+
This instance has the following rules:
+ {{- if .instance.Rules }}
+
+ {{- range .instance.Rules }}
+
{{- .Text -}}
+ {{- end }}
+
+ {{- else }}
+
This instance has not yet set any rules.
+ {{- end }}
+
+
+
Instance Features
+
+
{{- template "registrationLimits" . -}}
+
{{- template "customCSSLimits" . -}}
+
{{- template "statusLimits" . -}}
+
{{- template "pollLimits" . -}}
+
+
+
+
Moderated servers
+
+ ActivityPub instances federate with other instances by exchanging data with them over the network.
+ Exchanged data includes things like accounts, statuses, likes, boosts, and media attachments.
+ This exchange of data can prevented for instances on specific domains via a domain block created
+ by an instance admin. When an instance is domain blocked by another instance:
+
+
+
Any existing data from the blocked instance is deleted from the storage of the instance doing the blocking.
+
Interaction between the two instances is cut off in both directions; neither instance can interact with the other.
+
No new data from the blocked instance will be created on the instance that blocks it.
+
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- end }}
\ No newline at end of file
diff --git a/web/template/authorize.tmpl b/web/template/authorize.tmpl
index ada078968..9be094137 100644
--- a/web/template/authorize.tmpl
+++ b/web/template/authorize.tmpl
@@ -17,26 +17,24 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
-
-
-
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- with . }}
+
+
+
+{{- end }}
\ No newline at end of file
diff --git a/web/template/confirmed.tmpl b/web/template/confirmed.tmpl
index 3cf5b7ac9..c1633a8fb 100644
--- a/web/template/confirmed.tmpl
+++ b/web/template/confirmed.tmpl
@@ -17,12 +17,11 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
+{{- with . }}
-
-
Email Address Confirmed
-
Thanks {{.username}}! Your email address {{.email}} has been confirmed.
-
+
+
Email Address Confirmed
+
Thanks {{ .username -}}! Your email address {{- .email -}} has been confirmed.
+
-
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- end }}
\ No newline at end of file
diff --git a/web/template/domain-blocklist.tmpl b/web/template/domain-blocklist.tmpl
index def1b990e..9a21796f9 100644
--- a/web/template/domain-blocklist.tmpl
+++ b/web/template/domain-blocklist.tmpl
@@ -17,36 +17,36 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
+{{- with . }}
-
-
Suspended Instances
-
- The following list of domains have been suspended by the administrator(s) of this server.
-
-
- All current and future accounts on these instances are blocked, and no more data is federated to the remote
- servers.
- This extends to subdomains, so an entry for 'example.com' includes 'social.example.com' as well.
-
+ The following list of domains have been suspended
+ by the administrator(s) of this server.
+
+
+ All current and future accounts on these instances are
+ blocked, and no more data is federated to the remote servers.
+ This extends to subdomains, so an entry for 'example.com'
+ includes 'social.example.com' as well.
+
+
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- end }}
\ No newline at end of file
diff --git a/web/template/error.tmpl b/web/template/error.tmpl
index dc0713e43..816062e27 100644
--- a/web/template/error.tmpl
+++ b/web/template/error.tmpl
@@ -17,16 +17,16 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
+{{- with . }}
-
-
An error occured:
-
{{.error}}
- {{if .requestID}}
-
- Request ID:{{.requestID}}
-
- {{end}}
-
+
+
An error occured:
+
{{- .error -}}
+ {{- if .requestID }}
+
+ Request ID:{{- .requestID -}}
+
+ {{- end }}
+
-{{ template "footer.tmpl" .}}
\ No newline at end of file
+{{- end }}
\ No newline at end of file
diff --git a/web/template/finalize.tmpl b/web/template/finalize.tmpl
index e0d880db7..56ab677e5 100644
--- a/web/template/finalize.tmpl
+++ b/web/template/finalize.tmpl
@@ -17,34 +17,31 @@
// along with this program. If not, see .
*/ -}}
-{{ template "header.tmpl" .}}
-
-
-
-{{ template "footer.tmpl" .}}
+{{- with . }}
+
+
+
+{{- end }}
\ No newline at end of file
diff --git a/web/template/footer.tmpl b/web/template/footer.tmpl
deleted file mode 100644
index 028a27ffb..000000000
--- a/web/template/footer.tmpl
+++ /dev/null
@@ -1,46 +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 .
-*/ -}}
-
-
-
-
-
- {{ if .javascript }}
- {{ range .javascript }}
-
- {{ end }}
- {{ end }}
-