From 0091459369f0b570e1b7d076b252a4ff741e6633 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 09:39:50 +0100 Subject: [PATCH 01/20] Update RuboCop (non-major) to v1.71.0 (#33644) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a90aef56a1..fcbe74b9c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -370,7 +370,7 @@ GEM marcel (~> 1.0.1) mime-types terrapin (>= 0.6.0, < 2.0) - language_server-protocol (3.17.0.3) + language_server-protocol (3.17.0.4) launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) @@ -716,7 +716,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) rspec-support (3.13.2) - rubocop (1.70.0) + rubocop (1.71.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -726,14 +726,14 @@ GEM rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.37.0) + rubocop-ast (1.38.0) parser (>= 3.3.1.0) rubocop-capybara (2.21.0) rubocop (~> 1.41) rubocop-performance (1.23.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.28.0) + rubocop-rails (2.29.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) From 80f72ee501980f092468f337a20efb5950e8beb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:40:50 +0100 Subject: [PATCH 02/20] New Crowdin Translations (automated) (#33753) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/cs.json | 9 +++++++++ app/javascript/mastodon/locales/es-MX.json | 4 ++-- app/javascript/mastodon/locales/ko.json | 1 + config/locales/cs.yml | 1 + config/locales/ko.yml | 3 ++- config/locales/simple_form.cs.yml | 2 ++ 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index 05fb7200b5..66cc0727b6 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -86,6 +86,13 @@ "alert.unexpected.message": "Objevila se neočekávaná chyba.", "alert.unexpected.title": "Jejda!", "alt_text_badge.title": "Popisek", + "alt_text_modal.add_alt_text": "Přidat alt text", + "alt_text_modal.add_text_from_image": "Přidat text z obrázku", + "alt_text_modal.cancel": "Zrušit", + "alt_text_modal.change_thumbnail": "Změnit miniaturu", + "alt_text_modal.describe_for_people_with_hearing_impairments": "Popište to pro osoby se sluchovým postižením…", + "alt_text_modal.describe_for_people_with_visual_impairments": "Popište to pro osoby se zrakovým postižením…", + "alt_text_modal.done": "Hotovo", "announcement.announcement": "Oznámení", "annual_report.summary.archetype.booster": "Lovec obsahu", "annual_report.summary.archetype.lurker": "Špión", @@ -407,6 +414,8 @@ "ignore_notifications_modal.not_followers_title": "Ignorovat oznámení od lidí, kteří vás nesledují?", "ignore_notifications_modal.not_following_title": "Ignorovat oznámení od lidí, které nesledujete?", "ignore_notifications_modal.private_mentions_title": "Ignorovat oznámení z nevyžádaných soukromých zmínek?", + "info_button.label": "Nápověda", + "info_button.what_is_alt_text": "

Co je to alt text?

Alt text poskytuje popis obrázků pro lidi se zrakovými postižením, špatným připojením něbo těm, kteří potřebují více kontextu.

Můžete zlepšit přístupnost a porozumění napsáním jasného, stručného a objektivního alt textu.

", "interaction_modal.action.favourite": "Chcete-li pokračovat, musíte oblíbit z vašeho účtu.", "interaction_modal.action.follow": "Chcete-li pokračovat, musíte sledovat z vašeho účtu.", "interaction_modal.action.reblog": "Chcete-li pokračovat, musíte dát boost z vašeho účtu.", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 26f070139f..473b489a0b 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -28,7 +28,7 @@ "account.enable_notifications": "Notificarme cuando @{name} publique algo", "account.endorse": "Destacar en mi perfil", "account.featured_tags.last_status_at": "Última publicación el {date}", - "account.featured_tags.last_status_never": "No hay publicaciones", + "account.featured_tags.last_status_never": "Sin publicaciones", "account.featured_tags.title": "Etiquetas destacadas de {name}", "account.follow": "Seguir", "account.follow_back": "Seguir también", @@ -146,7 +146,7 @@ "column.about": "Acerca de", "column.blocks": "Usuarios bloqueados", "column.bookmarks": "Marcadores", - "column.community": "Línea de tiempo local", + "column.community": "Cronología local", "column.create_list": "Crear lista", "column.direct": "Menciones privadas", "column.directory": "Buscar perfiles", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index d1ee08dcad..88f35cfc30 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -414,6 +414,7 @@ "ignore_notifications_modal.not_followers_title": "나를 팔로우하지 않는 사람들의 알림을 무시할까요?", "ignore_notifications_modal.not_following_title": "내가 팔로우하지 않는 사람들의 알림을 무시할까요?", "ignore_notifications_modal.private_mentions_title": "요청하지 않은 개인 멘션 알림을 무시할까요?", + "info_button.label": "도움말", "interaction_modal.action.favourite": "계속하려면 내 계정으로 즐겨찾기해야 합니다.", "interaction_modal.action.follow": "계속하려면 내 계정으로 팔로우해야 합니다.", "interaction_modal.action.reblog": "계속하려면 내 계정으로 리블로그해야 합니다.", diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 135975c3c8..3ca73a3a14 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -1247,6 +1247,7 @@ cs: too_fast: Formulář byl odeslán příliš rychle, zkuste to znovu. use_security_key: Použít bezpečnostní klíč user_agreement_html: Přečetl jsem si a souhlasím s podmínkami služby a ochranou osobních údajů + user_privacy_agreement_html: Četl jsem a souhlasím se zásadami ochrany osobních údajů author_attribution: example_title: Ukázkový text hint_html: Píšete novinové články nebo blog mimo Mastodon? Kontrolujte, jak Vám bude připisováno autorství, když jsou sdíleny na Mastodonu. diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 1575e839e1..5405cabd9b 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -302,7 +302,7 @@ ko: deleted_account: 계정을 삭제했습니다 empty: 로그를 찾을 수 없습니다 filter_by_action: 동작 별 필터 - filter_by_user: 사용자로 거르기 + filter_by_user: 사용자 기준으로 필터 title: 감사 로그 unavailable_instance: "(도메인네임 사용불가)" announcements: @@ -1192,6 +1192,7 @@ ko: too_fast: 너무 빠르게 양식이 제출되었습니다, 다시 시도하세요. use_security_key: 보안 키 사용 user_agreement_html: 이용 약관개인정보처리방침을 읽었고 동의합니다 + user_privacy_agreement_html: 개인정보처리방침을 읽었고 동의합니다 author_attribution: example_title: 예시 텍스트 hint_html: 마스토돈 밖에서 뉴스나 블로그 글을 쓰시나요? 마스토돈에 공유되었을 때 어떻게 표시될지를 제어하세요. diff --git a/config/locales/simple_form.cs.yml b/config/locales/simple_form.cs.yml index 515c7fb9e8..37ad72a7e4 100644 --- a/config/locales/simple_form.cs.yml +++ b/config/locales/simple_form.cs.yml @@ -3,6 +3,7 @@ cs: simple_form: hints: account: + attribution_domains: Jeden na řádek. Chrání před falešným připisování autorství. discoverable: Vaše veřejné příspěvky a profil mohou být zobrazeny nebo doporučeny v různých oblastech Mastodonu a váš profil může být navrhován ostatním uživatelům. display_name: Vaše celé jméno nebo přezdívka. fields: Vaše domovská stránka, zájmena, věk, cokoliv chcete. @@ -155,6 +156,7 @@ cs: url: Kam budou události odesílány labels: account: + attribution_domains: Webové stránky s povolením Vám připsat autorství discoverable: Zobrazovat profil a příspěvky ve vyhledávacích algoritmech fields: name: Označení From 32aa83e9d79c5a11ea39c90ba3d6f67f091e6499 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jan 2025 15:38:18 +0100 Subject: [PATCH 03/20] Fix polls not being validated on edition (#33755) --- app/models/poll.rb | 3 +- app/serializers/rest/instance_serializer.rb | 8 ++-- .../rest/v1/instance_serializer.rb | 8 ++-- app/validators/poll_expiration_validator.rb | 13 ++++++ ...validator.rb => poll_options_validator.rb} | 8 +--- spec/requests/api/v2/instance_spec.rb | 2 +- ...c.rb => poll_expiration_validator_spec.rb} | 16 +++++-- .../validators/poll_options_validator_spec.rb | 45 +++++++++++++++++++ 8 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 app/validators/poll_expiration_validator.rb rename app/validators/{poll_validator.rb => poll_options_validator.rb} (57%) rename spec/validators/{poll_validator_spec.rb => poll_expiration_validator_spec.rb} (64%) create mode 100644 spec/validators/poll_options_validator_spec.rb diff --git a/app/models/poll.rb b/app/models/poll.rb index 93ef0cc589..7c59339b7e 100644 --- a/app/models/poll.rb +++ b/app/models/poll.rb @@ -37,7 +37,8 @@ class Poll < ApplicationRecord validates :options, presence: true validates :expires_at, presence: true, if: :local? - validates_with PollValidator, on: :create, if: :local? + validates_with PollOptionsValidator, if: :local? + validates_with PollExpirationValidator, if: -> { local? && expires_at_changed? } before_validation :prepare_options, if: :local? before_validation :prepare_votes_count diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 936748707f..e15cba7cc8 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -87,10 +87,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer }, polls: { - max_options: PollValidator::MAX_OPTIONS, - max_characters_per_option: PollValidator::MAX_OPTION_CHARS, - min_expiration: PollValidator::MIN_EXPIRATION, - max_expiration: PollValidator::MAX_EXPIRATION, + max_options: PollOptionsValidator::MAX_OPTIONS, + max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS, + min_expiration: PollExpirationValidator::MIN_EXPIRATION, + max_expiration: PollExpirationValidator::MAX_EXPIRATION, }, translation: { diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index db83af4907..a1d51f50e4 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -70,10 +70,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer }, polls: { - max_options: PollValidator::MAX_OPTIONS, - max_characters_per_option: PollValidator::MAX_OPTION_CHARS, - min_expiration: PollValidator::MIN_EXPIRATION, - max_expiration: PollValidator::MAX_EXPIRATION, + max_options: PollOptionsValidator::MAX_OPTIONS, + max_characters_per_option: PollOptionsValidator::MAX_OPTION_CHARS, + min_expiration: PollExpirationValidator::MIN_EXPIRATION, + max_expiration: PollExpirationValidator::MAX_EXPIRATION, }, } end diff --git a/app/validators/poll_expiration_validator.rb b/app/validators/poll_expiration_validator.rb new file mode 100644 index 0000000000..ea8b08e186 --- /dev/null +++ b/app/validators/poll_expiration_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PollExpirationValidator < ActiveModel::Validator + MAX_EXPIRATION = 1.month.freeze + MIN_EXPIRATION = 5.minutes.freeze + + def validate(poll) + current_time = Time.now.utc + + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION + end +end diff --git a/app/validators/poll_validator.rb b/app/validators/poll_options_validator.rb similarity index 57% rename from app/validators/poll_validator.rb rename to app/validators/poll_options_validator.rb index a327277963..0ac84f93f4 100644 --- a/app/validators/poll_validator.rb +++ b/app/validators/poll_options_validator.rb @@ -1,19 +1,13 @@ # frozen_string_literal: true -class PollValidator < ActiveModel::Validator +class PollOptionsValidator < ActiveModel::Validator MAX_OPTIONS = 4 MAX_OPTION_CHARS = 50 - MAX_EXPIRATION = 1.month.freeze - MIN_EXPIRATION = 5.minutes.freeze def validate(poll) - current_time = Time.now.utc - poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size - poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time > MAX_EXPIRATION - poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && (poll.expires_at - current_time).ceil < MIN_EXPIRATION end end diff --git a/spec/requests/api/v2/instance_spec.rb b/spec/requests/api/v2/instance_spec.rb index bdccfdb626..788d30fa69 100644 --- a/spec/requests/api/v2/instance_spec.rb +++ b/spec/requests/api/v2/instance_spec.rb @@ -59,7 +59,7 @@ RSpec.describe 'Instances' do description_limit: MediaAttachment::MAX_DESCRIPTION_LENGTH ), polls: include( - max_options: PollValidator::MAX_OPTIONS + max_options: PollOptionsValidator::MAX_OPTIONS ) ) ) diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_expiration_validator_spec.rb similarity index 64% rename from spec/validators/poll_validator_spec.rb rename to spec/validators/poll_expiration_validator_spec.rb index f2a2534898..41b8c96211 100644 --- a/spec/validators/poll_validator_spec.rb +++ b/spec/validators/poll_expiration_validator_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -RSpec.describe PollValidator do +RSpec.describe PollExpirationValidator do describe '#validate' do before do validator.validate(poll) @@ -14,16 +14,24 @@ RSpec.describe PollValidator do let(:options) { %w(foo bar) } let(:expires_at) { 1.day.from_now } - it 'have no errors' do + it 'has no errors' do expect(errors).to_not have_received(:add) end - context 'when expires is just 5 min ago' do + context 'when the poll expires in 5 min from now' do let(:expires_at) { 5.minutes.from_now } - it 'not calls errors add' do + it 'has no errors' do expect(errors).to_not have_received(:add) end end + + context 'when the poll expires in the past' do + let(:expires_at) { 5.minutes.ago } + + it 'has errors' do + expect(errors).to have_received(:add) + end + end end end diff --git a/spec/validators/poll_options_validator_spec.rb b/spec/validators/poll_options_validator_spec.rb new file mode 100644 index 0000000000..9e4ec744db --- /dev/null +++ b/spec/validators/poll_options_validator_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PollOptionsValidator do + describe '#validate' do + before do + validator.validate(poll) + end + + let(:validator) { described_class.new } + let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) } + let(:errors) { instance_double(ActiveModel::Errors, add: nil) } + let(:options) { %w(foo bar) } + let(:expires_at) { 1.day.from_now } + + it 'has no errors' do + expect(errors).to_not have_received(:add) + end + + context 'when the poll has duplicate options' do + let(:options) { %w(foo foo) } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + + context 'when the poll has no options' do + let(:options) { [] } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + + context 'when the poll has too many options' do + let(:options) { Array.new(described_class::MAX_OPTIONS + 1) { |i| "option #{i}" } } + + it 'adds errors' do + expect(errors).to have_received(:add) + end + end + end +end From 5b291fcbe41564264954618fb1f4086a3be1c600 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jan 2025 15:44:27 +0100 Subject: [PATCH 04/20] Fix incorrect signature after HTTP redirect (#33757) --- .../concerns/signature_verification.rb | 8 +-- app/lib/http_signature_draft.rb | 31 ++++++++++ app/lib/request.rb | 60 ++++++++++--------- spec/lib/request_spec.rb | 31 ++++++++-- 4 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 app/lib/http_signature_draft.rb diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4ae63632c0..5f7ef8dd63 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -117,7 +117,7 @@ module SignatureVerification def verify_signature_strength! raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') - raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') + raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(HttpSignatureDraft::REQUEST_TARGET) || signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') end @@ -155,14 +155,14 @@ module SignatureVerification def build_signed_string(include_query_string: true) signed_headers.map do |signed_header| case signed_header - when Request::REQUEST_TARGET + when HttpSignatureDraft::REQUEST_TARGET if include_query_string - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}" else # Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header. # Therefore, temporarily support such incorrect signatures for compatibility. # TODO: remove eventually some time after release of the fixed version - "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + "#{HttpSignatureDraft::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" end when '(created)' raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' diff --git a/app/lib/http_signature_draft.rb b/app/lib/http_signature_draft.rb new file mode 100644 index 0000000000..fc0d498b29 --- /dev/null +++ b/app/lib/http_signature_draft.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# This implements an older draft of HTTP Signatures: +# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures + +class HttpSignatureDraft + REQUEST_TARGET = '(request-target)' + + def initialize(keypair, key_id, full_path: true) + @keypair = keypair + @key_id = key_id + @full_path = full_path + end + + def request_target(verb, url) + if url.query.nil? || !@full_path + "#{verb} #{url.path}" + else + "#{verb} #{url.path}?#{url.query}" + end + end + + def sign(signed_headers, verb, url) + signed_headers = signed_headers.merge(REQUEST_TARGET => request_target(verb, url)) + signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") + algorithm = 'rsa-sha256' + signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) + + "keyId=\"#{@key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + end +end diff --git a/app/lib/request.rb b/app/lib/request.rb index f984f0e63e..4e86cc2fdf 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -61,8 +61,6 @@ class PerOperationWithDeadline < HTTP::Timeout::PerOperation end class Request - REQUEST_TARGET = '(request-target)' - # We enforce a 5s timeout on DNS resolving, 5s timeout on socket opening # and 5s timeout on the TLS handshake, meaning the worst case should take # about 15s in total @@ -78,11 +76,18 @@ class Request @http_client = options.delete(:http_client) @allow_local = options.delete(:allow_local) @full_path = !options.delete(:omit_query_string) - @options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket) - @options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT) + @options = { + follow: { + max_hops: 3, + on_redirect: ->(response, request) { re_sign_on_redirect(response, request) }, + }, + socket_class: use_proxy? || @allow_local ? ProxySocket : Socket, + }.merge(options) @options = @options.merge(proxy_url) if use_proxy? @headers = {} + @signing = nil + raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service? set_common_headers! @@ -92,8 +97,9 @@ class Request def on_behalf_of(actor, sign_with: nil) raise ArgumentError, 'actor must not be nil' if actor.nil? - @actor = actor - @keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : @actor.keypair + key_id = ActivityPub::TagManager.instance.key_uri_for(actor) + keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair + @signing = HttpSignatureDraft.new(keypair, key_id, full_path: @full_path) self end @@ -119,7 +125,7 @@ class Request end def headers - (@actor ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET) + (@signing ? @headers.merge('Signature' => signature) : @headers) end class << self @@ -134,14 +140,13 @@ class Request end def http_client - HTTP.use(:auto_inflate).follow(max_hops: 3) + HTTP.use(:auto_inflate) end end private def set_common_headers! - @headers[REQUEST_TARGET] = request_target @headers['User-Agent'] = Mastodon::Version.user_agent @headers['Host'] = @url.host @headers['Date'] = Time.now.utc.httpdate @@ -152,31 +157,28 @@ class Request @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" end - def request_target - if @url.query.nil? || !@full_path - "#{@verb} #{@url.path}" - else - "#{@verb} #{@url.path}?#{@url.query}" - end - end - def signature - algorithm = 'rsa-sha256' - signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string)) - - "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\"" + @signing.sign(@headers.without('User-Agent', 'Accept-Encoding'), @verb, @url) end - def signed_string - signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") - end + def re_sign_on_redirect(_response, request) + # Delete existing signature if there is one, since it will be invalid + request.headers.delete('Signature') - def signed_headers - @headers.without('User-Agent', 'Accept-Encoding') - end + return unless @signing.present? && @verb == :get - def key_id - ActivityPub::TagManager.instance.key_uri_for(@actor) + signed_headers = request.headers.to_h.slice(*@headers.keys) + unless @headers.keys.all? { |key| signed_headers.key?(key) } + # We have lost some headers in the process, so don't sign the new + # request, in order to avoid issuing a valid signature with fewer + # conditions than expected. + + Rails.logger.warn { "Some headers (#{@headers.keys - signed_headers.keys}) have been lost on redirect from {@uri} to #{request.uri}, this should not happen. Skipping signatures" } + return + end + + signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri)) + request.headers['Signature'] = signature_value end def http_client diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb index f17cf637b9..79c400e098 100644 --- a/spec/lib/request_spec.rb +++ b/spec/lib/request_spec.rb @@ -60,16 +60,12 @@ RSpec.describe Request do expect(a_request(:get, 'http://example.com')).to have_been_made.once end - it 'sets headers' do - expect { |block| subject.perform(&block) }.to yield_control - expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made - end - - it 'closes underlying connection' do + it 'makes a request with expected headers, yields, and closes the underlying connection' do allow(subject.send(:http_client)).to receive(:close) expect { |block| subject.perform(&block) }.to yield_control + expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made expect(subject.send(:http_client)).to have_received(:close) end @@ -80,6 +76,29 @@ RSpec.describe Request do end end + context 'with a redirect and HTTP signatures' do + let(:account) { Fabricate(:account) } + + before do + stub_request(:get, 'http://example.com').to_return(status: 301, headers: { Location: 'http://redirected.example.com/foo' }) + stub_request(:get, 'http://redirected.example.com/foo').to_return(body: 'lorem ipsum') + end + + it 'makes a request with expected headers and follows redirects' do + expect { |block| subject.on_behalf_of(account).perform(&block) }.to yield_control + + # request.headers includes the `Signature` sent for the first request + expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made.once + + # request.headers includes the `Signature`, but it has changed + expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.merge({ 'Host' => 'redirected.example.com' }))).to_not have_been_made + + # `with(headers: )` matching tests for inclusion, so strip `Signature` + # This doesn't actually test that there is a signature, but it tests that the original signature is not passed + expect(a_request(:get, 'http://redirected.example.com/foo').with(headers: subject.headers.without('Signature').merge({ 'Host' => 'redirected.example.com' }))).to have_been_made.once + end + end + context 'with private host' do around do |example| WebMock.disable! From 8e24c4801d27eb31b0c86cd9a6e758edfdbb19b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:27:27 +0100 Subject: [PATCH 05/20] Update dependency opentelemetry-instrumentation-rails to v0.35.1 (#33767) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index fcbe74b9c0..8547e4fba1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -530,7 +530,7 @@ GEM opentelemetry-instrumentation-rack (0.26.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rails (0.35.0) + opentelemetry-instrumentation-rails (0.35.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_mailer (~> 0.4.0) opentelemetry-instrumentation-action_pack (~> 0.11.0) From 9c85825ac6f080f21d03fc219249c13c440b79a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:38:53 +0100 Subject: [PATCH 06/20] New Crowdin Translations (automated) (#33766) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/sk.json | 4 ++++ config/locales/nn.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index 2bb214b887..5e7385bdc4 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -86,6 +86,9 @@ "alert.unexpected.message": "Vyskytla sa nečakaná chyba.", "alert.unexpected.title": "Ups!", "alt_text_badge.title": "Alternatívny popis", + "alt_text_modal.add_text_from_image": "Pridaj text z obrázka", + "alt_text_modal.cancel": "Zrušiť", + "alt_text_modal.done": "Hotovo", "announcement.announcement": "Oznámenie", "annual_report.summary.archetype.oracle": "Veštec", "annual_report.summary.followers.followers": "sledovatelia", @@ -378,6 +381,7 @@ "ignore_notifications_modal.not_followers_title": "Nevšímať si oznámenia od ľudí, ktorí ťa nenasledujú?", "ignore_notifications_modal.not_following_title": "Nevšímať si oznámenia od ľudí, ktorých nenasleduješ?", "ignore_notifications_modal.private_mentions_title": "Nevšímať si oznámenia o nevyžiadaných súkromných spomínaniach?", + "info_button.label": "Pomoc", "interaction_modal.action.favourite": "Pre pokračovanie si musíš obľúbiť zo svojho účtu.", "interaction_modal.action.follow": "Pre pokračovanie musíš nasledovať zo svojho účtu.", "interaction_modal.action.reply": "Pre pokračovanie musíš odpovedať s tvojho účtu.", diff --git a/config/locales/nn.yml b/config/locales/nn.yml index 2f652a646c..b7982e93ca 100644 --- a/config/locales/nn.yml +++ b/config/locales/nn.yml @@ -1209,6 +1209,7 @@ nn: too_fast: Skjemaet ble sendt inn for raskt, prøv på nytt. use_security_key: Bruk sikkerhetsnøkkel user_agreement_html: Eg godtek bruksvilkåra og personvernvllkåra + user_privacy_agreement_html: Eg har lese og godtar personvernerklæringa author_attribution: example_title: Eksempeltekst hint_html: Skriv du nyhende eller blogginnlegg utanfor Mastodon? Her kan du kontrollera korleis du blir kreditert når artiklane dine blir delte på Mastodon. From bd481204b539348858d30bca845882d9d07f518c Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 29 Jan 2025 09:42:20 +0100 Subject: [PATCH 07/20] Fix missing timeout options in `Request` class (#33769) --- app/lib/request.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/request.rb b/app/lib/request.rb index 4e86cc2fdf..4e0ba77833 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -81,8 +81,11 @@ class Request max_hops: 3, on_redirect: ->(response, request) { re_sign_on_redirect(response, request) }, }, + }.merge(options).merge( socket_class: use_proxy? || @allow_local ? ProxySocket : Socket, - }.merge(options) + timeout_class: PerOperationWithDeadline, + timeout_options: TIMEOUT + ) @options = @options.merge(proxy_url) if use_proxy? @headers = {} From 82183d8a79979a738304c73f6808794d6f5d442f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 29 Jan 2025 09:46:04 +0100 Subject: [PATCH 08/20] Add loading indicator to timeline gap indicators in web UI (#33762) --- .../mastodon/components/load_gap.tsx | 14 +++++++++++--- .../styles/mastodon/components.scss | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/javascript/mastodon/components/load_gap.tsx b/app/javascript/mastodon/components/load_gap.tsx index 544b5e1461..6cbdee6ce5 100644 --- a/app/javascript/mastodon/components/load_gap.tsx +++ b/app/javascript/mastodon/components/load_gap.tsx @@ -1,9 +1,10 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; const messages = defineMessages({ load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, @@ -17,10 +18,12 @@ interface Props { export const LoadGap = ({ disabled, param, onClick }: Props) => { const intl = useIntl(); + const [loading, setLoading] = useState(false); const handleClick = useCallback(() => { + setLoading(true); onClick(param); - }, [param, onClick]); + }, [setLoading, param, onClick]); return ( ); }; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ed23a88d41..75c38d91f2 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4028,23 +4028,27 @@ a.status-card { } .load-more { - display: block; + display: flex; + align-items: center; + justify-content: center; color: $dark-text-color; background-color: transparent; border: 0; font-size: inherit; - text-align: center; line-height: inherit; - margin: 0; + width: 100%; padding: 15px; box-sizing: border-box; - width: 100%; - clear: both; text-decoration: none; &:hover { background: var(--on-surface-color); } + + .icon { + width: 22px; + height: 22px; + } } .load-gap { @@ -4421,6 +4425,7 @@ a.status-card { justify-content: center; } +.load-more .loading-indicator, .button .loading-indicator { position: static; transform: none; @@ -4432,6 +4437,10 @@ a.status-card { } } +.load-more .loading-indicator .circular-progress { + color: lighten($ui-base-color, 26%); +} + .circular-progress { color: lighten($ui-base-color, 26%); animation: 1.4s linear 0s infinite normal none running simple-rotate; From 85668becdee8eb91bd999a5a58d01979c532e1c0 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 29 Jan 2025 10:26:06 +0100 Subject: [PATCH 09/20] Change language detection debouncing behavior to refresh at least once every 1.5 seconds (#33770) --- .../mastodon/features/compose/util/language_detection.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/compose/util/language_detection.js b/app/javascript/mastodon/features/compose/util/language_detection.js index b3be07d516..ed22a2bd9c 100644 --- a/app/javascript/mastodon/features/compose/util/language_detection.js +++ b/app/javascript/mastodon/features/compose/util/language_detection.js @@ -73,4 +73,4 @@ const guessLanguage = (text) => { export const debouncedGuess = debounce((text, setGuess) => { setGuess(guessLanguage(text)); -}, 500, { leading: true, trailing: true }); \ No newline at end of file +}, 500, { maxWait: 1500, leading: true, trailing: true }); From 51bbca7723806d55f9013a50b3f9b007feba573a Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 29 Jan 2025 11:15:32 +0100 Subject: [PATCH 10/20] =?UTF-8?q?Fix=20=E2=80=9Cx=E2=80=9D=20hotkey=20not?= =?UTF-8?q?=20working=20on=20boosted=20filtered=20posts=20(#33758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/javascript/mastodon/components/status.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index fb1fcb87fe..f44b0f0535 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -333,7 +333,7 @@ class Status extends ImmutablePureComponent { const { onToggleHidden } = this.props; const status = this._properStatus(); - if (status.get('matched_filters')) { + if (this.props.status.get('matched_filters')) { const expandedBecauseOfCW = !status.get('hidden') || status.get('spoiler_text').length === 0; const expandedBecauseOfFilter = this.state.showDespiteFilter; From 6aa565b3191b9f0181206e72b0881232aa40cbf9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 29 Jan 2025 11:36:24 +0100 Subject: [PATCH 11/20] Fix missing button styles on some forms (#33771) --- app/views/admin/invites/_invite.html.haml | 2 +- app/views/invites/_invite.html.haml | 2 +- app/views/mail_subscriptions/show.html.haml | 3 ++- app/views/oauth/authorizations/new.html.haml | 5 +++-- app/views/oauth/authorizations/show.html.haml | 2 +- app/views/settings/verifications/show.html.haml | 4 ++-- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml index e3e5d32542..fcaf5fc616 100644 --- a/app/views/admin/invites/_invite.html.haml +++ b/app/views/admin/invites/_invite.html.haml @@ -3,7 +3,7 @@ .input-copy .input-copy__wrapper = copyable_input value: public_invite_url(invite_code: invite.code) - %button{ type: :button }= t('generic.copy') + %button.button{ type: :button }= t('generic.copy') %td .name-tag diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml index 7c94062de4..88479c4cad 100644 --- a/app/views/invites/_invite.html.haml +++ b/app/views/invites/_invite.html.haml @@ -3,7 +3,7 @@ .input-copy .input-copy__wrapper = copyable_input value: public_invite_url(invite_code: invite.code) - %button{ type: :button }= t('generic.copy') + %button.button{ type: :button }= t('generic.copy') - if invite.valid_for_use? %td diff --git a/app/views/mail_subscriptions/show.html.haml b/app/views/mail_subscriptions/show.html.haml index a09dacc4d3..78de486457 100644 --- a/app/views/mail_subscriptions/show.html.haml +++ b/app/views/mail_subscriptions/show.html.haml @@ -16,4 +16,5 @@ = form.hidden_field :type, value: params[:type] = form.button t('mail_subscriptions.unsubscribe.action'), - type: :submit + type: :submit, + class: 'btn' diff --git a/app/views/oauth/authorizations/new.html.haml b/app/views/oauth/authorizations/new.html.haml index ca9d12a676..17228d3cae 100644 --- a/app/views/oauth/authorizations/new.html.haml +++ b/app/views/oauth/authorizations/new.html.haml @@ -35,7 +35,8 @@ = form.hidden_field :scope, value: @pre_auth.scope = form.button t('doorkeeper.authorizations.buttons.authorize'), - type: :submit + type: :submit, + class: 'btn' = form_with url: oauth_authorization_path, method: :delete do |form| = form.hidden_field :client_id, @@ -52,4 +53,4 @@ value: @pre_auth.scope = form.button t('doorkeeper.authorizations.buttons.deny'), type: :submit, - class: 'negative' + class: 'btn negative' diff --git a/app/views/oauth/authorizations/show.html.haml b/app/views/oauth/authorizations/show.html.haml index bdff336368..f014a1052f 100644 --- a/app/views/oauth/authorizations/show.html.haml +++ b/app/views/oauth/authorizations/show.html.haml @@ -4,4 +4,4 @@ .input-copy .input-copy__wrapper = copyable_input value: params[:code], class: 'oauth-code' - %button{ type: :button }= t('generic.copy') + %button.button{ type: :button }= t('generic.copy') diff --git a/app/views/settings/verifications/show.html.haml b/app/views/settings/verifications/show.html.haml index c569843793..0243e3b806 100644 --- a/app/views/settings/verifications/show.html.haml +++ b/app/views/settings/verifications/show.html.haml @@ -17,7 +17,7 @@ .input-copy.lead .input-copy__wrapper = copyable_input value: link_to('Mastodon', ActivityPub::TagManager.instance.url_for(@account), rel: :me) - %button{ type: :button }= t('generic.copy') + %button.button{ type: :button }= t('generic.copy') %p.lead= t('verification.extra_instructions_html') @@ -60,7 +60,7 @@ .input-copy.lead .input-copy__wrapper = copyable_input value: tag.meta(name: 'fediverse:creator', content: "@#{@account.local_username_and_domain}") - %button{ type: :button }= t('generic.copy') + %button.button{ type: :button }= t('generic.copy') %p.lead= t('author_attribution.then_instructions') From 2beab34ca405a0beb3ea9f5ab684779dc2eb6374 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 29 Jan 2025 05:54:20 -0500 Subject: [PATCH 12/20] Convert `admin/email_domain_blocks` controller -> system spec (#33759) --- .../email_domain_blocks_controller_spec.rb | 68 ------------------- spec/support/domain_helpers.rb | 19 ++++++ spec/system/admin/email_domain_blocks_spec.rb | 37 ++++++++++ 3 files changed, 56 insertions(+), 68 deletions(-) delete mode 100644 spec/controllers/admin/email_domain_blocks_controller_spec.rb diff --git a/spec/controllers/admin/email_domain_blocks_controller_spec.rb b/spec/controllers/admin/email_domain_blocks_controller_spec.rb deleted file mode 100644 index f274c01281..0000000000 --- a/spec/controllers/admin/email_domain_blocks_controller_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Admin::EmailDomainBlocksController do - render_views - - before do - sign_in Fabricate(:admin_user), scope: :user - end - - describe 'GET #index' do - around do |example| - default_per_page = EmailDomainBlock.default_per_page - EmailDomainBlock.paginates_per 2 - example.run - EmailDomainBlock.paginates_per default_per_page - end - - it 'returns http success' do - 2.times { Fabricate(:email_domain_block) } - Fabricate(:email_domain_block, allow_with_approval: true) - get :index, params: { page: 2 } - expect(response).to have_http_status(200) - end - end - - describe 'GET #new' do - it 'returns http success' do - get :new - expect(response).to have_http_status(200) - end - end - - describe 'POST #create' do - context 'when resolve button is pressed' do - before do - resolver = instance_double(Resolv::DNS) - - allow(resolver).to receive(:getresources) - .with('example.com', Resolv::DNS::Resource::IN::MX) - .and_return([]) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) - allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) - allow(resolver).to receive(:timeouts=).and_return(nil) - allow(Resolv::DNS).to receive(:open).and_yield(resolver) - - post :create, params: { email_domain_block: { domain: 'example.com' } } - end - - it 'renders new template' do - expect(response).to render_template(:new) - end - end - - context 'when save button is pressed' do - before do - post :create, params: { email_domain_block: { domain: 'example.com' }, save: '' } - end - - it 'blocks the domain and redirects to email domain blocks' do - expect(EmailDomainBlock.find_by(domain: 'example.com')).to_not be_nil - - expect(response).to redirect_to(admin_email_domain_blocks_path) - end - end - end -end diff --git a/spec/support/domain_helpers.rb b/spec/support/domain_helpers.rb index 9977702099..051cdaa7d2 100644 --- a/spec/support/domain_helpers.rb +++ b/spec/support/domain_helpers.rb @@ -28,6 +28,25 @@ module DomainHelpers .and_yield(resolver) end + def configure_dns(domain:, results:) + resolver = instance_double(Resolv::DNS, :timeouts= => nil) + + allow(resolver).to receive(:getresources) + .with(domain, Resolv::DNS::Resource::IN::MX) + .and_return(results) + allow(resolver) + .to receive(:getresources) + .with(domain, Resolv::DNS::Resource::IN::A) + .and_return(results) + allow(resolver) + .to receive(:getresources) + .with(domain, Resolv::DNS::Resource::IN::AAAA) + .and_return(results) + allow(Resolv::DNS) + .to receive(:open) + .and_yield(resolver) + end + private def double_mx(exchange) diff --git a/spec/system/admin/email_domain_blocks_spec.rb b/spec/system/admin/email_domain_blocks_spec.rb index 807cfb3768..e88811ac49 100644 --- a/spec/system/admin/email_domain_blocks_spec.rb +++ b/spec/system/admin/email_domain_blocks_spec.rb @@ -7,6 +7,43 @@ RSpec.describe 'Admin::EmailDomainBlocks' do before { sign_in current_user } + describe 'Managing email domain blocks' do + before { configure_dns(domain: 'example.com', results: []) } + + let!(:email_domain_block) { Fabricate :email_domain_block } + + it 'views and creates new blocks' do + visit admin_email_domain_blocks_path + expect(page) + .to have_content(I18n.t('admin.email_domain_blocks.title')) + .and have_content(email_domain_block.domain) + + click_on I18n.t('admin.email_domain_blocks.add_new') + expect(page) + .to have_content(I18n.t('admin.email_domain_blocks.new.title')) + + fill_in I18n.t('admin.email_domain_blocks.domain'), with: 'example.com' + expect { submit_resolve } + .to_not change(EmailDomainBlock, :count) + expect(page) + .to have_content(I18n.t('admin.email_domain_blocks.new.title')) + + expect { submit_create } + .to change(EmailDomainBlock.where(domain: 'example.com'), :count).by(1) + expect(page) + .to have_content(I18n.t('admin.email_domain_blocks.title')) + .and have_content(I18n.t('admin.email_domain_blocks.created_msg')) + end + + def submit_resolve + click_on I18n.t('admin.email_domain_blocks.new.resolve') + end + + def submit_create + click_on I18n.t('admin.email_domain_blocks.new.create') + end + end + describe 'Performing batch updates' do before do visit admin_email_domain_blocks_path From 1e70da5e3c279c8e632abb6bdf90b1ddc67b035e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 29 Jan 2025 12:37:56 +0100 Subject: [PATCH 13/20] Add reminder when about to post without alt text in web UI (#33760) --- .../compose/components/compose_form.jsx | 5 +- .../containers/compose_form_container.js | 16 +++- .../confirmation_modal.tsx | 17 ++-- .../components/confirmation_modals/index.ts | 1 + .../confirmation_modals/missing_alt_text.tsx | 81 +++++++++++++++++++ .../features/ui/components/modal_root.jsx | 2 + app/javascript/mastodon/initial_state.js | 2 + app/javascript/mastodon/locales/en.json | 4 + app/models/user_settings.rb | 1 + app/serializers/initial_state_serializer.rb | 3 +- .../preferences/appearance/show.html.haml | 1 + config/locales/simple_form.en.yml | 1 + 12 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modals/missing_alt_text.tsx diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 468125afb0..2cd88aab41 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -10,6 +10,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; +import { missingAltTextModal } from 'mastodon/initial_state'; + import AutosuggestInput from '../../../components/autosuggest_input'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import { Button } from '../../../components/button'; @@ -65,6 +67,7 @@ class ComposeForm extends ImmutablePureComponent { autoFocus: PropTypes.bool, withoutNavigation: PropTypes.bool, anyMedia: PropTypes.bool, + missingAltText: PropTypes.bool, isInReply: PropTypes.bool, singleColumn: PropTypes.bool, lang: PropTypes.string, @@ -117,7 +120,7 @@ class ComposeForm extends ImmutablePureComponent { return; } - this.props.onSubmit(); + this.props.onSubmit(missingAltTextModal && this.props.missingAltText); if (e) { e.preventDefault(); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index bda2edba60..15ccabf748 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -9,7 +9,9 @@ import { changeComposeSpoilerText, insertEmojiCompose, uploadCompose, -} from '../../../actions/compose'; +} from 'mastodon/actions/compose'; +import { openModal } from 'mastodon/actions/modal'; + import ComposeForm from '../components/compose_form'; const mapStateToProps = state => ({ @@ -26,6 +28,7 @@ const mapStateToProps = state => ({ isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isUploading: state.getIn(['compose', 'is_uploading']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + missingAltText: state.getIn(['compose', 'media_attachments']).some(media => ['image', 'gifv'].includes(media.get('type')) && (media.get('description') ?? '').length === 0), isInReply: state.getIn(['compose', 'in_reply_to']) !== null, lang: state.getIn(['compose', 'language']), maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500), @@ -37,8 +40,15 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(changeCompose(text)); }, - onSubmit () { - dispatch(submitCompose()); + onSubmit (missingAltText) { + if (missingAltText) { + dispatch(openModal({ + modalType: 'CONFIRM_MISSING_ALT_TEXT', + modalProps: {}, + })); + } else { + dispatch(submitCompose()); + } }, onClearSuggestions () { diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index ab567c697a..929b1a240f 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -56,14 +56,6 @@ export const ConfirmationModal: React.FC<
- {secondary && ( - <> - - -
- - )} - + {secondary && ( + <> +
+ + + )} + {/* eslint-disable-next-line jsx-a11y/no-autofocus -- we are in a modal and thus autofocusing is justified */} @@ -47,9 +54,12 @@ export const AltTextBadge: React.FC<{ > {({ props }) => (
-