diff options
author | 2024-12-02 06:24:48 -0500 | |
---|---|---|
committer | 2024-12-02 12:24:48 +0100 | |
commit | 9609c4550d0cf6010ab88357fb5636e42ad22ba7 (patch) | |
tree | 2c339976192013aa79d164de90104ba3ca990a84 /internal | |
parent | [feature/themes] Add auto-switching themes for blurple/brutalist/solarized (#... (diff) | |
download | gotosocial-9609c4550d0cf6010ab88357fb5636e42ad22ba7.tar.xz |
[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>
Diffstat (limited to 'internal')
-rw-r--r-- | internal/api/client/instance/instancepatch.go | 1 | ||||
-rw-r--r-- | internal/api/model/instance.go | 2 | ||||
-rw-r--r-- | internal/api/model/instancev1.go | 2 | ||||
-rw-r--r-- | internal/api/model/instancev2.go | 2 | ||||
-rw-r--r-- | internal/db/bundb/migrations/20240924222938_add_instance_custom_css.go | 44 | ||||
-rw-r--r-- | internal/gtsmodel/instance.go | 1 | ||||
-rw-r--r-- | internal/processing/instance.go | 11 | ||||
-rw-r--r-- | internal/typeutils/internaltofrontend.go | 2 | ||||
-rw-r--r-- | internal/validate/formvalidation.go | 10 | ||||
-rw-r--r-- | internal/web/about.go | 2 | ||||
-rw-r--r-- | internal/web/confirmemail.go | 5 | ||||
-rw-r--r-- | internal/web/customcss.go | 19 | ||||
-rw-r--r-- | internal/web/domain-blocklist.go | 2 | ||||
-rw-r--r-- | internal/web/index.go | 2 | ||||
-rw-r--r-- | internal/web/profile.go | 3 | ||||
-rw-r--r-- | internal/web/settings-panel.go | 1 | ||||
-rw-r--r-- | internal/web/signup.go | 7 | ||||
-rw-r--r-- | internal/web/tag.go | 2 | ||||
-rw-r--r-- | internal/web/thread.go | 3 | ||||
-rw-r--r-- | internal/web/web.go | 30 |
20 files changed, 126 insertions, 25 deletions
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 <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) + } +} 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) |