[feature] User-selectable preset CSS themes for accounts (#2777)

* [feature] User-selectable preset themes

* docs, more theme stuff

* lint, tests

* fix css name

* correct some little issues

* add another theme

* fix poll background

* okay last theme i swear

* make retrieval of apimodel themes more conventional

* preallocate stylesheet slices
This commit is contained in:
tobi 2024-03-25 18:32:24 +01:00 committed by GitHub
commit 8953f57d88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1230 additions and 28 deletions

View file

@ -55,6 +55,7 @@ const (
VerifyPath = BasePath + "/verify_credentials"
MovePath = BasePath + "/move"
AliasPath = BasePath + "/alias"
ThemesPath = BasePath + "/themes"
)
type Module struct {
@ -114,4 +115,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
// migration handlers
attachHandler(http.MethodPost, AliasPath, m.AccountAliasPOSTHandler)
attachHandler(http.MethodPost, MovePath, m.AccountMovePOSTHandler)
// account themes
attachHandler(http.MethodGet, ThemesPath, m.AccountThemesGETHandler)
}

View file

@ -309,6 +309,7 @@ func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest,
form.Source.Language == nil &&
form.Source.StatusContentType == nil &&
form.FieldsAttributes == nil &&
form.Theme == nil &&
form.CustomCSS == nil &&
form.EnableRSS == nil) {
return nil, errors.New("empty form submitted")

View file

@ -69,7 +69,7 @@ import (
// '500':
// description: internal server error
func (m *Module) AccountListsGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, false, false, false, false)
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -134,7 +134,7 @@ import (
// '500':
// description: internal server error
func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
authed, err := oauth.Authed(c, false, false, false, false)
authed, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return

View file

@ -0,0 +1,77 @@
// 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 accounts
import (
"net/http"
"github.com/gin-gonic/gin"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// AccountThemesGETHandler swagger:operation GET /api/v1/accounts/themes accountThemes
//
// See preset CSS themes available to accounts on this instance.
//
// ---
// tags:
// - accounts
//
// produces:
// - application/json
//
// security:
// - OAuth2 Bearer:
// - read:accounts
//
// responses:
// '200':
// name: statuses
// description: Array of themes.
// schema:
// type: array
// items:
// "$ref": "#/definitions/theme"
// '400':
// description: bad request
// '401':
// description: unauthorized
// '404':
// description: not found
// '406':
// description: not acceptable
// '500':
// description: internal server error
func (m *Module) AccountThemesGETHandler(c *gin.Context) {
_, err := oauth.Authed(c, true, true, true, true)
if err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
return
}
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
// Retrieve available themes.
themes := m.processor.Account().ThemesGet()
apiutil.JSON(c, http.StatusOK, themes)
}

View file

@ -89,6 +89,8 @@ type Account struct {
MuteExpiresAt string `json:"mute_expires_at,omitempty"`
// Extra profile information. Shown only if the requester owns the account being requested.
Source *Source `json:"source,omitempty"`
// Filename of user-selected CSS theme to include when rendering this account's profile or statuses. Eg., `blurple-light.css`.
Theme string `json:"theme,omitempty"`
// CustomCSS to include when rendering this account's profile or statuses.
CustomCSS string `json:"custom_css,omitempty"`
// Account has enabled RSS feed.
@ -162,7 +164,11 @@ type UpdateCredentialsRequest struct {
FieldsAttributes *[]UpdateField `form:"fields_attributes" json:"-"`
// Profile metadata names and values, parsed from JSON.
JSONFieldsAttributes *map[string]UpdateField `form:"-" json:"fields_attributes"`
// Theme file name to be used when rendering this account's profile or statuses.
// Use empty string to unset.
Theme *string `form:"theme" json:"theme"`
// Custom CSS to be included when rendering this account's profile or statuses.
// Use empty string to unset.
CustomCSS *string `form:"custom_css" json:"custom_css"`
// Enable RSS feed of public toots for this account at /@[username]/feed.rss
EnableRSS *bool `form:"enable_rss" json:"enable_rss"`

View file

@ -0,0 +1,32 @@
// 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 model
// Theme represents one user-selectable preset CSS theme.
//
// swagger:model theme
type Theme struct {
// User-facing title of this theme.
Title string `json:"title"`
// User-facing description of this theme.
Description string `json:"description"`
// FileName of this theme in the themes directory.
FileName string `json:"file_name"`
}

View file

@ -0,0 +1,55 @@
// 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 migrations
import (
"context"
"strings"
"github.com/uptrace/bun"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
// Add theme to account settings table.
_, err := db.ExecContext(ctx,
"ALTER TABLE ? ADD COLUMN ? TEXT",
bun.Ident("account_settings"), bun.Ident("theme"),
)
if err != nil {
e := err.Error()
if !(strings.Contains(e, "already exists") ||
strings.Contains(e, "duplicate column name") ||
strings.Contains(e, "SQLSTATE 42701")) {
return err
}
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
return nil
})
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -220,3 +220,17 @@ type Relationship struct {
Endorsed bool // Are you featuring this user on your profile?
Note string // Your note on this account.
}
// Theme represents a user-selected
// CSS theme for an account.
type Theme struct {
// User-facing title of this theme.
Title string
// User-facing description of this theme.
Description string
// FileName of this theme in the themes
// directory (eg., `light-blurple.css`).
FileName string
}

View file

@ -29,6 +29,7 @@ type AccountSettings struct {
Sensitive *bool `bun:",nullzero,notnull,default:false"` // Set posts from this account to sensitive by default?
Language string `bun:",nullzero,notnull,default:'en'"` // What language does this account post in?
StatusContentType string `bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts).
Theme string `bun:",nullzero"` // Preset CSS theme filename selected by this Account (empty string if nothing set).
CustomCSS string `bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses.
EnableRSS *bool `bun:",nullzero,notnull,default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
HideCollections *bool `bun:",nullzero,notnull,default:false"` // Hide this account's followers/following collections.

View file

@ -44,6 +44,7 @@ type Processor struct {
formatter *text.Formatter
federator *federation.Federator
parseMention gtsmodel.ParseMentionFunc
themes *Themes
}
// New returns a new account processor.
@ -67,5 +68,6 @@ func New(
formatter: text.NewFormatter(state.DB),
federator: federator,
parseMention: parseMention,
themes: PopulateThemes(),
}
}

View file

@ -0,0 +1,151 @@
// 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 account
import (
"cmp"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"codeberg.org/gruf/go-bytesize"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
)
var (
themeTitleRegex = regexp.MustCompile(`(?m)^\ *theme-title:(.*)$`)
themeDescriptionRegex = regexp.MustCompile(`(?m)^\ *theme-description:(.*)$`)
)
// GetThemes returns available account css themes.
func (p *Processor) ThemesGet() []apimodel.Theme {
return p.converter.ThemesToAPIThemes(p.themes.SortedByTitle)
}
// Themes represents an in-memory
// storage structure for themes.
type Themes struct {
// Themes sorted alphabetically
// by title (case insensitive).
SortedByTitle []*gtsmodel.Theme
// ByFileName contains themes retrievable
// by their filename eg., `light-blurple.css`.
ByFileName map[string]*gtsmodel.Theme
}
// PopulateThemes parses available account CSS
// themes from the web assets themes directory.
func PopulateThemes() *Themes {
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
if err != nil {
log.Panicf(nil, "error getting abs path for web assets: %v", err)
}
themesAbsFilePath := filepath.Join(webAssetsAbsFilePath, "themes")
themesFiles, err := os.ReadDir(themesAbsFilePath)
if err != nil {
log.Warnf(nil, "error reading themes at %s: %v", themesAbsFilePath, err)
return nil
}
themes := &Themes{
ByFileName: make(map[string]*gtsmodel.Theme),
}
for _, f := range themesFiles {
// Ignore nested directories.
if f.IsDir() {
continue
}
// Ignore weird files.
info, err := f.Info()
if err != nil {
continue
}
// Ignore really big files.
if info.Size() > int64(bytesize.MiB) {
continue
}
// Get just the name of the
// file, eg `blurple-light.css`.
fileName := f.Name()
// Get just the `.css` part.
extensionWithDot := filepath.Ext(fileName)
// Remove any leading `.`
extension := strings.TrimPrefix(extensionWithDot, ".")
// Ignore non-css files.
if extension != "css" {
continue
}
// Load the file contents.
path := filepath.Join(themesAbsFilePath, fileName)
contents, err := os.ReadFile(path)
if err != nil {
log.Warnf(nil, "error reading css theme at %s: %v", path, err)
continue
}
// Try to parse a title and description
// for this theme from the file itself.
var themeTitle string
titleMatches := themeTitleRegex.FindSubmatch(contents)
if len(titleMatches) == 2 {
themeTitle = strings.TrimSpace(string(titleMatches[1]))
} else {
// Fall back to file name
// without `.css` suffix.
themeTitle = strings.TrimSuffix(fileName, ".css")
}
var themeDescription string
descMatches := themeDescriptionRegex.FindSubmatch(contents)
if len(descMatches) == 2 {
themeDescription = strings.TrimSpace(string(descMatches[1]))
}
theme := &gtsmodel.Theme{
Title: themeTitle,
Description: themeDescription,
FileName: fileName,
}
themes.SortedByTitle = append(themes.SortedByTitle, theme)
themes.ByFileName[fileName] = theme
}
// Sort themes alphabetically
// by title (case insensitive).
slices.SortFunc(themes.SortedByTitle, func(a, b *gtsmodel.Theme) int {
return cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
})
return themes
}

View file

@ -0,0 +1,52 @@
// 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 account_test
import (
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
)
type ThemesTestSuite struct {
AccountStandardTestSuite
}
func (suite *ThemesTestSuite) TestPopulateThemes() {
config.SetWebAssetBaseDir("../../../web/assets")
themes := account.PopulateThemes()
if themes == nil {
suite.FailNow("themes was nil")
}
suite.NotEmpty(themes.SortedByTitle)
theme := themes.ByFileName["blurple-light.css"]
if theme == nil {
suite.FailNow("theme was nil")
}
suite.Equal("Blurple (light)", theme.Title)
suite.Equal("Official light blurple theme", theme.Description)
suite.Equal("blurple-light.css", theme.FileName)
}
func TestThemesTestSuite(t *testing.T) {
suite.Run(t, new(ThemesTestSuite))
}

View file

@ -256,6 +256,22 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, form
}
}
if form.Theme != nil {
theme := *form.Theme
if theme == "" {
// Empty is easy, just clear this.
account.Settings.Theme = ""
} else {
// Theme was provided, check
// against known available themes.
if _, ok := p.themes.ByFileName[theme]; !ok {
err := fmt.Errorf("theme %s not available on this instance, see /api/v1/accounts/themes for available themes", theme)
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
account.Settings.Theme = theme
}
}
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.CustomCSS(customCSS); err != nil {

View file

@ -170,12 +170,13 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
// Bits that vary between remote + local accounts:
// - Account (acct) string.
// - Role.
// - Settings things (enableRSS, customCSS).
// - Settings things (enableRSS, theme, customCSS).
var (
acct string
role *apimodel.AccountRole
enableRSS bool
theme string
customCSS string
)
@ -208,6 +209,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
}
enableRSS = *a.Settings.EnableRSS
theme = a.Settings.Theme
customCSS = a.Settings.CustomCSS
}
@ -272,6 +274,7 @@ func (c *Converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
Emojis: apiEmojis,
Fields: fields,
Suspended: !a.SuspendedAt.IsZero(),
Theme: theme,
CustomCSS: customCSS,
EnableRSS: enableRSS,
Role: role,
@ -1771,3 +1774,16 @@ func (c *Converter) convertTagsToAPITags(ctx context.Context, tags []*gtsmodel.T
return apiTags, errs.Combine()
}
// ThemesToAPIThemes converts a slice of gtsmodel Themes into apimodel Themes.
func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme {
apiThemes := make([]apimodel.Theme, len(themes))
for i, theme := range themes {
apiThemes[i] = apimodel.Theme{
Title: theme.Title,
Description: theme.Description,
FileName: theme.FileName,
}
}
return apiThemes
}

View file

@ -140,16 +140,40 @@ func (m *Module) profileGETHandler(c *gin.Context) {
return
}
// Prepare stylesheets for profile.
stylesheets := make([]string, 0, 6)
// Basic profile stylesheets.
stylesheets = append(
stylesheets,
[]string{
cssFA,
cssStatus,
cssThread,
cssProfile,
}...,
)
// User-selected theme if set.
if theme := targetAccount.Theme; theme != "" {
stylesheets = append(
stylesheets,
themesPathPrefix+"/"+theme,
)
}
// Custom CSS for this user last in cascade.
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},
Template: "profile.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance).WithAccount(targetAccount),
Stylesheets: stylesheets,
Javascript: []string{jsFrontend},
Extra: map[string]any{
"account": targetAccount,
"rssFeed": rssFeed,

View file

@ -138,16 +138,39 @@ func (m *Module) threadGETHandler(c *gin.Context) {
return
}
// Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5)
// Basic thread stylesheets.
stylesheets = append(
stylesheets,
[]string{
cssFA,
cssStatus,
cssThread,
}...,
)
// User-selected theme if set.
if theme := targetAccount.Theme; theme != "" {
stylesheets = append(
stylesheets,
themesPathPrefix+"/"+theme,
)
}
// Custom CSS for this user last in cascade.
stylesheets = append(
stylesheets,
"/@"+targetAccount.Username+"/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},
Template: "thread.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance).WithStatus(status),
Stylesheets: stylesheets,
Javascript: []string{jsFrontend},
Extra: map[string]any{
"status": status,
"context": context,

View file

@ -44,6 +44,7 @@ const (
rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist"
themesPathPrefix = assetsPathPrefix + "/themes"
settingsPathPrefix = "/settings"
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"