diff --git a/docs/admin/settings.md b/docs/admin/settings.md
index 0efb5bf45..170d07e6a 100644
--- a/docs/admin/settings.md
+++ b/docs/admin/settings.md
@@ -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.
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index 5c0c2ae3d..a3a79e2fb 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -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
diff --git a/docs/overrides/public/admin-settings-instance.png b/docs/overrides/public/admin-settings-instance.png
index 181a35a7c..1203e8a41 100644
Binary files a/docs/overrides/public/admin-settings-instance.png and b/docs/overrides/public/admin-settings-instance.png differ
diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go
index 64263caf6..5085399eb 100644
--- a/internal/api/client/instance/instancepatch.go
+++ b/internal/api/client/instance/instancepatch.go
@@ -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 &&
diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go
index 5232e8d66..d59424fa5 100644
--- a/internal/api/model/instance.go
+++ b/internal/api/model/instance.go
@@ -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.
diff --git a/internal/api/model/instancev1.go b/internal/api/model/instancev1.go
index efa6d6faa..6dedd04cc 100644
--- a/internal/api/model/instancev1.go
+++ b/internal/api/model/instancev1.go
@@ -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.
diff --git a/internal/api/model/instancev2.go b/internal/api/model/instancev2.go
index 8d6873497..982ed0c63 100644
--- a/internal/api/model/instancev2.go
+++ b/internal/api/model/instancev2.go
@@ -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.
diff --git a/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go b/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go
new file mode 100644
index 000000000..14231927a
--- /dev/null
+++ b/internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go
@@ -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 .
+
+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)
+ }
+}
diff --git a/internal/gtsmodel/instance.go b/internal/gtsmodel/instance.go
index 027d8fba4..97c0268ce 100644
--- a/internal/gtsmodel/instance.go
+++ b/internal/gtsmodel/instance.go
@@ -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
diff --git a/internal/processing/instance.go b/internal/processing/instance.go
index a9be6db1d..fab71b1de 100644
--- a/internal/processing/instance.go
+++ b/internal/processing/instance.go
@@ -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 {
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 750d4eec4..fda59610b 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -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),
diff --git a/internal/validate/formvalidation.go b/internal/validate/formvalidation.go
index 207e8e05e..4de7636a5 100644
--- a/internal/validate/formvalidation.go
+++ b/internal/validate/formvalidation.go
@@ -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.
diff --git a/internal/web/about.go b/internal/web/about.go
index 2bc558962..843dda652 100644
--- a/internal/web/about.go
+++ b/internal/web/about.go
@@ -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(),
diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go
index e512761f4..21028c6c4 100644
--- a/internal/web/confirmemail.go
+++ b/internal/web/confirmemail.go
@@ -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,
diff --git a/internal/web/customcss.go b/internal/web/customcss.go
index b4072f2a7..36ae9de55 100644
--- a/internal/web/customcss.go
+++ b/internal/web/customcss.go
@@ -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))
+}
diff --git a/internal/web/domain-blocklist.go b/internal/web/domain-blocklist.go
index 5d631e0f7..7b6710049 100644
--- a/internal/web/domain-blocklist.go
+++ b/internal/web/domain-blocklist.go
@@ -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},
}
diff --git a/internal/web/index.go b/internal/web/index.go
index 25960cf7f..dd9d80561 100644
--- a/internal/web/index.go
+++ b/internal/web/index.go
@@ -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},
}
diff --git a/internal/web/profile.go b/internal/web/profile.go
index 60157fd19..741dc2a83 100644
--- a/internal/web/profile.go
+++ b/internal/web/profile.go
@@ -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,
}...,
)
diff --git a/internal/web/settings-panel.go b/internal/web/settings-panel.go
index ec8166e95..41cd8666e 100644
--- a/internal/web/settings-panel.go
+++ b/internal/web/settings-panel.go
@@ -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},
}
diff --git a/internal/web/signup.go b/internal/web/signup.go
index a943f3680..64b9f4e2d 100644
--- a/internal/web/signup.go
+++ b/internal/web/signup.go
@@ -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,
diff --git a/internal/web/tag.go b/internal/web/tag.go
index 5c3cd31a6..423000f99 100644
--- a/internal/web/tag.go
+++ b/internal/web/tag.go
@@ -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},
}
diff --git a/internal/web/thread.go b/internal/web/thread.go
index d3ba6ea5e..246c05b97 100644
--- a/internal/web/thread.go
+++ b/internal/web/thread.go
@@ -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,
)
}
diff --git a/internal/web/web.go b/internal/web/web.go
index 185bf7120..35f8f21b0 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -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
@@ -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)
diff --git a/web/source/settings/lib/types/instance.ts b/web/source/settings/lib/types/instance.ts
index 11f75032c..9abdc6a96 100644
--- a/web/source/settings/lib/types/instance.ts
+++ b/web/source/settings/lib/types/instance.ts
@@ -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;
diff --git a/web/source/settings/views/admin/instance/settings.tsx b/web/source/settings/views/admin/instance/settings.tsx
index c769b11ec..fd5ceb1ee 100644
--- a/web/source/settings/views/admin/instance/settings.tsx
+++ b/web/source/settings/views/admin/instance/settings.tsx
@@ -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,
@@ -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,7 +195,16 @@ function InstanceSettingsForm({ data: instance }: InstanceSettingsFormProps) {
type="email"
/>
+
+
);
-}
\ No newline at end of file
+}