From d25ed12e56c4688c20c762cedc0f06da170df2d9 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 23 Feb 2026 18:46:36 +0100 Subject: [PATCH 1/4] 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 d5e7e26baa..e9f5aa9e3e 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -194,7 +194,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 c01b48e93b..562b135eb3 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(%w(test foo)).to(%w(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 50255a345fb6ed8657c91a1a3e0f7a85eb7dd1bb Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 12 Feb 2026 11:25:29 +0100 Subject: [PATCH 2/4] 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 4a8949de0e..1f83f87a40 100644 --- a/lib/mastodon/cli/emoji.rb +++ b/lib/mastodon/cli/emoji.rb @@ -107,15 +107,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 ebf54afc1b1f2a3af5c03a2351fe1b223169b249 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Mon, 16 Feb 2026 03:24:39 -0500 Subject: [PATCH 3/4] 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 9437cddda1121cee77f260a1afeec45541e826a0 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 24 Feb 2026 11:55:09 +0100 Subject: [PATCH 4/4] Bump version to v4.3.20 (#37959) --- CHANGELOG.md | 10 ++++++++++ docker-compose.yml | 6 +++--- lib/mastodon/version.rb | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99175533af..bf49817dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. +## [4.3.20] - 2026-02-24 + +### Added + +- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski) + +### Fixed + +- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire) + ## [4.3.19] - 2026-02-03 ### Security diff --git a/docker-compose.yml b/docker-compose.yml index 893926fee5..5ed0804465 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.3.19 + image: ghcr.io/mastodon/mastodon:v4.3.20 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.3.19 + image: ghcr.io/mastodon/mastodon-streaming:v4.3.20 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.3.19 + image: ghcr.io/mastodon/mastodon:v4.3.20 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 7524157207..312149a634 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 19 + 20 end def default_prerelease