[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
commit 0ff52b71f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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

@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package web
package util
import (
"html"
@ -28,10 +28,10 @@ import (
const maxOGDescriptionLength = 300
// ogMeta represents supported OpenGraph Meta tags
// OGMeta represents supported OpenGraph Meta tags
//
// see eg https://ogp.me/
type ogMeta struct {
type OGMeta struct {
// vanilla og tags
Title string // og:title
Type string // og:type
@ -56,23 +56,23 @@ type ogMeta struct {
ProfileUsername string // profile:username
}
// ogBase returns an *ogMeta suitable for serving at
// 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 {
func OGBase(instance *apimodel.InstanceV1) *OGMeta {
var locale string
if len(instance.Languages) > 0 {
locale = instance.Languages[0]
}
og := &ogMeta{
og := &OGMeta{
Title: text.SanitizeToPlaintext(instance.Title) + " - GoToSocial",
Type: "website",
Locale: locale,
URL: instance.URI,
SiteName: instance.AccountDomain,
Description: parseDescription(instance.ShortDescription),
Description: ParseDescription(instance.ShortDescription),
Image: instance.Thumbnail,
ImageAlt: instance.ThumbnailDescription,
@ -81,15 +81,15 @@ func ogBase(instance *apimodel.InstanceV1) *ogMeta {
return og
}
// withAccount uses the given account to build an ogMeta
// 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)
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)
og.Description = ParseDescription(account.Note)
} else {
og.Description = `content="This GoToSocial user hasn't written a bio yet!"`
}
@ -102,11 +102,11 @@ func (og *ogMeta) withAccount(account *apimodel.Account) *ogMeta {
return og
}
// withStatus uses the given status to build an ogMeta
// 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)
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
@ -114,9 +114,9 @@ func (og *ogMeta) withStatus(status *apimodel.Status) *ogMeta {
og.URL = status.URL
switch {
case status.SpoilerText != "":
og.Description = parseDescription("CW: " + status.SpoilerText)
og.Description = ParseDescription("CW: " + status.SpoilerText)
case status.Text != "":
og.Description = parseDescription(status.Text)
og.Description = ParseDescription(status.Text)
default:
og.Description = og.Title
}
@ -147,34 +147,38 @@ func (og *ogMeta) withStatus(status *apimodel.Status) *ogMeta {
return og
}
// parseTitle parses a page title from account and accountDomain
func parseTitle(account *apimodel.Account, accountDomain string) string {
// 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 + ")"
return account.DisplayName + ", " + user
}
// parseDescription returns a string description which is
// ParseDescription returns a string description which is
// safe to use as a template.HTMLAttr inside templates.
func parseDescription(in string) string {
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 = trim(i, maxOGDescriptionLength)
i = truncate(i, maxOGDescriptionLength)
return `content="` + i + `"`
}
// trim strings trim s to specified length
func trim(s string, length int) string {
if len(s) < length {
// 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 s[:length]
return string(r[:l]) + "..."
}

View file

@ -15,7 +15,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package web
package util
import (
"fmt"
@ -40,18 +40,18 @@ func (suite *OpenGraphTestSuite) TestParseDescription() {
for _, tt := range tests {
tt := tt
suite.Run(tt.name, func() {
suite.Equal(fmt.Sprintf("content=\"%s\"", tt.exp), parseDescription(tt.in))
suite.Equal(fmt.Sprintf("content=\"%s\"", tt.exp), ParseDescription(tt.in))
})
}
}
func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
baseMeta := ogBase(&apimodel.InstanceV1{
baseMeta := OGBase(&apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
})
accountMeta := baseMeta.withAccount(&apimodel.Account{
accountMeta := baseMeta.WithAccount(&apimodel.Account{
Acct: "example_account",
DisplayName: "example person!!",
URL: "https://example.org/@example_account",
@ -59,8 +59,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
Username: "example_account",
})
suite.EqualValues(ogMeta{
Title: "example person!! (@example_account@example.org)",
suite.EqualValues(OGMeta{
Title: "example person!!, @example_account@example.org",
Type: "profile",
Locale: "en",
URL: "https://example.org/@example_account",
@ -79,12 +79,12 @@ func (suite *OpenGraphTestSuite) TestWithAccountWithNote() {
}
func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
baseMeta := ogBase(&apimodel.InstanceV1{
baseMeta := OGBase(&apimodel.InstanceV1{
AccountDomain: "example.org",
Languages: []string{"en"},
})
accountMeta := baseMeta.withAccount(&apimodel.Account{
accountMeta := baseMeta.WithAccount(&apimodel.Account{
Acct: "example_account",
DisplayName: "example person!!",
URL: "https://example.org/@example_account",
@ -92,8 +92,8 @@ func (suite *OpenGraphTestSuite) TestWithAccountNoNote() {
Username: "example_account",
})
suite.EqualValues(ogMeta{
Title: "example person!! (@example_account@example.org)",
suite.EqualValues(OGMeta{
Title: "example person!!, @example_account@example.org",
Type: "profile",
Locale: "en",
URL: "https://example.org/@example_account",

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

View file

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

View file

@ -39,7 +39,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
fmt.Println(feed)
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: &#34;🐕🐕🐕🐕🐕&#34;</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: &#34;hello world! #welcome ! first post on the instance :rainbow: !&#34;</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: &#34;🐕🐕🐕🐕🐕&#34;</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: &#34;hello world! #welcome ! first post on the instance :rainbow: !&#34;</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" width=\"25\" height=\"25\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
}
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {

View file

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

View file

@ -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,
)
}
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)
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,
)
}
engine.LoadHTMLGlob(filepath.Join(templateBaseDir, "*"))
// 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
}
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
}
// 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(`<i aria-label="Visibility: %v" class="fa fa-%v"></i>`, icon.label, icon.faIcon))
return template.HTML(fmt.Sprintf(
`<i aria-label="Visibility: %s" class="fa fa-%s"></i>`,
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 <img> 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)+)<pre>.*</pre>`, 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 `<pre></pre>` 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,
})
}

View file

@ -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 <http://www.gnu.org/licenses/>.
package router
import (
"html/template"
"testing"
)
func TestOutdentPre(t *testing.T) {
const html = template.HTML(`
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
`)
const expected = template.HTML(`
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
<div class="text">
<div class="content" lang="en">
<p>Here's a bunch of HTML, read it and weep, weep then!</p>
<pre><code class="language-html">&lt;section class=&#34;about-user&#34;&gt;
&lt;div class=&#34;col-header&#34;&gt;
&lt;h2&gt;About&lt;/h2&gt;
&lt;/div&gt;
&lt;div class=&#34;fields&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Fields&lt;/h3&gt;
&lt;dl&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;should you follow me?&lt;/dt&gt;
&lt;dd&gt;maybe!&lt;/dd&gt;
&lt;/div&gt;
&lt;div class=&#34;field&#34;&gt;
&lt;dt&gt;age&lt;/dt&gt;
&lt;dd&gt;120&lt;/dd&gt;
&lt;/div&gt;
&lt;/dl&gt;
&lt;/div&gt;
&lt;div class=&#34;bio&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Bio&lt;/h3&gt;
&lt;p&gt;i post about things that concern me&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;sr-only&#34; role=&#34;group&#34;&gt;
&lt;h3 class=&#34;sr-only&#34;&gt;Stats&lt;/h3&gt;
&lt;span&gt;Joined in Jun, 2022.&lt;/span&gt;
&lt;span&gt;8 posts.&lt;/span&gt;
&lt;span&gt;Followed by 1.&lt;/span&gt;
&lt;span&gt;Following 1.&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&#34;accountstats&#34; aria-hidden=&#34;true&#34;&gt;
&lt;b&gt;Joined&lt;/b&gt;&lt;time datetime=&#34;2022-06-04T13:12:00.000Z&#34;&gt;Jun, 2022&lt;/time&gt;
&lt;b&gt;Posts&lt;/b&gt;&lt;span&gt;8&lt;/span&gt;
&lt;b&gt;Followed by&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;b&gt;Following&lt;/b&gt;&lt;span&gt;1&lt;/span&gt;
&lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>There, hope you liked that!</p>
</div>
</div>
`)
out := outdentPre(html)
if out != expected {
t.Fatalf("unexpected output:\n`%s`\n", out)
}
}

View file

@ -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 `<img>` 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(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" class="emoji" `)
// Lazy load emojis when
// they scroll into view.
buf.WriteString(`loading="lazy" `)
// Limit size to avoid showing
// huge emojis when unstyled.
buf.WriteString(`width="25" height="25"/>`)
},
)
// 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 `<img>` 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(`<img src="`)
buf.WriteString(url)
buf.WriteString(`" title=":`)
buf.WriteString(code)
buf.WriteString(`:" alt=":`)
buf.WriteString(code)
buf.WriteString(`:" `)
// Limit size to avoid showing
// huge emojis in RSS readers.
buf.WriteString(`width="25" height="25"/>`)
},
)
}
// 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(`<img src="`)
buf.WriteString(safeURL)
buf.WriteString(`" title=":`)
buf.WriteString(safeCode)
buf.WriteString(`:" alt=":`)
buf.WriteString(safeCode)
buf.WriteString(`:" class="emoji"/>`)
// 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()
},
)

View file

@ -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, "<pre>", "\n<pre>")
webStatus.Content = strings.ReplaceAll(webStatus.Content, "</pre>", "</pre>\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
}

View file

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

View file

@ -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 <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !", item.Content)
suite.Equal("hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" width=\"25\" height=\"25\"/> !", item.Content)
}
func (suite *InternalToRSSTestSuite) TestStatusToRSSItem3() {

View file

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

View file

@ -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()
// if there's no token in the query, just serve the 404 web handler
token := c.Query(tokenParam)
if token == "" {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGetV1)
return
}
user, errWithCode := m.processor.User().EmailConfirm(ctx, token)
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
instance, err := m.processor.InstanceGetV1(ctx)
if err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), 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
}
c.HTML(http.StatusOK, "confirmed.tmpl", gin.H{
"instance": instance,
"email": user.Email,
"username": user.Account.Username,
})
// If there's no token in the query,
// just serve the 404 web handler.
token := c.Query("token")
if token == "" {
errWithCode := gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound)))
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
user, errWithCode := m.processor.User().EmailConfirm(c.Request.Context(), token)
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, instanceGet)
return
}
page := apiutil.WebPage{
Template: "confirmed.tmpl",
Instance: instance,
Extra: map[string]any{
"email": user.Email,
"username": user.Account.Username,
},
}
apiutil.TemplateWebPage(c, page)
}

View file

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

View file

@ -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)
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, err := m.processor.InstanceGetV1(c.Request.Context())
if err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
return
}
domainBlocks, errWithCode := m.processor.InstancePeersGet(c.Request.Context(), true, false, false)
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
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"},
})
// 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 !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, instanceGet)
return
}
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)
}

View file

@ -18,33 +18,50 @@
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) baseHandler(c *gin.Context) {
// if a landingPageUser is set in the config, redirect to that user's profile
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
}
instance, err := m.processor.InstanceGetV1(c.Request.Context())
if err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
return
page := apiutil.WebPage{
Template: "index.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout, cssIndex},
Extra: map[string]any{"showStrap": true},
}
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"instance": instance,
"ogMeta": ogBase(instance),
"stylesheets": []string{
distPathPrefix + "/index.css",
},
})
apiutil.TemplateWebPage(c, page)
}

View file

@ -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")
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,
},
}
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"},
})
apiutil.TemplateWebPage(c, page)
}
// returnAPAccount returns an ActivityPub representation of

View file

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

View file

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

View file

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

View file

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

View file

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