Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add global instance CSS customization setting #3352

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/admin/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions docs/api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,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
Expand Down Expand Up @@ -1725,6 +1729,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
Expand Down
Binary file modified docs/assets/admin-settings-instance.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions internal/api/client/instance/instancepatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
2 changes: 2 additions & 0 deletions internal/api/model/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/api/model/instancev1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/api/model/instancev2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// GoToSocial
// Copyright (C) GoToSocial Authors [email protected]
// 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)
}
}
1 change: 1 addition & 0 deletions internal/gtsmodel/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/processing/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/typeutils/internaltofrontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,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,
Expand Down Expand Up @@ -1644,6 +1645,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),
Expand Down
10 changes: 10 additions & 0 deletions internal/validate/formvalidation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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., 2-30 characters,
// a-zA-Z, numbers, and underscores.
Expand Down
2 changes: 1 addition & 1 deletion internal/web/about.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 3 additions & 2 deletions internal/web/confirmemail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions internal/web/customcss.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
2 changes: 1 addition & 1 deletion internal/web/domain-blocklist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/web/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}

Expand Down
3 changes: 2 additions & 1 deletion internal/web/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -142,6 +142,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
cssStatus,
cssThread,
cssProfile,
instanceCustomCSSPath,
}...,
)

Expand Down
1 change: 1 addition & 0 deletions internal/web/settings-panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down
7 changes: 4 additions & 3 deletions internal/web/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/web/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}

Expand Down
3 changes: 2 additions & 1 deletion internal/web/thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -131,6 +131,7 @@ func (m *Module) threadGETHandler(c *gin.Context) {
if theme := targetAccount.Theme; theme != "" {
stylesheets = append(
stylesheets,
instanceCustomCSSPath,
themesPathPrefix+"/"+theme,
)
}
Expand Down
30 changes: 16 additions & 14 deletions internal/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,21 @@ import (
)

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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions web/source/settings/lib/types/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions web/source/settings/views/admin/instance/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
const shortDescLimit = 500;
const descLimit = 5000;
const termsLimit = 5000;

const form = {
title: useTextInput("title", {
source: instance,
Expand All @@ -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.
Expand Down Expand Up @@ -191,7 +195,16 @@ 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>
);
}
}