diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index ebcf14c02..0d75a6a12 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -12,6 +12,17 @@ definitions: title: A FileHeader describes a file part of a multipart request. type: object x-go-package: mime/multipart + InstanceConfigurationAccounts: + properties: + allow_custom_css: + description: Whether or not accounts on this instance are allowed to upload + custom CSS for profiles and statuses. + example: false + type: boolean + x-go-name: AllowCustomCSS + title: InstanceConfigurationAccounts models instance account config parameters. + type: object + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model Link: description: See https://webfinger.net/ properties: @@ -240,6 +251,11 @@ definitions: example: "2021-07-30T09:20:25+00:00" type: string x-go-name: CreatedAt + custom_css: + description: CustomCSS to include when rendering this account's profile or + statuses. + type: string + x-go-name: CustomCSS discoverable: description: Account has opted into discovery features. type: boolean @@ -1086,6 +1102,8 @@ definitions: x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model instanceConfiguration: properties: + accounts: + $ref: '#/definitions/InstanceConfigurationAccounts' media_attachments: $ref: '#/definitions/instanceConfigurationMediaAttachments' polls: @@ -2618,6 +2636,12 @@ paths: in: formData name: source[status_format] type: string + - description: |- + Custom CSS to use when rendering this account's profile or statuses. + String must be no more than 5,000 characters (~5kb). + in: formData + name: custom_css + type: string produces: - application/json responses: diff --git a/docs/assets/cssblack.png b/docs/assets/cssblack.png new file mode 100644 index 000000000..6c1d91146 Binary files /dev/null and b/docs/assets/cssblack.png differ diff --git a/docs/assets/cssgradient.png b/docs/assets/cssgradient.png new file mode 100644 index 000000000..23b85dd0e Binary files /dev/null and b/docs/assets/cssgradient.png differ diff --git a/docs/assets/cssstandard.png b/docs/assets/cssstandard.png new file mode 100644 index 000000000..e4d9cab7c Binary files /dev/null and b/docs/assets/cssstandard.png differ diff --git a/docs/configuration/accounts.md b/docs/configuration/accounts.md index edb647a1b..3f0c6d5f8 100644 --- a/docs/configuration/accounts.md +++ b/docs/configuration/accounts.md @@ -23,4 +23,20 @@ accounts-approval-required: true # Options: [true, false] # Default: true accounts-reason-required: true + +# Bool. Allow accounts on this instance to set custom CSS for their profile pages and statuses. +# Enabling this setting will allow accounts to upload custom CSS via the /user settings page, +# which will then be rendered on the web view of the account's profile and statuses. +# +# For instances with public sign ups, it is **HIGHLY RECOMMENDED** to leave this setting on 'false', +# since setting it to true allows malicious accounts to make their profile pages misleading, unusable +# or even dangerous to visitors. In other words, you should only enable this setting if you trust +# the users on your instance not to produce harmful CSS. +# +# Regardless of what this value is set to, any uploaded CSS will not be federated to other instances, +# it will only be shown on profiles and statuses on *this* instance. +# +# Options: [true, false] +# Default: false +accounts-allow-custom-css: false ``` diff --git a/docs/user_guide/custom_css.md b/docs/user_guide/custom_css.md new file mode 100644 index 000000000..513074c62 --- /dev/null +++ b/docs/user_guide/custom_css.md @@ -0,0 +1,73 @@ +# Custom CSS (Advanced) + +CSS (Cascading Style Sheets) is a coding language used alongside HTML, which determines how a web page looks in a web browser: + +> While HTML is used to define the structure and semantics of your content, CSS is used to style it and lay it out. For example, you can use CSS to alter the font, color, size, and spacing of your content, split it into multiple columns, or add animations and other decorative features. +> +> -- [Learn CSS (Mozilla)](https://developer.mozilla.org/en-US/docs/Learn/CSS) + +Depending on the settings configured by the admin of your GoToSocial instance, you may be able to upload custom CSS for your account via the User Settings Panel. + +This allows you to customize the appearance of your GoToSocial profile for users visiting it using a web browser. + +## Example - Changing Background Color + +Here's a standard GoToSocial profile page: + +![A GoToSocial test profile page. The standard color scheme of grey, blue, and orange.](./../assets/cssstandard.png) + +Let's say we want the background color to be black instead of grey. + +In the User Settings Panel, we enter the following CSS code in the Custom CSS field: + +```css +.page { + background: black; +} +``` + +We then click on Save Profile Info. + +If we go back to our profile page and refresh the page, it now looks like this: + +![The same GoToSocial test profile page. The background is now black.](./../assets/cssblack.png) + +If we want to get really fancy, we can add an ombre effect to the background, by using the following CSS code instead: + +```css +.page { + background: linear-gradient(crimson, purple); +} +``` + +After saving the css and refreshing the profile page, the profile now looks like this: + +![The same GoToSocial test profile page. The background now starts dark red and fades to purple further down the page.](./../assets/cssgradient.png) + +## Accessibility + +The importance of accessible HTML and CSS cannot be overstated. From W3: + +> The Web is fundamentally designed to work for all people, whatever their hardware, software, language, location, or ability. When the Web meets this goal, it is accessible to people with a diverse range of hearing, movement, sight, and cognitive ability. +> +> Thus the impact of disability is radically changed on the Web because the Web removes barriers to communication and interaction that many people face in the physical world. However, when websites, applications, technologies, or tools are badly designed, they can create barriers that exclude people from using the Web. +> +> Accessibility is essential for developers and organizations that want to create high-quality websites and web tools, and not exclude people from using their products and services. +> +> -- [Introduction To Web Accessibility](https://www.w3.org/WAI/fundamentals/accessibility-intro/) + +The standard GoToSocial theme is designed with web accessibility in mind, especially when it comes to layout, color contrasts, font sizes, and so on. + +If you write custom CSS for your profile, it is very important that you make sure that it remains legible and that it behaves as expected. Buttons should look like buttons, links should look like links, text should be presented in a readable font, elements should not jump around the page, etc. Web pages can be pretty and exciting without sacrificing readability, or making things overcomplicated. + +If you change your color scheme, it's a good idea to validate the new colors to make sure that they have sufficient contrast to be readable by people with visual impairments like color blindness. Once you've updated your CSS, try entering your profile URL in a contrast checking tool, like the [Color Contrast Accessibility Validator](https://color.a11y.com/Contrast). You can also use the 'Accessibility' tab in the developer tools of your web browser to check for any issues. + +Styling with accessibility in mind makes the web better for everyone! Have a look at the links below for more information. + +## Useful Links + +- [Learn CSS (Mozilla)](https://developer.mozilla.org/en-US/docs/Learn/CSS) +- [CSS Tutorial (W3 Schools)](https://www.w3schools.com/Css/default.asp) +- [CSS and JavaScript Accessibility Best Practices (Mozilla)](https://developer.mozilla.org/en-US/docs/Learn/Accessibility/CSS_and_JavaScript#css) +- [WAVE Web Accessibility Evaluation Tool](https://wave.webaim.org/) +- [Color Contrast Accessibility Validator](https://color.a11y.com/Contrast) diff --git a/example/config.yaml b/example/config.yaml index 1998ce16a..f6f54720e 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -207,6 +207,22 @@ accounts-approval-required: true # Default: true accounts-reason-required: true +# Bool. Allow accounts on this instance to set custom CSS for their profile pages and statuses. +# Enabling this setting will allow accounts to upload custom CSS via the /user settings page, +# which will then be rendered on the web view of the account's profile and statuses. +# +# For instances with public sign ups, it is **HIGHLY RECOMMENDED** to leave this setting on 'false', +# since setting it to true allows malicious accounts to make their profile pages misleading, unusable +# or even dangerous to visitors. In other words, you should only enable this setting if you trust +# the users on your instance not to produce harmful CSS. +# +# Regardless of what this value is set to, any uploaded CSS will not be federated to other instances, +# it will only be shown on profiles and statuses on *this* instance. +# +# Options: [true, false] +# Default: false +accounts-allow-custom-css: false + ######################## ##### MEDIA CONFIG ##### ######################## diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go index 3ba214ed1..3e5f60324 100644 --- a/internal/api/client/account/accountupdate.go +++ b/internal/api/client/account/accountupdate.go @@ -92,6 +92,12 @@ import ( // in: formData // description: Default format to use for authored statuses (plain or markdown). // type: string +// - name: custom_css +// in: formData +// description: |- +// Custom CSS to use when rendering this account's profile or statuses. +// String must be no more than 5,000 characters (~5kb). +// type: string // // security: // - OAuth2 Bearer: @@ -183,7 +189,8 @@ func parseUpdateAccountForm(c *gin.Context) (*model.UpdateCredentialsRequest, er form.Source.Sensitive == nil && form.Source.Language == nil && form.Source.StatusFormat == nil && - form.FieldsAttributes == nil) { + form.FieldsAttributes == nil && + form.CustomCSS == nil) { return nil, errors.New("empty form submitted") } diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index 97129ca2e..4f8bcccb3 100644 --- a/internal/api/client/instance/instancepatch_test.go +++ b/internal/api/client/instance/instancepatch_test.go @@ -63,7 +63,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch2() { @@ -93,7 +93,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch3() { @@ -123,7 +123,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch4() { @@ -214,7 +214,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() { b, err := io.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) + suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b)) } func (suite *InstancePatchTestSuite) TestInstancePatch7() { diff --git a/internal/api/mime.go b/internal/api/mime.go index 0c9595c50..b495b059b 100644 --- a/internal/api/mime.go +++ b/internal/api/mime.go @@ -31,4 +31,5 @@ const ( MultipartForm MIME = `multipart/form-data` TextXML MIME = `text/xml` TextHTML MIME = `text/html` + TextCSS MIME = `text/css` ) diff --git a/internal/api/model/account.go b/internal/api/model/account.go index dc6fa24b8..b085e84e7 100644 --- a/internal/api/model/account.go +++ b/internal/api/model/account.go @@ -90,6 +90,8 @@ type Account struct { MuteExpiresAt string `json:"mute_expires_at,omitempty"` // Extra profile information. Shown only if the requester owns the account being requested. Source *Source `json:"source,omitempty"` + // CustomCSS to include when rendering this account's profile or statuses. + CustomCSS string `json:"custom_css,omitempty"` } // AccountCreateRequest models account creation parameters. @@ -151,6 +153,8 @@ type UpdateCredentialsRequest struct { Source *UpdateSource `form:"source" json:"source" xml:"source"` // Profile metadata name and value FieldsAttributes *[]UpdateField `form:"fields_attributes" json:"fields_attributes" xml:"fields_attributes"` + // Custom CSS to be included when rendering this account's profile or statuses. + CustomCSS *string `form:"custom_css" json:"custom_css" xml:"custom_css"` } // UpdateSource is to be used specifically in an UpdateCredentialsRequest. diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go index aec42f8b1..467cb886e 100644 --- a/internal/api/model/instance.go +++ b/internal/api/model/instance.go @@ -97,6 +97,8 @@ type InstanceConfiguration struct { MediaAttachments *InstanceConfigurationMediaAttachments `json:"media_attachments"` // Instance configuration pertaining to poll limits. Polls *InstanceConfigurationPolls `json:"polls"` + // Instance configuration pertaining to accounts. + Accounts *InstanceConfigurationAccounts `json:"accounts"` } // InstanceConfigurationStatuses models instance status config parameters. @@ -175,6 +177,14 @@ type InstanceConfigurationPolls struct { MaxExpiration int `json:"max_expiration"` } +// InstanceConfigurationAccounts models instance account config parameters. +type InstanceConfigurationAccounts struct { + // Whether or not accounts on this instance are allowed to upload custom CSS for profiles and statuses. + // + // example: false + AllowCustomCSS bool `json:"allow_custom_css"` +} + // InstanceURLs models instance-relevant URLs for client application consumption. // // swagger:model instanceURLs diff --git a/internal/cache/account.go b/internal/cache/account.go index 5ba97c7d8..709d1ec30 100644 --- a/internal/cache/account.go +++ b/internal/cache/account.go @@ -131,6 +131,7 @@ func copyAccount(account *gtsmodel.Account) *gtsmodel.Account { Sensitive: copyBoolPtr(account.Sensitive), Language: account.Language, StatusFormat: account.StatusFormat, + CustomCSS: account.CustomCSS, URI: account.URI, URL: account.URL, LastWebfingeredAt: account.LastWebfingeredAt, diff --git a/internal/config/config.go b/internal/config/config.go index 7efed1815..7c0bf99a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type Configuration struct { AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."` AccountsApprovalRequired bool `name:"accounts-approval-required" usage:"Do account signups require approval by an admin or moderator before user can log in? If false, new registrations will be automatically approved."` AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"` + AccountsAllowCustomCSS bool `name:"accounts-allow-custom-css" usage:"Allow accounts to enable custom CSS for their profile pages and statuses."` MediaImageMaxSize int `name:"media-image-max-size" usage:"Max size of accepted images in bytes"` MediaVideoMaxSize int `name:"media-video-max-size" usage:"Max size of accepted videos in bytes"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 8a4a3129e..a0d409c5f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -52,6 +52,7 @@ var Defaults = Configuration{ AccountsRegistrationOpen: true, AccountsApprovalRequired: true, AccountsReasonRequired: true, + AccountsAllowCustomCSS: false, MediaImageMaxSize: 10485760, // 10mb MediaVideoMaxSize: 41943040, // 40mb diff --git a/internal/config/flags.go b/internal/config/flags.go index 9b4c40428..183ed3762 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -68,6 +68,7 @@ func AddServerFlags(cmd *cobra.Command) { cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage")) cmd.Flags().Bool(AccountsApprovalRequiredFlag(), cfg.AccountsApprovalRequired, fieldtag("AccountsApprovalRequired", "usage")) cmd.Flags().Bool(AccountsReasonRequiredFlag(), cfg.AccountsReasonRequired, fieldtag("AccountsReasonRequired", "usage")) + cmd.Flags().Bool(AccountsAllowCustomCSSFlag(), cfg.AccountsAllowCustomCSS, fieldtag("AccountsAllowCustomCSS", "usage")) // Media cmd.Flags().Int(MediaImageMaxSizeFlag(), cfg.MediaImageMaxSize, fieldtag("MediaImageMaxSize", "usage")) diff --git a/internal/config/helpers.gen.go b/internal/config/helpers.gen.go index 51891a537..c8fd4f621 100644 --- a/internal/config/helpers.gen.go +++ b/internal/config/helpers.gen.go @@ -668,6 +668,31 @@ func GetAccountsReasonRequired() bool { return global.GetAccountsReasonRequired( // SetAccountsReasonRequired safely sets the value for global configuration 'AccountsReasonRequired' field func SetAccountsReasonRequired(v bool) { global.SetAccountsReasonRequired(v) } +// GetAccountsAllowCustomCSS safely fetches the Configuration value for state's 'AccountsAllowCustomCSS' field +func (st *ConfigState) GetAccountsAllowCustomCSS() (v bool) { + st.mutex.Lock() + v = st.config.AccountsAllowCustomCSS + st.mutex.Unlock() + return +} + +// SetAccountsAllowCustomCSS safely sets the Configuration value for state's 'AccountsAllowCustomCSS' field +func (st *ConfigState) SetAccountsAllowCustomCSS(v bool) { + st.mutex.Lock() + defer st.mutex.Unlock() + st.config.AccountsAllowCustomCSS = v + st.reloadToViper() +} + +// AccountsAllowCustomCSSFlag returns the flag name for the 'AccountsAllowCustomCSS' field +func AccountsAllowCustomCSSFlag() string { return "accounts-allow-custom-css" } + +// GetAccountsAllowCustomCSS safely fetches the value for global configuration 'AccountsAllowCustomCSS' field +func GetAccountsAllowCustomCSS() bool { return global.GetAccountsAllowCustomCSS() } + +// SetAccountsAllowCustomCSS safely sets the value for global configuration 'AccountsAllowCustomCSS' field +func SetAccountsAllowCustomCSS(v bool) { global.SetAccountsAllowCustomCSS(v) } + // GetMediaImageMaxSize safely fetches the Configuration value for state's 'MediaImageMaxSize' field func (st *ConfigState) GetMediaImageMaxSize() (v int) { st.mutex.Lock() diff --git a/internal/db/account.go b/internal/db/account.go index 04c76777f..5f1336872 100644 --- a/internal/db/account.go +++ b/internal/db/account.go @@ -45,6 +45,9 @@ type Account interface { // UpdateAccount updates one account by ID. UpdateAccount(ctx context.Context, account *gtsmodel.Account) (*gtsmodel.Account, Error) + // GetAccountCustomCSSByUsername returns the custom css of an account on this instance with the given username. + GetAccountCustomCSSByUsername(ctx context.Context, username string) (string, Error) + // GetAccountFaves fetches faves/likes created by the target accountID. GetAccountFaves(ctx context.Context, accountID string) ([]*gtsmodel.StatusFave, Error) diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go index 23030c612..2105368d3 100644 --- a/internal/db/bundb/account.go +++ b/internal/db/bundb/account.go @@ -231,6 +231,15 @@ func (a *accountDB) SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachmen return nil } +func (a *accountDB) GetAccountCustomCSSByUsername(ctx context.Context, username string) (string, db.Error) { + account, err := a.GetAccountByUsernameDomain(ctx, username, "") + if err != nil { + return "", err + } + + return account.CustomCSS, nil +} + func (a *accountDB) GetAccountFaves(ctx context.Context, accountID string) ([]*gtsmodel.StatusFave, db.Error) { faves := new([]*gtsmodel.StatusFave) diff --git a/internal/db/bundb/migrations/20220823140228_user_custom_css.go b/internal/db/bundb/migrations/20220823140228_user_custom_css.go new file mode 100644 index 000000000..12b093729 --- /dev/null +++ b/internal/db/bundb/migrations/20220823140228_user_custom_css.go @@ -0,0 +1,46 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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("accounts"), 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 { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 49db7dbda..18b808a2f 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -55,6 +55,7 @@ type Account struct { Sensitive *bool `validate:"-" bun:",default:false"` // Set posts from this account to sensitive by default? Language string `validate:"omitempty,bcp47_language_tag" bun:",nullzero,notnull,default:'en'"` // What language does this account post in? StatusFormat string `validate:"required_without=Domain,omitempty,oneof=plain markdown" bun:",nullzero"` // What is the default format for statuses posted by this account (only for local accounts). + CustomCSS string `validate:"-" bun:",nullzero"` // Custom CSS that should be displayed for this Account's profile and statuses. URI string `validate:"required,url" bun:",nullzero,notnull,unique"` // ActivityPub URI for this account. URL string `validate:"required_without=Domain,omitempty,url" bun:",nullzero,unique"` // Web URL for this account's profile LastWebfingeredAt time.Time `validate:"required_with=Domain" bun:"type:timestamptz,nullzero"` // Last time this account was refreshed/located with webfinger. diff --git a/internal/processing/account.go b/internal/processing/account.go index df351d7b9..ada511133 100644 --- a/internal/processing/account.go +++ b/internal/processing/account.go @@ -42,6 +42,10 @@ func (p *processor) AccountGetLocalByUsername(ctx context.Context, authed *oauth return p.accountProcessor.GetLocalByUsername(ctx, authed.Account, username) } +func (p *processor) AccountGetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) { + return p.accountProcessor.GetCustomCSSForUsername(ctx, username) +} + func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) { return p.accountProcessor.Update(ctx, authed.Account, form) } diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go index 868308efe..aca46394a 100644 --- a/internal/processing/account/account.go +++ b/internal/processing/account/account.go @@ -51,6 +51,8 @@ type Processor interface { Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Account, gtserror.WithCode) // GetLocalByUsername processes the given request for account information targeting a local account by username. GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode) + // GetCustomCSSForUsername returns custom css for the given local username. + GetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) // Update processes the update of an account with the given form Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go index c558b52ed..7d373bc8c 100644 --- a/internal/processing/account/get.go +++ b/internal/processing/account/get.go @@ -55,6 +55,18 @@ func (p *processor) GetLocalByUsername(ctx context.Context, requestingAccount *g return p.getAccountFor(ctx, requestingAccount, targetAccount) } +func (p *processor) GetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) { + customCSS, err := p.db.GetAccountCustomCSSByUsername(ctx, username) + if err != nil { + if err == db.ErrNoEntries { + return "", gtserror.NewErrorNotFound(errors.New("account not found")) + } + return "", gtserror.NewErrorInternalError(fmt.Errorf("db error: %s", err)) + } + + return customCSS, nil +} + func (p *processor) getAccountFor(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (*apimodel.Account, gtserror.WithCode) { var blocked bool var err error diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go index 3b844a160..47c4a2b4b 100644 --- a/internal/processing/account/update.go +++ b/internal/processing/account/update.go @@ -124,6 +124,14 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form } } + if form.CustomCSS != nil { + customCSS := *form.CustomCSS + if err := validate.CustomCSS(customCSS); err != nil { + return nil, gtserror.NewErrorBadRequest(err, err.Error()) + } + account.CustomCSS = text.SanitizePlaintext(customCSS) + } + updatedAccount, err := p.db.UpdateAccount(ctx, account) if err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %s: %s", account.ID, err)) diff --git a/internal/processing/processor.go b/internal/processing/processor.go index 4b81c0ca4..5dd795c18 100644 --- a/internal/processing/processor.go +++ b/internal/processing/processor.go @@ -79,6 +79,7 @@ type Processor interface { AccountGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) (*apimodel.Account, gtserror.WithCode) // AccountGet processes the given request for account information. AccountGetLocalByUsername(ctx context.Context, authed *oauth.Auth, username string) (*apimodel.Account, gtserror.WithCode) + AccountGetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode) // AccountUpdate processes the update of an account with the given form AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for diff --git a/internal/text/sanitize_test.go b/internal/text/sanitize_test.go index eea5daadb..727da6f35 100644 --- a/internal/text/sanitize_test.go +++ b/internal/text/sanitize_test.go @@ -94,6 +94,35 @@ func (suite *SanitizeTestSuite) TestSanitizeCaption6() { suite.Equal("hello world", sanitized) } +func (suite *SanitizeTestSuite) TestSanitizeCustomCSS() { + customCSS := `.toot .username { + color: var(--link_fg); + line-height: 2rem; + margin-top: -0.5rem; + align-self: start; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +}` + sanitized := text.SanitizePlaintext(customCSS) + suite.Equal(customCSS, sanitized) // should be the same as it was before +} + +func (suite *SanitizeTestSuite) TestSanitizeNaughtyCustomCSS1() { + // try to break out of pee pee poo poo