summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/account/accountupdate.go9
-rw-r--r--internal/api/client/instance/instancepatch_test.go8
-rw-r--r--internal/api/mime.go1
-rw-r--r--internal/api/model/account.go4
-rw-r--r--internal/api/model/instance.go10
-rw-r--r--internal/cache/account.go1
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/config/defaults.go1
-rw-r--r--internal/config/flags.go1
-rw-r--r--internal/config/helpers.gen.go25
-rw-r--r--internal/db/account.go3
-rw-r--r--internal/db/bundb/account.go9
-rw-r--r--internal/db/bundb/migrations/20220823140228_user_custom_css.go46
-rw-r--r--internal/gtsmodel/account.go1
-rw-r--r--internal/processing/account.go4
-rw-r--r--internal/processing/account/account.go2
-rw-r--r--internal/processing/account/get.go12
-rw-r--r--internal/processing/account/update.go8
-rw-r--r--internal/processing/processor.go1
-rw-r--r--internal/text/sanitize_test.go29
-rw-r--r--internal/typeutils/internaltofrontend.go4
-rw-r--r--internal/validate/formvalidation.go15
-rw-r--r--internal/web/customcss.go60
-rw-r--r--internal/web/profile.go15
-rw-r--r--internal/web/thread.go21
-rw-r--r--internal/web/web.go4
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)