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