diff options
author | 2022-09-12 13:14:29 +0200 | |
---|---|---|
committer | 2022-09-12 13:14:29 +0200 | |
commit | b42469e4e06d82a0e458b94379e226257ba3ca6c (patch) | |
tree | f4df0740ff0378dd4d3691cac4e942ea0a22eb5a /internal | |
parent | [feature] Fetch + display custom emoji in statuses from remote instances (#807) (diff) | |
download | gotosocial-b42469e4e06d82a0e458b94379e226257ba3ca6c.tar.xz |
[feature] Allow users to set custom css for their profiles + threads (#808)
* add custom css account property + db func to fetch
* allow account to get/set custom css
* serve custom css for an account
* go fmt
* use monospace for customcss, add link
* add custom css to account cache
* fix broken field
* add custom css docs to user guide
* add `accounts-allow-custom-css` config flag
* add allow custom css to /api/v1/instance response
* only show/set custom css if allowed to do so
* only set/serve custom account css if enabled
* update swagger docs
* chain promise
* make bool a bit clearer
* use cache for GetAccountCustomCSSByUsername
Diffstat (limited to 'internal')
26 files changed, 275 insertions, 20 deletions
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 <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("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 <style> into <head> and change the document title + customCSS := "</style><title>pee pee poo poo</title><style>" + sanitized := text.SanitizePlaintext(customCSS) + suite.Empty(sanitized) +} + +func (suite *SanitizeTestSuite) TestSanitizeNaughtyCustomCSS2() { + // try to break out of <style> into <head> and change the document title + customCSS := "pee pee poo poo</style><title></title><style>" + sanitized := text.SanitizePlaintext(customCSS) + suite.Equal("pee pee poo poo", sanitized) +} + func TestSanitizeTestSuite(t *testing.T) { suite.Run(t, new(SanitizeTestSuite)) } diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index a3496bb33..2f21f2d19 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -197,6 +197,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A Emojis: emojis, // TODO: implement this Fields: fields, Suspended: suspended, + CustomCSS: a.CustomCSS, } c.ensureAvatar(accountFrontend) @@ -678,6 +679,9 @@ func (c *converter) InstanceToAPIInstance(ctx context.Context, i *gtsmodel.Insta MinExpiration: instancePollsMinExpiration, // seconds MaxExpiration: instancePollsMaxExpiration, // seconds }, + Accounts: &model.InstanceConfigurationAccounts{ + AllowCustomCSS: config.GetAccountsAllowCustomCSS(), + }, } } diff --git a/internal/validate/formvalidation.go b/internal/validate/formvalidation.go index d22e43f6c..c51c17922 100644 --- a/internal/validate/formvalidation.go +++ b/internal/validate/formvalidation.go @@ -25,6 +25,7 @@ import ( "strings" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/regexes" pwv "github.com/wagslane/go-password-validator" "golang.org/x/text/language" @@ -40,8 +41,7 @@ const ( maximumDescriptionLength = 5000 maximumSiteTermsLength = 5000 maximumUsernameLength = 64 - // maximumEmojiShortcodeLength = 30 - // maximumHashtagLength = 30 + maximumCustomCSSLength = 5000 ) // NewPassword returns an error if the given password is not sufficiently strong, or nil if it's ok. @@ -159,6 +159,17 @@ func StatusFormat(statusFormat string) error { return fmt.Errorf("status format '%s' was not recognized, valid options are 'plain', 'markdown'", statusFormat) } +func CustomCSS(customCSS string) error { + if !config.GetAccountsAllowCustomCSS() { + return errors.New("accounts-allow-custom-css is not enabled for this instance") + } + + if length := len(customCSS); length > maximumCustomCSSLength { + return fmt.Errorf("custom_css must be less than %d characters, but submitted custom_css was %d characters", maximumCustomCSSLength, length) + } + return nil +} + // EmojiShortcode just runs the given shortcode through the regular expression // for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters, // lowercase a-z, numbers, and underscores. diff --git a/internal/web/customcss.go b/internal/web/customcss.go new file mode 100644 index 000000000..34e15844c --- /dev/null +++ b/internal/web/customcss.go @@ -0,0 +1,60 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package web + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +func (m *Module) customCSSGETHandler(c *gin.Context) { + if !config.GetAccountsAllowCustomCSS() { + err := errors.New("accounts-allow-custom-css is not enabled on this instance") + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) + return + } + + if _, err := api.NegotiateAccept(c, api.TextCSS); err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + // usernames on our instance will always be lowercase + username := strings.ToLower(c.Param(usernameKey)) + if username == "" { + err := errors.New("no account username specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + customCSS, errWithCode := m.processor.AccountGetCustomCSSForUsername(c.Request.Context(), username) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/css; charset=utf-8", []byte(customCSS)) +} diff --git a/internal/web/profile.go b/internal/web/profile.go index 0640c7df9..67407f8e4 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -99,6 +99,15 @@ func (m *Module) profileGETHandler(c *gin.Context) { return } + stylesheets := []string{ + "/assets/Fork-Awesome/css/fork-awesome.min.css", + "/assets/dist/status.css", + "/assets/dist/profile.css", + } + if config.GetAccountsAllowCustomCSS() { + stylesheets = append(stylesheets, "/@"+account.Username+"/custom.css") + } + c.HTML(http.StatusOK, "profile.tmpl", gin.H{ "instance": instance, "account": account, @@ -106,11 +115,7 @@ func (m *Module) profileGETHandler(c *gin.Context) { "statuses": statusResp.Items, "statuses_next": statusResp.NextLink, "show_back_to_top": showBackToTop, - "stylesheets": []string{ - "/assets/Fork-Awesome/css/fork-awesome.min.css", - "/assets/dist/status.css", - "/assets/dist/profile.css", - }, + "stylesheets": stylesheets, "javascript": []string{ "/assets/dist/frontend.js", }, diff --git a/internal/web/thread.go b/internal/web/thread.go index 824d3c33c..b7e1bef3d 100644 --- a/internal/web/thread.go +++ b/internal/web/thread.go @@ -104,15 +104,20 @@ func (m *Module) threadGETHandler(c *gin.Context) { return } + stylesheets := []string{ + "/assets/Fork-Awesome/css/fork-awesome.min.css", + "/assets/dist/status.css", + } + if config.GetAccountsAllowCustomCSS() { + stylesheets = append(stylesheets, "/@"+username+"/custom.css") + } + c.HTML(http.StatusOK, "thread.tmpl", gin.H{ - "instance": instance, - "status": status, - "context": context, - "ogMeta": ogBase(instance).withStatus(status), - "stylesheets": []string{ - "/assets/Fork-Awesome/css/fork-awesome.min.css", - "/assets/dist/status.css", - }, + "instance": instance, + "status": status, + "context": context, + "ogMeta": ogBase(instance).withStatus(status), + "stylesheets": stylesheets, "javascript": []string{ "/assets/dist/frontend.js", }, diff --git a/internal/web/web.go b/internal/web/web.go index 336525938..a74fc8e19 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -35,6 +35,7 @@ import ( const ( confirmEmailPath = "/" + uris.ConfirmEmailPath profilePath = "/@:" + usernameKey + customCSSPath = profilePath + "/custom.css" statusPath = profilePath + "/statuses/:" + statusIDKey adminPanelPath = "/admin" userPanelpath = "/user" @@ -91,6 +92,9 @@ func (m *Module) Route(s router.Router) error { // serve profile pages at /@username s.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) + // serve custom css at /@username/custom.css + s.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler) + // serve statuses s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) |