mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[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
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
@@ -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() {
|
||||
|
@@ -31,4 +31,5 @@ const (
|
||||
MultipartForm MIME = `multipart/form-data`
|
||||
TextXML MIME = `text/xml`
|
||||
TextHTML MIME = `text/html`
|
||||
TextCSS MIME = `text/css`
|
||||
)
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
1
internal/cache/account.go
vendored
1
internal/cache/account.go
vendored
@@ -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,
|
||||
|
@@ -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"`
|
||||
|
@@ -52,6 +52,7 @@ var Defaults = Configuration{
|
||||
AccountsRegistrationOpen: true,
|
||||
AccountsApprovalRequired: true,
|
||||
AccountsReasonRequired: true,
|
||||
AccountsAllowCustomCSS: false,
|
||||
|
||||
MediaImageMaxSize: 10485760, // 10mb
|
||||
MediaVideoMaxSize: 41943040, // 40mb
|
||||
|
@@ -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"))
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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.
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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))
|
||||
|
@@ -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
|
||||
|
@@ -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))
|
||||
}
|
||||
|
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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.
|
||||
|
60
internal/web/customcss.go
Normal file
60
internal/web/customcss.go
Normal file
@@ -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))
|
||||
}
|
@@ -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",
|
||||
},
|
||||
|
@@ -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",
|
||||
},
|
||||
|
@@ -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)
|
||||
|
||||
|
Reference in New Issue
Block a user