diff --git a/.env.vagrant b/.env.vagrant
index 69c1bf1fb3d801..0d17d78554535e 100644
--- a/.env.vagrant
+++ b/.env.vagrant
@@ -5,4 +5,4 @@ DB_HOST=/var/run/postgresql/
ES_ENABLED=true
ES_HOST=localhost
-ES_PORT=9200
\ No newline at end of file
+ES_PORT=9200
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 8ae69b7fe066d1..8da323f2786991 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -20,7 +20,7 @@ def update
private
def account_params
- params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, fields_attributes: [:name, :value])
+ params.require(:account).permit(:display_name, :note, :avatar, :header, :bot, :account_css, fields_attributes: [:name, :value])
end
def set_account
diff --git a/app/javascript/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts
index 5bf3e64288c76e..d6dada067b6678 100644
--- a/app/javascript/flavours/glitch/api_types/accounts.ts
+++ b/app/javascript/flavours/glitch/api_types/accounts.ts
@@ -44,4 +44,5 @@ export interface ApiAccountJSON {
limited?: boolean;
memorial?: boolean;
hide_collections: boolean;
+ account_css?: string;
}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx
index 62c8c84c66eafa..d504ff260cf174 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.jsx
+++ b/app/javascript/flavours/glitch/features/account/components/header.jsx
@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
-import { Helmet } from 'react-helmet';
-import { withRouter } from 'react-router-dom';
+import {Helmet} from 'react-helmet';
+import {withRouter} from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -405,6 +405,11 @@ class Header extends ImmutablePureComponent {
{titleFromAccount(account)}
+ {account.account_css && (
+
+ )}
);
diff --git a/app/javascript/flavours/glitch/models/account.ts b/app/javascript/flavours/glitch/models/account.ts
index 0b698ead3d1930..eaad1fd3d9a9fb 100644
--- a/app/javascript/flavours/glitch/models/account.ts
+++ b/app/javascript/flavours/glitch/models/account.ts
@@ -95,6 +95,7 @@ export const accountDefaultValues: AccountShape = {
limited: false,
moved: null,
hide_collections: false,
+ account_css: '',
};
const AccountFactory = ImmutableRecord(accountDefaultValues);
diff --git a/app/javascript/flavours/glitch/styles/neuromatchstodon/index.scss b/app/javascript/flavours/glitch/styles/neuromatchstodon/index.scss
index 9c3885ff72ad8f..ee9c375fc20f0f 100644
--- a/app/javascript/flavours/glitch/styles/neuromatchstodon/index.scss
+++ b/app/javascript/flavours/glitch/styles/neuromatchstodon/index.scss
@@ -1,3 +1,4 @@
@import 'latex';
@import 'bigger_collapsed_statuses';
@import 'better_code_blocks';
+@import 'myspace';
diff --git a/app/javascript/flavours/glitch/styles/neuromatchstodon/myspace.scss b/app/javascript/flavours/glitch/styles/neuromatchstodon/myspace.scss
new file mode 100644
index 00000000000000..7bcb775aa1a9b9
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/neuromatchstodon/myspace.scss
@@ -0,0 +1,4 @@
+#account_account_css {
+ font-family: $font-monospace;
+ height: 15em;
+}
diff --git a/app/models/account.rb b/app/models/account.rb
index c344a30bbe98b9..f07cecb4600fa4 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -45,12 +45,13 @@
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# devices_url :string
-# suspension_origin :integer
# sensitized_at :datetime
+# suspension_origin :integer
# trendable :boolean
# reviewed_at :datetime
# requested_review_at :datetime
# indexable :boolean default(FALSE), not null
+# account_css :text
#
class Account < ApplicationRecord
@@ -114,6 +115,7 @@ class Account < ApplicationRecord
validates :followers_url, absence: true, if: :local?, on: :create
normalizes :username, with: ->(username) { username.squish }
+ normalizes :account_css, with: ->(account_css) { Sanitize::CSS.stylesheet(account_css, Sanitize::Config::RELAXED) }
scope :without_internal, -> { where(id: 1...) }
scope :remote, -> { where.not(domain: nil) }
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index a10d6988817370..dbaa15944b5e28 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -8,7 +8,8 @@ class REST::AccountSerializer < ActiveModel::Serializer
attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :indexable, :group, :created_at,
:note, :url, :uri, :avatar, :avatar_static, :header, :header_static,
- :followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections
+ :followers_count, :following_count, :statuses_count, :last_status_at, :hide_collections,
+ :account_css
has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 61ba79c4491f02..fc44f01402e1d1 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -67,5 +67,10 @@
.fields-group
= f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot')
+ %h4= t 'edit_profile.myspace_mode'
+
+ .fields-group
+ = f.input :account_css, as: :text, wrapper: :with_block_label, label: I18n.t('simple_form.labels.account.account_css'), hint: I18n.t('simple_form.hints.account.account_css'), neuromatchstodon_only: true
+
.actions
= f.button :button, t('generic.save_changes'), type: :submit
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index 2fd87acc8aca64..fc7fc21999ce0d 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -38,10 +38,20 @@ def glitch_only(_wrapper_options = nil)
end
end
+module NeuromatchstodonOnlyComponent
+ def neuromatchstodon_only(_wrapper_options = nil)
+ return unless options[:neuromatchstodon_only]
+
+ options[:label_text] = ->(raw_label_text, _required_label_text, _label_present) { safe_join([raw_label_text, ' ', content_tag(:span, I18n.t('simple_form.neuromatchstodon_only'), class: 'glitch_only')]) }
+ nil
+ end
+end
+
SimpleForm.include_component(AppendComponent)
SimpleForm.include_component(RecommendedComponent)
SimpleForm.include_component(WarningHintComponent)
SimpleForm.include_component(GlitchOnlyComponent)
+SimpleForm.include_component(NeuromatchstodonOnlyComponent)
SimpleForm.setup do |config|
# Wrappers are used by the form builder to generate a
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1df9af88d95306..dccc71a8b65e9c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1236,6 +1236,7 @@ en:
basic_information: Basic information
hint_html: "Customize what people see on your public profile and next to your posts. Other people are more likely to follow you back and interact with you when you have a filled out profile and a profile picture."
other: Other
+ myspace_mode: "MySpace Mode"
errors:
'400': The request you submitted was invalid or malformed.
'403': You don't have permission to view this page.
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index fee3a6151ad419..9300b0f862d076 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -10,6 +10,7 @@ en:
note: 'You can @mention other people or #hashtags.'
show_collections: People will be able to browse through your follows and followers. People that you follow will see that you follow them regardless.
unlocked: People will be able to follow you without requesting approval. Uncheck if you want to review follow requests and chose whether to accept or reject new followers.
+ account_css: Custom CSS that is applied to your account page
account_alias:
acct: Specify the username@domain of the account you want to move from
account_migration:
@@ -150,6 +151,7 @@ en:
indexable: Include public posts in search results
show_collections: Show follows and followers on profile
unlocked: Automatically accept new followers
+ account_css: Account CSS
account_alias:
acct: Handle of the old account
account_migration:
@@ -339,3 +341,4 @@ en:
sessions:
webauthn: Use one of your security keys to sign in
'yes': 'Yes'
+ neuromatchstodon_only: "Neuromatchstodon Only"
diff --git a/db/migrate/20240828084252_add_account_css_to_account.rb b/db/migrate/20240828084252_add_account_css_to_account.rb
new file mode 100644
index 00000000000000..01a14f31f37a37
--- /dev/null
+++ b/db/migrate/20240828084252_add_account_css_to_account.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddAccountCssToAccount < ActiveRecord::Migration[7.1]
+ def change
+ add_column :accounts, :account_css, :text, null: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a3afab78167fe8..5ac44d4d653c3f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2024_08_08_125420) do
+ActiveRecord::Schema[7.1].define(version: 2024_08_28_084252) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -200,6 +200,7 @@
t.datetime "reviewed_at", precision: nil
t.datetime "requested_review_at", precision: nil
t.boolean "indexable", default: false, null: false
+ t.text "account_css"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"
@@ -1428,9 +1429,9 @@
add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
create_view "user_ips", sql_definition: <<-SQL
- SELECT user_id,
- ip,
- max(used_at) AS used_at
+ SELECT t0.user_id,
+ t0.ip,
+ max(t0.used_at) AS used_at
FROM ( SELECT users.id AS user_id,
users.sign_up_ip AS ip,
users.created_at AS used_at
@@ -1447,7 +1448,7 @@
login_activities.created_at
FROM login_activities
WHERE (login_activities.success = true)) t0
- GROUP BY user_id, ip;
+ GROUP BY t0.user_id, t0.ip;
SQL
create_view "account_summaries", materialized: true, sql_definition: <<-SQL
SELECT accounts.id AS account_id,
@@ -1468,9 +1469,9 @@
add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL
- SELECT account_id,
- sum(rank) AS rank,
- array_agg(reason) AS reason
+ SELECT t0.account_id,
+ sum(t0.rank) AS rank,
+ array_agg(t0.reason) AS reason
FROM ( SELECT account_summaries.account_id,
((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
'most_followed'::text AS reason
@@ -1494,8 +1495,8 @@
WHERE (follow_recommendation_suppressions.account_id = statuses.account_id)))))
GROUP BY account_summaries.account_id
HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
- GROUP BY account_id
- ORDER BY (sum(rank)) DESC;
+ GROUP BY t0.account_id
+ ORDER BY (sum(t0.rank)) DESC;
SQL
add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true
diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb
index e3197f0a6d7882..6450c95444c884 100644
--- a/spec/controllers/settings/profiles_controller_spec.rb
+++ b/spec/controllers/settings/profiles_controller_spec.rb
@@ -48,4 +48,31 @@
expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id)
end
end
+
+ describe 'PUT #account_css with custom css' do
+ it 'hopefully removes malicious css' do
+ put :update, params: {
+ account: {
+ account_css: <<~CSS,
+ @import url(swear_words.css);
+ a { text-decoration: none; }
+
+ a:hover {
+ left: expression(alert('xss!'));
+ text-decoration: underline;
+ }
+ CSS
+ },
+ }
+ expect(account.reload.account_css).to eq <<~CSS
+
+ a { text-decoration: none; }
+
+ a:hover {
+
+ text-decoration: underline;
+ }
+ CSS
+ end
+ end
end
diff --git a/spec/support/stories/profile_stories.rb b/spec/support/stories/profile_stories.rb
index 07eaaca9fb93b0..45c02875a28aab 100644
--- a/spec/support/stories/profile_stories.rb
+++ b/spec/support/stories/profile_stories.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ProfileStories
- attr_reader :bob, :alice, :alice_bio
+ attr_reader :bob, :alice, :alice_bio, :chupacabra, :chupacabra_css
def fill_in_auth_details(email, password)
fill_in 'user_email', with: email
@@ -43,6 +43,21 @@ def with_alice_as_local_user
)
end
+ def with_chupacabras_fancy_profile
+ @chupacabra_css = <<~CSS
+ body {
+ background-color: red !important;
+ }
+ CSS
+
+ @chupacabra = Fabricate(
+ :user,
+ email: 'chupacabra@example.com', password: password, confirmed_at: confirmed_at,
+ account: Fabricate(:account, username: 'chupacabra', note: 'I am gonna getcha!', account_css: @chupacabra_css)
+ )
+ Web::Setting.where(user: chupacabra).first_or_initialize(user: chupacabra).update!(data: { introductionVersion: 2018_12_16_044202 })
+ end
+
def confirmed_at
@confirmed_at ||= Time.zone.now
end
diff --git a/spec/system/profile_spec.rb b/spec/system/profile_spec.rb
index 2517e823b50c5f..cb8e74285ed868 100644
--- a/spec/system/profile_spec.rb
+++ b/spec/system/profile_spec.rb
@@ -30,4 +30,21 @@
expect(subject).to have_content 'Changes successfully saved!'
end
+
+ describe 'with JS', :js, :streaming do
+ before do
+ with_chupacabras_fancy_profile
+ end
+
+ it 'Can have custom account_css set' do
+ visit account_path('chupacabra')
+ # wait for page to load...
+ page.find '.account__header'
+ expect(subject.html).to have_content('background-color: red !important')
+
+ visit account_path('bob')
+ page.find '.account__header'
+ expect(subject.html).to have_no_content('background-color: red !important')
+ end
+ end
end