[feature] Add global instance CSS customization setting (#3352)

Allow instance admins to add custom CSS that will affect
every page of their instance.

This is done with a new CustomCSS instance setting that
works pretty much exactly like the Users CustomCSS property.
This custom CSS is then requested for every page load.
User styles/themes take precedence over this CSS.

Co-authored-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
Victor Dyotte 2024-12-02 06:24:48 -05:00 committed by GitHub
parent dce85a2b7a
commit 9609c4550d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 158 additions and 27 deletions

View file

@ -167,3 +167,11 @@ Links to the set contact account and/or email address will appear on the footer
The selected **contact user** must be an active (not suspended) admin and/or moderator on the instance.
If you're on a single-user instance and you give admin privileges to your main account, you can just fill in your own username here; you don't need to make a separate admin account just for this.
### Instance Custom CSS
custom CSS allows you to further customize the way your instance looks when visited through a browser.
This custom CSS will be applied to all pages of your instance. Users themes and CSS still take precedence over this customization.
See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS for your instance.

View file

@ -1570,6 +1570,10 @@ definitions:
$ref: '#/definitions/instanceV1Configuration'
contact_account:
$ref: '#/definitions/account'
custom_css:
description: Custom CSS for the instance.
type: string
x-go-name: CustomCSS
debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean
@ -1750,6 +1754,10 @@ definitions:
$ref: '#/definitions/instanceV2Configuration'
contact:
$ref: '#/definitions/instanceV2Contact'
custom_css:
description: Instance Custom Css
type: string
x-go-name: CustomCSS
debug:
description: Whether or not instance is running in DEBUG mode. Omitted if false.
type: boolean

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 229 KiB

View file

@ -175,6 +175,7 @@ func validateInstanceUpdate(form *apimodel.InstanceSettingsUpdateRequest) error
form.ContactEmail == nil &&
form.ShortDescription == nil &&
form.Description == nil &&
form.CustomCSS == nil &&
form.Terms == nil &&
form.Avatar == nil &&
form.AvatarDescription == nil &&

View file

@ -33,6 +33,8 @@ type InstanceSettingsUpdateRequest struct {
ShortDescription *string `form:"short_description" json:"short_description" xml:"short_description"`
// Longer description of the instance, max 5,000 chars. HTML formatting accepted.
Description *string `form:"description" json:"description" xml:"description"`
// Custom CSS for the instance.
CustomCSS *string `form:"custom_css" json:"custom_css,omitempty" xml:"custom_css"`
// Terms and conditions of the instance, max 5,000 chars. HTML formatting accepted.
Terms *string `form:"terms" json:"terms" xml:"terms"`
// Image to use as the instance thumbnail.

View file

@ -38,6 +38,8 @@ type InstanceV1 struct {
//
// This should be displayed on the 'about' page for an instance.
Description string `json:"description"`
// Custom CSS for the instance.
CustomCSS string `json:"custom_css,omitempty"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// A shorter description of the instance.

View file

@ -53,6 +53,8 @@ type InstanceV2 struct {
Description string `json:"description"`
// Raw (unparsed) version of description.
DescriptionText string `json:"description_text,omitempty"`
// Instance Custom Css
CustomCSS string `json:"custom_css,omitempty"`
// Basic anonymous usage data for this instance.
Usage InstanceV2Usage `json:"usage"`
// An image used to represent this instance.

View file

@ -0,0 +1,44 @@
// 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 {
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TEXT", bun.Ident("instances"), bun.Ident("custom_css"))
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
return err
}
return nil
}
down := func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, "ALTER TABLE ? DROP COLUMN ?", bun.Ident("instances"), bun.Ident("custom_css"))
return err
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -34,6 +34,7 @@ type Instance struct {
ShortDescriptionText string `bun:""` // Raw text version of short description (before parsing).
Description string `bun:""` // Longer description of this instance.
DescriptionText string `bun:""` // Raw text version of long description (before parsing).
CustomCSS string `bun:",nullzero"` // Custom CSS for the instance.
Terms string `bun:""` // Terms and conditions of this instance.
TermsText string `bun:""` // Raw text version of terms (before parsing).
ContactEmail string `bun:""` // Contact email address for this instance

View file

@ -227,6 +227,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...)
}
// validate & update site custom css if it's set on the form
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.InstanceCustomCSS(customCSS); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
instance.CustomCSS = text.SanitizeToPlaintext(customCSS)
columns = append(columns, []string{"custom_css"}...)
}
// Validate & update site
// terms if set on the form.
if form.Terms != nil {

View file

@ -1534,6 +1534,7 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
Title: i.Title,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
ShortDescription: i.ShortDescription,
ShortDescriptionText: i.ShortDescriptionText,
Email: i.ContactEmail,
@ -1674,6 +1675,7 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
SourceURL: instanceSourceURL,
Description: i.Description,
DescriptionText: i.DescriptionText,
CustomCSS: i.CustomCSS,
Usage: apimodel.InstanceV2Usage{}, // todo: not implemented
Languages: config.GetInstanceLanguages().TagStrs(),
Rules: c.InstanceRulesToAPIRules(i.Rules),

View file

@ -189,6 +189,16 @@ func CustomCSS(customCSS string) error {
return nil
}
func InstanceCustomCSS(customCSS string) error {
maximumCustomCSSLength := config.GetAccountsCustomCSSLength()
if length := len([]rune(customCSS)); length > maximumCustomCSSLength {
return fmt.Errorf("custom_css must be less than %d characters, but submitted custom_css was %d characters", maximumCustomCSSLength, length)
}
return nil
}
// EmojiShortcode just runs the given shortcode through the regular expression
// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 1-30 characters,
// a-zA-Z, numbers, and underscores.

View file

@ -54,7 +54,7 @@ func (m *Module) aboutGETHandler(c *gin.Context) {
Template: "about.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout},
Stylesheets: []string{cssAbout, instanceCustomCSSPath},
Extra: map[string]any{
"showStrap": true,
"blocklistExposed": config.GetInstanceExposeSuspendedWeb(),

View file

@ -127,8 +127,9 @@ func (m *Module) confirmEmailPOSTHandler(c *gin.Context) {
// Serve page informing user that their
// email address is now confirmed.
page := apiutil.WebPage{
Template: "confirmed_email.tmpl",
Instance: instance,
Template: "confirmed_email.tmpl",
Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
Extra: map[string]any{
"email": user.Email,
"username": user.Account.Username,

View file

@ -55,3 +55,22 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS))
}
func (m *Module) instanceCustomCSSGETHandler(c *gin.Context) {
if _, err := apiutil.NegotiateAccept(c, apiutil.TextCSS); err != nil {
apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
return
}
instanceV1, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
if errWithCode != nil {
apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return
}
instanceCustomCSS := instanceV1.CustomCSS
c.Header(cacheControlHeader, cacheControlNoCache)
c.Data(http.StatusOK, textCSSUTF8, []byte(instanceCustomCSS))
}

View file

@ -67,7 +67,7 @@ func (m *Module) domainBlockListGETHandler(c *gin.Context) {
Template: "domain-blocklist.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA},
Stylesheets: []string{cssFA, instanceCustomCSSPath},
Javascript: []string{jsFrontend},
Extra: map[string]any{"blocklist": domainBlocks},
}

View file

@ -59,7 +59,7 @@ func (m *Module) indexHandler(c *gin.Context) {
Template: "index.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssAbout, cssIndex},
Stylesheets: []string{cssAbout, cssIndex, instanceCustomCSSPath},
Extra: map[string]any{"showStrap": true},
}

View file

@ -132,7 +132,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
}
// Prepare stylesheets for profile.
stylesheets := make([]string, 0, 6)
stylesheets := make([]string, 0, 7)
// Basic profile stylesheets.
stylesheets = append(
@ -142,6 +142,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
cssStatus,
cssThread,
cssProfile,
instanceCustomCSSPath,
}...,
)

View file

@ -53,6 +53,7 @@ func (m *Module) SettingsPanelHandler(c *gin.Context) {
cssProfile, // Used for rendering stub/fake profiles.
cssStatus, // Used for rendering stub/fake statuses.
cssSettings,
instanceCustomCSSPath,
},
Javascript: []string{jsSettings},
}

View file

@ -126,9 +126,10 @@ func (m *Module) signupPOSTHandler(c *gin.Context) {
// Serve a page informing the
// user that they've signed up.
page := apiutil.WebPage{
Template: "signed-up.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Template: "signed-up.tmpl",
Instance: instance,
Stylesheets: []string{instanceCustomCSSPath},
OGMeta: apiutil.OGBase(instance),
Extra: map[string]any{
"email": user.UnconfirmedEmail,
"username": user.Account.Username,

View file

@ -59,7 +59,7 @@ func (m *Module) tagGETHandler(c *gin.Context) {
Template: "tag.tmpl",
Instance: instance,
OGMeta: apiutil.OGBase(instance),
Stylesheets: []string{cssFA, cssThread, cssTag},
Stylesheets: []string{cssFA, cssThread, cssTag, instanceCustomCSSPath},
Extra: map[string]any{"tagName": tagName},
}

View file

@ -115,7 +115,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
}
// Prepare stylesheets for thread.
stylesheets := make([]string, 0, 5)
stylesheets := make([]string, 0, 6)
// Basic thread stylesheets.
stylesheets = append(
@ -131,6 +131,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
if theme := targetAccount.Theme; theme != "" {
stylesheets = append(
stylesheets,
instanceCustomCSSPath,
themesPathPrefix+"/"+theme,
)
}

View file

@ -36,20 +36,21 @@
)
const (
confirmEmailPath = "/" + uris.ConfirmEmailPath
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"
rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist"
themesPathPrefix = assetsPathPrefix + "/themes"
settingsPathPrefix = "/settings"
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin"
signupPath = "/signup"
confirmEmailPath = "/" + uris.ConfirmEmailPath
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"
instanceCustomCSSPath = "/custom.css"
rssFeedPath = profileGroupPath + "/feed.rss"
assetsPathPrefix = "/assets"
distPathPrefix = assetsPathPrefix + "/dist"
themesPathPrefix = assetsPathPrefix + "/themes"
settingsPathPrefix = "/settings"
settingsPanelGlob = settingsPathPrefix + "/*panel"
userPanelPath = settingsPathPrefix + "/user"
adminPanelPath = settingsPathPrefix + "/admin"
signupPath = "/signup"
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
@ -114,6 +115,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
r.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)
r.AttachHandler(http.MethodGet, instanceCustomCSSPath, m.instanceCustomCSSGETHandler)
r.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler)
r.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler)
r.AttachHandler(http.MethodPost, confirmEmailPath, m.confirmEmailPOSTHandler)

View file

@ -25,6 +25,7 @@ export interface InstanceV1 {
description_text?: string;
short_description: string;
short_description_text?: string;
custom_css: string;
email: string;
version: string;
debug?: boolean;

View file

@ -66,6 +66,10 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
valueSelector: (s: InstanceV1) => s.description_text,
validator: (val: string) => val.length <= descLimit ? "" : `Instance description is ${val.length} characters; must be ${descLimit} characters or less`
}),
customCSS: useTextInput("custom_css", {
source: instance,
valueSelector: (s: InstanceV1) => s.custom_css
}),
terms: useTextInput("terms", {
source: instance,
// Select "raw" text version of parsed field for editing.
@ -191,6 +195,15 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
type="email"
/>
<TextArea
field={form.customCSS}
label={"Custom CSS"}
className="monospace"
rows={8}
autoCapitalize="none"
spellCheck="false"
/>
<MutationButton label="Save" result={result} disabled={false} />
</form>
);