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" /> +