diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e975479..cfbc450d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [4.5.6] - 2026-02-03 + +### Security + +- Fix ActivityPub collection caching logic for pinned posts and featured tags not checking blocked accounts ([GHSA-ccpr-m53r-mfwr](https://github.com/mastodon/mastodon/security/advisories/GHSA-ccpr-m53r-mfwr)) + +### Changed + +- Shorten caching of quote posts pending approval (#37570 and #37592 by @ClearlyClaire) + +### Fixed + +- Fix relationship cache not being cleared when handling account migrations (#37664 by @ClearlyClaire) +- Fix quote cancel button not appearing after edit then delete-and-redraft (#37066 by @PGrayCS) +- Fix followers with profile subscription (bell icon) being notified of post edits (#37646 by @ClearlyClaire) +- Fix error when encountering invalid tag in updated object (#37635 by @ClearlyClaire) +- Fix cross-server conversation tracking (#37559 by @ClearlyClaire) +- Fix recycled connections not being immediately closed (#37335 and #37674 by @ClearlyClaire and @shleeable) + ## [4.5.5] - 2026-01-20 ### Security diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c80db3500..752b843c8 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -4,17 +4,31 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :check_authorization before_action :set_items before_action :set_size before_action :set_type def show expires_in 3.minutes, public: public_fetch_mode? - render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + + if @unauthorized + render json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + else + render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end end private + def check_authorization + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + @unauthorized = authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + end + def set_items case params[:id] when 'featured' @@ -57,11 +71,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController end def for_signed_account - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + if @unauthorized [] else yield diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index e673faca0..65db807d1 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -29,7 +29,7 @@ class StatusesController < ApplicationController end format.json do - expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode? + expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode? render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 2a8c9bfb2..aac58b31f 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -145,6 +145,7 @@ class Status extends ImmutablePureComponent { 'hidden', 'unread', 'pictureInPicture', + 'onQuoteCancel', ]; state = { diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 43c7bb1fe..a7d2be35e 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -379,6 +379,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def conversation_from_uri(uri) return nil if uri.nil? return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri) + return ActivityPub::TagManager.instance.uri_to_resource(uri, Conversation) if ActivityPub::TagManager.instance.local_uri?(uri) begin Conversation.find_or_create_by!(uri: uri) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3174d1792..40adb5737 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -241,12 +241,6 @@ class ActivityPub::TagManager !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)) end - def uri_to_local_id(uri, param = :id) - path_params = Rails.application.routes.recognize_path(uri) - path_params[:username] = Rails.configuration.x.local_domain if path_params[:controller] == 'instance_actors' - path_params[param] - end - def uris_to_local_accounts(uris) usernames = [] ids = [] @@ -264,6 +258,14 @@ class ActivityPub::TagManager uri_to_resource(uri, Account) end + def uri_to_local_conversation(uri) + path_params = Rails.application.routes.recognize_path(uri) + return unless path_params[:controller] == 'activitypub/contexts' + + account_id, conversation_id = path_params[:id].split('-') + Conversation.find_by(parent_account_id: account_id, id: conversation_id) + end + def uri_to_resource(uri, klass) return if uri.nil? @@ -271,6 +273,8 @@ class ActivityPub::TagManager case klass.name when 'Account' uris_to_local_accounts([uri]).first + when 'Conversation' + uri_to_local_conversation(uri) else StatusFinder.new(uri).status end diff --git a/app/lib/connection_pool/shared_timed_stack.rb b/app/lib/connection_pool/shared_timed_stack.rb index 14a5285c4..8a13f4473 100644 --- a/app/lib/connection_pool/shared_timed_stack.rb +++ b/app/lib/connection_pool/shared_timed_stack.rb @@ -70,6 +70,7 @@ class ConnectionPool::SharedTimedStack if @created == @max && !@queue.empty? throw_away_connection = @queue.pop @tagged_queue[throw_away_connection.site].delete(throw_away_connection) + throw_away_connection.close @create_block.call(preferred_tag) elsif @created != @max connection = @create_block.call(preferred_tag) diff --git a/app/lib/ostatus/tag_manager.rb b/app/lib/ostatus/tag_manager.rb index cb0c9f896..7d0f23c4d 100644 --- a/app/lib/ostatus/tag_manager.rb +++ b/app/lib/ostatus/tag_manager.rb @@ -11,16 +11,12 @@ class OStatus::TagManager def unique_tag_to_local_id(tag, expected_type) return nil unless local_id?(tag) - if ActivityPub::TagManager.instance.local_uri?(tag) - ActivityPub::TagManager.instance.uri_to_local_id(tag) - else - matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) - matches[1] unless matches.nil? - end + matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag) + matches[1] unless matches.nil? end def local_id?(id) - id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id) + id.start_with?("tag:#{Rails.configuration.x.local_domain}") end def uri_for(target) diff --git a/app/models/quote.rb b/app/models/quote.rb index 4ad393e3a..e4f3b823f 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -47,6 +47,8 @@ class Quote < ApplicationRecord def accept! update!(state: :accepted) + + reset_parent_cache! if attribute_previously_changed?(:state) end def reject! @@ -75,6 +77,15 @@ class Quote < ApplicationRecord private + def reset_parent_cache! + return if status_id.nil? + + Rails.cache.delete("v3:statuses/#{status_id}") + + # This clears the web cache for the ActivityPub representation + Rails.cache.delete("statuses/show:v3:statuses/#{status_id}") + end + def set_accounts self.account = status.account self.quoted_account = quoted_status&.account diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 1cdf0b483..c45454fc7 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -204,7 +204,11 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def update_tags! previous_tags = @status.tags.to_a - current_tags = @status.tags = Tag.find_or_create_by_names(@raw_tags) + current_tags = @status.tags = @raw_tags.flat_map do |tag| + Tag.find_or_create_by_names([tag]).filter(&:valid?) + rescue ActiveRecord::RecordInvalid + [] + end return unless @status.distributable? diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index e883daf3e..b0c9c9181 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -53,7 +53,7 @@ class FeedInsertWorker def notify?(filter_result) return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) || - filter_result == :filter + update? || filter_result == :filter Follow.find_by(account: @follower, target_account: @status.account)&.notify? end diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index 1a5745a86..d0103dc66 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -64,6 +64,16 @@ class MoveWorker .in_batches do |follows| ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id) num_moved += follows.update_all(target_account_id: @target_account.id) + + # Clear any relationship cache, since callbacks are not called + Rails.cache.delete_multi(follows.flat_map do |follow| + [ + ['relationship', follow.account_id, follow.target_account_id], + ['relationship', follow.target_account_id, follow.account_id], + ['relationship', follow.account_id, @target_account.id], + ['relationship', @target_account.id, follow.account_id], + ] + end) end num_moved diff --git a/docker-compose.yml b/docker-compose.yml index 52d2a83f4..caff99be1 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.5.5 + image: ghcr.io/mastodon/mastodon:v4.5.6 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.5.5 + image: ghcr.io/mastodon/mastodon-streaming:v4.5.6 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.5.5 + image: ghcr.io/mastodon/mastodon:v4.5.6 restart: always env_file: .env.production command: bundle exec sidekiq diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 8fa37f225..9a7415aa8 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 5 + 6 end def default_prerelease diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 1e8a2a29d..19b6014af 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -471,7 +471,7 @@ RSpec.describe ActivityPub::Activity::Create do end end - context 'with a reply' do + context 'with a reply without explicitly setting a conversation' do let(:original_status) { Fabricate(:status) } let(:object_json) do @@ -493,6 +493,30 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with a reply explicitly setting a conversation' do + let(:original_status) { Fabricate(:status) } + + let(:object_json) do + build_object( + inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + conversation: ActivityPub::TagManager.instance.uri_for(original_status.conversation), + context: ActivityPub::TagManager.instance.uri_for(original_status.conversation) + ) + end + + it 'creates status' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.thread).to eq original_status + expect(status.reply?).to be true + expect(status.in_reply_to_account).to eq original_status.account + expect(status.conversation).to eq original_status.conversation + end + end + context 'with mentions' do let(:recipient) { Fabricate(:account) } diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index 6cbb58055..7571d03f0 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -612,14 +612,6 @@ RSpec.describe ActivityPub::TagManager do end end - describe '#uri_to_local_id' do - let(:account) { Fabricate(:account, id_scheme: :username_ap_id) } - - it 'returns the local ID' do - expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username - end - end - describe '#uris_to_local_accounts' do it 'returns the expected local accounts' do account = Fabricate(:account) diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 9d63c5f1f..9dc1ece33 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -258,6 +258,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do tag: [ { type: 'Hashtag', name: 'foo' }, { type: 'Hashtag', name: 'bar' }, + { type: 'Hashtag', name: '#2024' }, ], } end