From 7de301922b691d522e758e2c4eb760ec783b0ab2 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Fri, 20 Feb 2026 15:27:50 +0100 Subject: [PATCH 1/7] Re-use custom socket class for FASP requests (#37925) --- app/lib/fasp/request.rb | 2 +- app/lib/request.rb | 2 +- spec/lib/fasp/request_spec.rb | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/lib/fasp/request.rb b/app/lib/fasp/request.rb index 2002e90bb0..cf2324212f 100644 --- a/app/lib/fasp/request.rb +++ b/app/lib/fasp/request.rb @@ -29,7 +29,7 @@ class Fasp::Request response = HTTP .headers(headers) .use(http_signature: { key:, covered_components: COVERED_COMPONENTS }) - .send(verb, url, body:) + .send(verb, url, body:, socket_class: ::Request::Socket) validate!(response) diff --git a/app/lib/request.rb b/app/lib/request.rb index 4858aa4bc2..59c0e72526 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -349,5 +349,5 @@ class Request end end - private_constant :ClientLimit, :Socket, :ProxySocket + private_constant :ClientLimit end diff --git a/spec/lib/fasp/request_spec.rb b/spec/lib/fasp/request_spec.rb index 9b354c8f44..171d03bdbd 100644 --- a/spec/lib/fasp/request_spec.rb +++ b/spec/lib/fasp/request_spec.rb @@ -55,6 +55,24 @@ RSpec.describe Fasp::Request do end end end + + context 'when the provider host name resolves to a private address' do + around do |example| + WebMock.disable! + example.run + WebMock.enable! + end + + it 'raises Mastodon::ValidationError' do + resolver = instance_double(Resolv::DNS) + + allow(resolver).to receive(:getaddresses).with('reqprov.example.com').and_return(%w(0.0.0.0 2001:db8::face)) + allow(resolver).to receive(:timeouts=).and_return(nil) + allow(Resolv::DNS).to receive(:open).and_yield(resolver) + + expect { subject.send(method, '/test_path') }.to raise_error(Mastodon::ValidationError) + end + end end describe '#get' do From b89d6e256bc670142f4839aa96f1137c5afc1663 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Fri, 20 Feb 2026 15:40:31 +0100 Subject: [PATCH 2/7] Reject unconfirmed FASPs (#37926) --- app/controllers/api/fasp/base_controller.rb | 2 +- app/models/fasp/provider.rb | 1 + .../data_sharing/v0/backfill_requests_spec.rb | 37 ++++++------ .../data_sharing/v0/continuations_spec.rb | 21 ++++--- .../v0/event_subscriptions_spec.rb | 58 ++++++++++--------- .../fasp/debug/v0/callback/responses_spec.rb | 25 ++++---- spec/support/examples/fasp/api.rb | 13 +++++ ...nce_account_lifecycle_event_worker_spec.rb | 5 +- ...nce_content_lifecycle_event_worker_spec.rb | 5 +- .../fasp/announce_trend_worker_spec.rb | 3 +- spec/workers/fasp/backfill_worker_spec.rb | 6 +- 11 files changed, 106 insertions(+), 70 deletions(-) create mode 100644 spec/support/examples/fasp/api.rb diff --git a/app/controllers/api/fasp/base_controller.rb b/app/controllers/api/fasp/base_controller.rb index f786ea1767..a05d0049ba 100644 --- a/app/controllers/api/fasp/base_controller.rb +++ b/app/controllers/api/fasp/base_controller.rb @@ -47,7 +47,7 @@ class Api::Fasp::BaseController < ApplicationController provider = nil Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid| - provider = Fasp::Provider.find(keyid) + provider = Fasp::Provider.confirmed.find(keyid) Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid) end diff --git a/app/models/fasp/provider.rb b/app/models/fasp/provider.rb index 37d0b581ca..65a6084650 100644 --- a/app/models/fasp/provider.rb +++ b/app/models/fasp/provider.rb @@ -34,6 +34,7 @@ class Fasp::Provider < ApplicationRecord before_create :create_keypair after_commit :update_remote_capabilities + scope :confirmed, -> { where(confirmed: true) } scope :with_capability, lambda { |capability_name| where('fasp_providers.capabilities @> ?::jsonb', "[{\"id\": \"#{capability_name}\", \"enabled\": true}]") } diff --git a/spec/requests/api/fasp/data_sharing/v0/backfill_requests_spec.rb b/spec/requests/api/fasp/data_sharing/v0/backfill_requests_spec.rb index 2d1f1d6417..6306fdb02f 100644 --- a/spec/requests/api/fasp/data_sharing/v0/backfill_requests_spec.rb +++ b/spec/requests/api/fasp/data_sharing/v0/backfill_requests_spec.rb @@ -6,34 +6,33 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::BackfillRequests', feature: :fasp do include ProviderRequestHelper describe 'POST /api/fasp/data_sharing/v0/backfill_requests' do - let(:provider) { Fabricate(:fasp_provider) } + subject do + post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json + end + + let(:provider) { Fabricate(:confirmed_fasp) } + let(:params) { { category: 'content', maxCount: 10 } } + let(:headers) do + request_authentication_headers(provider, + url: api_fasp_data_sharing_v0_backfill_requests_url, + method: :post, + body: params) + end + + it_behaves_like 'forbidden for unconfirmed provider' context 'with valid parameters' do it 'creates a new backfill request' do - params = { category: 'content', maxCount: 10 } - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_backfill_requests_url, - method: :post, - body: params) - - expect do - post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json - end.to change(Fasp::BackfillRequest, :count).by(1) + expect { subject }.to change(Fasp::BackfillRequest, :count).by(1) expect(response).to have_http_status(201) end end context 'with invalid parameters' do - it 'does not create a backfill request' do - params = { category: 'unknown', maxCount: 10 } - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_backfill_requests_url, - method: :post, - body: params) + let(:params) { { category: 'unknown', maxCount: 10 } } - expect do - post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json - end.to_not change(Fasp::BackfillRequest, :count) + it 'does not create a backfill request' do + expect { subject }.to_not change(Fasp::BackfillRequest, :count) expect(response).to have_http_status(422) end end diff --git a/spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb b/spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb index 59ab44d0c4..12ce5124b0 100644 --- a/spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb +++ b/spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb @@ -6,15 +6,22 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::Continuations', feature: :fasp do include ProviderRequestHelper describe 'POST /api/fasp/data_sharing/v0/backfill_requests/:id/continuations' do - let(:backfill_request) { Fabricate(:fasp_backfill_request) } - let(:provider) { backfill_request.fasp_provider } + subject do + post api_fasp_data_sharing_v0_backfill_request_continuation_path(backfill_request), headers:, as: :json + end + + let(:provider) { Fabricate(:confirmed_fasp) } + let(:backfill_request) { Fabricate(:fasp_backfill_request, fasp_provider: provider) } + let(:headers) do + request_authentication_headers(provider, + url: api_fasp_data_sharing_v0_backfill_request_continuation_url(backfill_request), + method: :post) + end + + it_behaves_like 'forbidden for unconfirmed provider' it 'queues a job to continue the given backfill request' do - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_backfill_request_continuation_url(backfill_request), - method: :post) - - post api_fasp_data_sharing_v0_backfill_request_continuation_path(backfill_request), headers:, as: :json + subject expect(response).to have_http_status(204) expect(Fasp::BackfillWorker).to have_enqueued_sidekiq_job(backfill_request.id) end diff --git a/spec/requests/api/fasp/data_sharing/v0/event_subscriptions_spec.rb b/spec/requests/api/fasp/data_sharing/v0/event_subscriptions_spec.rb index beab9e326f..4b7ec5d59c 100644 --- a/spec/requests/api/fasp/data_sharing/v0/event_subscriptions_spec.rb +++ b/spec/requests/api/fasp/data_sharing/v0/event_subscriptions_spec.rb @@ -6,51 +6,57 @@ RSpec.describe 'Api::Fasp::DataSharing::V0::EventSubscriptions', feature: :fasp include ProviderRequestHelper describe 'POST /api/fasp/data_sharing/v0/event_subscriptions' do - let(:provider) { Fabricate(:fasp_provider) } + subject do + post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json + end + + let(:provider) { Fabricate(:confirmed_fasp) } + let(:params) { { category: 'content', subscriptionType: 'lifecycle', maxBatchSize: 10 } } + let(:headers) do + request_authentication_headers(provider, + url: api_fasp_data_sharing_v0_event_subscriptions_url, + method: :post, + body: params) + end + + it_behaves_like 'forbidden for unconfirmed provider' context 'with valid parameters' do it 'creates a new subscription' do - params = { category: 'content', subscriptionType: 'lifecycle', maxBatchSize: 10 } - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_event_subscriptions_url, - method: :post, - body: params) - expect do - post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json + subject end.to change(Fasp::Subscription, :count).by(1) expect(response).to have_http_status(201) end end context 'with invalid parameters' do - it 'does not create a subscription' do - params = { category: 'unknown' } - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_event_subscriptions_url, - method: :post, - body: params) + let(:params) { { category: 'unknown' } } - expect do - post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json - end.to_not change(Fasp::Subscription, :count) + it 'does not create a subscription' do + expect { subject }.to_not change(Fasp::Subscription, :count) expect(response).to have_http_status(422) end end end describe 'DELETE /api/fasp/data_sharing/v0/event_subscriptions/:id' do - let(:subscription) { Fabricate(:fasp_subscription) } - let(:provider) { subscription.fasp_provider } + subject do + delete api_fasp_data_sharing_v0_event_subscription_path(subscription), headers:, as: :json + end + + let(:provider) { Fabricate(:confirmed_fasp) } + let!(:subscription) { Fabricate(:fasp_subscription, fasp_provider: provider) } + let(:headers) do + request_authentication_headers(provider, + url: api_fasp_data_sharing_v0_event_subscription_url(subscription), + method: :delete) + end + + it_behaves_like 'forbidden for unconfirmed provider' it 'deletes the subscription' do - headers = request_authentication_headers(provider, - url: api_fasp_data_sharing_v0_event_subscription_url(subscription), - method: :delete) - - expect do - delete api_fasp_data_sharing_v0_event_subscription_path(subscription), headers:, as: :json - end.to change(Fasp::Subscription, :count).by(-1) + expect { subject }.to change(Fasp::Subscription, :count).by(-1) expect(response).to have_http_status(204) end end diff --git a/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb index 58c5e8897b..bcd6b74f79 100644 --- a/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb +++ b/spec/requests/api/fasp/debug/v0/callback/responses_spec.rb @@ -6,18 +6,23 @@ RSpec.describe 'Api::Fasp::Debug::V0::Callback::Responses', feature: :fasp do include ProviderRequestHelper describe 'POST /api/fasp/debug/v0/callback/responses' do - let(:provider) { Fabricate(:debug_fasp) } + subject do + post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json + end + + let(:provider) { Fabricate(:confirmed_fasp) } + let(:payload) { { test: 'call' } } + let(:headers) do + request_authentication_headers(provider, + url: api_fasp_debug_v0_callback_responses_url, + method: :post, + body: payload) + end + + it_behaves_like 'forbidden for unconfirmed provider' it 'create a record of the callback' do - payload = { test: 'call' } - headers = request_authentication_headers(provider, - url: api_fasp_debug_v0_callback_responses_url, - method: :post, - body: payload) - - expect do - post api_fasp_debug_v0_callback_responses_path, headers:, params: payload, as: :json - end.to change(Fasp::DebugCallback, :count).by(1) + expect { subject }.to change(Fasp::DebugCallback, :count).by(1) expect(response).to have_http_status(201) debug_callback = Fasp::DebugCallback.last diff --git a/spec/support/examples/fasp/api.rb b/spec/support/examples/fasp/api.rb new file mode 100644 index 0000000000..6d28933c6e --- /dev/null +++ b/spec/support/examples/fasp/api.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'forbidden for unconfirmed provider' do + context 'when the requesting provider is unconfirmed' do + let(:provider) { Fabricate(:fasp_provider) } + + it 'returns http unauthorized' do + subject + + expect(response).to have_http_status(401) + end + end +end diff --git a/spec/workers/fasp/announce_account_lifecycle_event_worker_spec.rb b/spec/workers/fasp/announce_account_lifecycle_event_worker_spec.rb index 0d4a870875..f3917d0d53 100644 --- a/spec/workers/fasp/announce_account_lifecycle_event_worker_spec.rb +++ b/spec/workers/fasp/announce_account_lifecycle_event_worker_spec.rb @@ -6,10 +6,11 @@ RSpec.describe Fasp::AnnounceAccountLifecycleEventWorker do include ProviderRequestHelper let(:account_uri) { 'https://masto.example.com/accounts/1' } + let(:provider) { Fabricate(:confirmed_fasp) } let(:subscription) do - Fabricate(:fasp_subscription, category: 'account') + Fabricate(:fasp_subscription, fasp_provider: provider, category: 'account') end - let(:provider) { subscription.fasp_provider } + let!(:stubbed_request) do stub_provider_request(provider, method: :post, diff --git a/spec/workers/fasp/announce_content_lifecycle_event_worker_spec.rb b/spec/workers/fasp/announce_content_lifecycle_event_worker_spec.rb index 60618607c9..6f7b44e67f 100644 --- a/spec/workers/fasp/announce_content_lifecycle_event_worker_spec.rb +++ b/spec/workers/fasp/announce_content_lifecycle_event_worker_spec.rb @@ -6,10 +6,11 @@ RSpec.describe Fasp::AnnounceContentLifecycleEventWorker do include ProviderRequestHelper let(:status_uri) { 'https://masto.example.com/status/1' } + let(:provider) { Fabricate(:confirmed_fasp) } let(:subscription) do - Fabricate(:fasp_subscription) + Fabricate(:fasp_subscription, fasp_provider: provider) end - let(:provider) { subscription.fasp_provider } + let!(:stubbed_request) do stub_provider_request(provider, method: :post, diff --git a/spec/workers/fasp/announce_trend_worker_spec.rb b/spec/workers/fasp/announce_trend_worker_spec.rb index 799d8a8f48..f63121640b 100644 --- a/spec/workers/fasp/announce_trend_worker_spec.rb +++ b/spec/workers/fasp/announce_trend_worker_spec.rb @@ -6,14 +6,15 @@ RSpec.describe Fasp::AnnounceTrendWorker do include ProviderRequestHelper let(:status) { Fabricate(:status) } + let(:provider) { Fabricate(:confirmed_fasp) } let(:subscription) do Fabricate(:fasp_subscription, + fasp_provider: provider, category: 'content', subscription_type: 'trends', threshold_timeframe: 15, threshold_likes: 2) end - let(:provider) { subscription.fasp_provider } let!(:stubbed_request) do stub_provider_request(provider, method: :post, diff --git a/spec/workers/fasp/backfill_worker_spec.rb b/spec/workers/fasp/backfill_worker_spec.rb index 43734e02ba..1877087674 100644 --- a/spec/workers/fasp/backfill_worker_spec.rb +++ b/spec/workers/fasp/backfill_worker_spec.rb @@ -5,8 +5,10 @@ require 'rails_helper' RSpec.describe Fasp::BackfillWorker do include ProviderRequestHelper - let(:backfill_request) { Fabricate(:fasp_backfill_request) } - let(:provider) { backfill_request.fasp_provider } + subject { described_class.new.perform(backfill_request.id) } + + let(:provider) { Fabricate(:confirmed_fasp) } + let(:backfill_request) { Fabricate(:fasp_backfill_request, fasp_provider: provider) } let(:status) { Fabricate(:status) } let!(:stubbed_request) do stub_provider_request(provider, From bae1da6f7329308a30bd11e19fe8d5f5a5309b77 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 6 Feb 2026 11:09:42 +0100 Subject: [PATCH 3/7] Fix processing of object updates with duplicate hashtags (#37756) --- app/services/activitypub/process_status_update_service.rb | 2 +- .../activitypub/process_status_update_service_spec.rb | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 1d7adc6161..23a2305a1a 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -209,7 +209,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService Tag.find_or_create_by_names([tag]).filter(&:valid?) rescue ActiveRecord::RecordInvalid [] - end + end.uniq return unless @status.distributable? diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index e8838d1104..59f712b9b8 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -259,6 +259,8 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do { type: 'Hashtag', name: 'foo' }, { type: 'Hashtag', name: 'bar' }, { type: 'Hashtag', name: '#2024' }, + { type: 'Hashtag', name: 'Foo Bar' }, + { type: 'Hashtag', name: 'FooBar' }, ], } end @@ -270,7 +272,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do it 'updates tags and featured tags' do expect { subject.call(status, json, json) } - .to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar')) + .to change { status.tags.reload.pluck(:name) }.from(contain_exactly('test', 'foo')).to(contain_exactly('foo', 'bar', 'foobar')) .and change { status.account.featured_tags.find_by(name: 'test').statuses_count }.by(-1) .and change { status.account.featured_tags.find_by(name: 'bar').statuses_count }.by(1) .and change { status.account.featured_tags.find_by(name: 'bar').last_status_at }.from(nil).to(be_present) From 5bcbc77e7559fb0cdf3516537510802fd2e90cb7 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 10 Feb 2026 17:13:06 +0100 Subject: [PATCH 4/7] Purge custom emojis on domain suspension (#37808) --- app/services/block_domain_service.rb | 7 +++- app/workers/purge_custom_emoji_worker.rb | 15 +++++++++ .../workers/purge_custom_emoji_worker_spec.rb | 33 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/workers/purge_custom_emoji_worker.rb create mode 100644 spec/workers/purge_custom_emoji_worker_spec.rb diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index ca6cbec0b6..fb506a8246 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -29,7 +29,12 @@ class BlockDomainService < BaseService suspend_accounts! end - DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media? + if domain_block.suspend? + # Account images and attachments are already handled by `suspend_accounts!` + PurgeCustomEmojiWorker.perform_async(blocked_domain) + elsif domain_block.reject_media? + DomainClearMediaWorker.perform_async(domain_block.id) + end end def silence_accounts! diff --git a/app/workers/purge_custom_emoji_worker.rb b/app/workers/purge_custom_emoji_worker.rb new file mode 100644 index 0000000000..4d973ba7cb --- /dev/null +++ b/app/workers/purge_custom_emoji_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PurgeCustomEmojiWorker + include Sidekiq::IterableJob + + def build_enumerator(domain, cursor:) + return if domain.blank? + + active_record_batches_enumerator(CustomEmoji.by_domain_and_subdomains(domain), cursor:) + end + + def each_iteration(custom_emojis, _domain) + AttachmentBatch.new(CustomEmoji, custom_emojis).delete + end +end diff --git a/spec/workers/purge_custom_emoji_worker_spec.rb b/spec/workers/purge_custom_emoji_worker_spec.rb new file mode 100644 index 0000000000..4317fee04a --- /dev/null +++ b/spec/workers/purge_custom_emoji_worker_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PurgeCustomEmojiWorker do + let(:worker) { described_class.new } + + let(:domain) { 'evil' } + + before do + Fabricate(:custom_emoji) + Fabricate(:custom_emoji, domain: 'example.com') + Fabricate.times(5, :custom_emoji, domain: domain) + end + + describe '#perform' do + context 'when domain is nil' do + it 'does not delete emojis' do + expect { worker.perform(nil) } + .to_not(change(CustomEmoji, :count)) + end + end + + context 'when passing a domain' do + it 'deletes emojis from this domain only' do + expect { worker.perform(domain) } + .to change { CustomEmoji.where(domain: domain).count }.to(0) + .and not_change { CustomEmoji.local.count } + .and(not_change { CustomEmoji.where(domain: 'example.com').count }) + end + end + end +end From 19edc6a26466ec13dc5d23c74afbeb820ee32624 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 12 Feb 2026 11:25:29 +0100 Subject: [PATCH 5/7] Add `--suspended-only` option to `tootctl emoji purge` (#37828) --- lib/mastodon/cli/emoji.rb | 16 ++++++++++++++-- spec/lib/mastodon/cli/emoji_spec.rb | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/mastodon/cli/emoji.rb b/lib/mastodon/cli/emoji.rb index 206961f854..a115750c53 100644 --- a/lib/mastodon/cli/emoji.rb +++ b/lib/mastodon/cli/emoji.rb @@ -109,15 +109,27 @@ module Mastodon::CLI end option :remote_only, type: :boolean + option :suspended_only, type: :boolean desc 'purge', 'Remove all custom emoji' long_desc <<-LONG_DESC Removes all custom emoji. With the --remote-only option, only remote emoji will be deleted. + + With the --suspended-only option, only emoji from suspended servers will be deleted. LONG_DESC def purge - scope = options[:remote_only] ? CustomEmoji.remote : CustomEmoji - scope.in_batches.destroy_all + if options[:suspended_only] + DomainBlock.where(severity: :suspend).find_each do |domain_block| + CustomEmoji.by_domain_and_subdomains(domain_block.domain).find_in_batches do |custom_emojis| + AttachmentBatch.new(CustomEmoji, custom_emojis).delete + end + end + else + scope = options[:remote_only] ? CustomEmoji.remote : CustomEmoji + scope.in_batches.destroy_all + end + say('OK', :green) end diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb index 4336db17d3..b21c533e97 100644 --- a/spec/lib/mastodon/cli/emoji_spec.rb +++ b/spec/lib/mastodon/cli/emoji_spec.rb @@ -23,6 +23,35 @@ RSpec.describe Mastodon::CLI::Emoji do .to output_results('OK') end end + + context 'with --suspended-only and existing custom emoji on blocked servers' do + let(:blocked_domain) { 'evil.com' } + let(:blocked_subdomain) { 'subdomain.evil.org' } + let(:blocked_domain_without_emoji) { 'blocked.com' } + let(:silenced_domain) { 'silenced.com' } + + let(:options) { { suspended_only: true } } + + before do + Fabricate(:custom_emoji) + Fabricate(:custom_emoji, domain: blocked_domain) + Fabricate(:custom_emoji, domain: blocked_subdomain) + Fabricate(:custom_emoji, domain: silenced_domain) + + Fabricate(:domain_block, severity: :suspend, domain: blocked_domain) + Fabricate(:domain_block, severity: :suspend, domain: 'evil.org') + Fabricate(:domain_block, severity: :suspend, domain: blocked_domain_without_emoji) + Fabricate(:domain_block, severity: :silence, domain: silenced_domain) + end + + it 'reports a successful purge' do + expect { subject } + .to change { CustomEmoji.by_domain_and_subdomains(blocked_domain).count }.to(0) + .and change { CustomEmoji.by_domain_and_subdomains('evil.org').count }.to(0) + .and not_change { CustomEmoji.by_domain_and_subdomains(silenced_domain).count } + .and(not_change { CustomEmoji.local.count }) + end + end end describe '#import' do From e4e5d0cd2dc19b00486b4d8c635fcb2ed9a9e497 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Mon, 16 Feb 2026 03:24:39 -0500 Subject: [PATCH 6/7] Capture output in `cli/emoji` spec (#37861) --- spec/lib/mastodon/cli/emoji_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb index b21c533e97..c257bdf792 100644 --- a/spec/lib/mastodon/cli/emoji_spec.rb +++ b/spec/lib/mastodon/cli/emoji_spec.rb @@ -46,7 +46,8 @@ RSpec.describe Mastodon::CLI::Emoji do it 'reports a successful purge' do expect { subject } - .to change { CustomEmoji.by_domain_and_subdomains(blocked_domain).count }.to(0) + .to output_results('OK') + .and change { CustomEmoji.by_domain_and_subdomains(blocked_domain).count }.to(0) .and change { CustomEmoji.by_domain_and_subdomains('evil.org').count }.to(0) .and not_change { CustomEmoji.by_domain_and_subdomains(silenced_domain).count } .and(not_change { CustomEmoji.local.count }) From 1e5ac0f4917f12f8caaaa9c13762a9d3e182c0f7 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 24 Feb 2026 14:44:49 +0100 Subject: [PATCH 7/7] Bump version to v4.4.14 (#37964) --- CHANGELOG.md | 16 ++++++++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 374813e565..baa140d883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [4.4.14] - 2026-02-24 + +### Security + +- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg)) +- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm)) + +### Added + +- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire) +- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire) + ## [4.4.13] - 2026-02-03 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index 77fbd72325..d6fda91970 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: web: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.4.13 + image: ghcr.io/mastodon/mastodon:v4.4.14 restart: always env_file: .env.production command: bundle exec puma -C config/puma.rb @@ -83,7 +83,7 @@ services: # build: # dockerfile: ./streaming/Dockerfile # context: . - image: ghcr.io/mastodon/mastodon-streaming:v4.4.13 + image: ghcr.io/mastodon/mastodon-streaming:v4.4.14 restart: always env_file: .env.production command: node ./streaming/index.js @@ -102,7 +102,7 @@ services: sidekiq: # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # build: . - image: ghcr.io/mastodon/mastodon:v4.4.13 + image: ghcr.io/mastodon/mastodon:v4.4.14 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 405790ff71..1af5a049b9 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 13 + 14 end def default_prerelease