diff options
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)  | 
