diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb index d05d12df60..78b5f6a6e2 100644 --- a/app/models/collection_item.rb +++ b/app/models/collection_item.rb @@ -35,6 +35,7 @@ class CollectionItem < ApplicationRecord validates :uri, presence: true, if: :remote? before_validation :set_position, on: :create + before_validation :set_activity_uri, only: :create, if: :local_item_with_remote_account? scope :ordered, -> { order(position: :asc) } scope :with_accounts, -> { includes(account: [:account_stat, :user]) } @@ -55,4 +56,8 @@ class CollectionItem < ApplicationRecord self.position = self.class.where(collection_id:).maximum(:position).to_i + 1 end + + def set_activity_uri + self.activity_uri = [ActivityPub::TagManager.instance.uri_for(collection.account), '/feature_requests/', SecureRandom.uuid].join + end end diff --git a/app/serializers/activitypub/feature_request_serializer.rb b/app/serializers/activitypub/feature_request_serializer.rb new file mode 100644 index 0000000000..a7a22f4ea0 --- /dev/null +++ b/app/serializers/activitypub/feature_request_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureRequestSerializer < ActivityPub::Serializer + attributes :id, :type, :instrument + attribute :virtual_object, key: :object + + def id + object.activity_uri + end + + def type + 'FeatureRequest' + end + + def virtual_object + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def instrument + ActivityPub::TagManager.instance.uri_for(object.collection) + end +end diff --git a/app/services/add_account_to_collection_service.rb b/app/services/add_account_to_collection_service.rb index 2109baf67e..e53c67b57f 100644 --- a/app/services/add_account_to_collection_service.rb +++ b/app/services/add_account_to_collection_service.rb @@ -11,7 +11,10 @@ class AddAccountToCollectionService @collection_item = create_collection_item - distribute_add_activity if @account.local? && Mastodon::Feature.collections_federation_enabled? + if Mastodon::Feature.collections_federation_enabled? + distribute_add_activity if @account.local? + distribute_feature_request_activity if @account.remote? + end @collection_item end @@ -26,10 +29,14 @@ class AddAccountToCollectionService end def distribute_add_activity - ActivityPub::AccountRawDistributionWorker.perform_async(activity_json, @collection.account_id) + ActivityPub::AccountRawDistributionWorker.perform_async(add_activity_json, @collection.account_id) end - def activity_json + def distribute_feature_request_activity + ActivityPub::FeatureRequestWorker.perform_async(@collection_item.id) + end + + def add_activity_json ActiveModelSerializers::SerializableResource.new(@collection_item, serializer: ActivityPub::AddFeaturedItemSerializer, adapter: ActivityPub::Adapter).to_json end end diff --git a/app/services/create_collection_service.rb b/app/services/create_collection_service.rb index bcc68d01cd..b0d291d7c3 100644 --- a/app/services/create_collection_service.rb +++ b/app/services/create_collection_service.rb @@ -9,7 +9,10 @@ class CreateCollectionService @collection.save! - distribute_add_activity if Mastodon::Feature.collections_federation_enabled? + if Mastodon::Feature.collections_federation_enabled? + distribute_add_activity + distribute_feature_request_activities + end @collection end @@ -20,6 +23,12 @@ class CreateCollectionService ActivityPub::AccountRawDistributionWorker.perform_async(activity_json, @account.id) end + def distribute_feature_request_activities + @collection.collection_items.select(&:local_item_with_remote_account?).each do |collection_item| + ActivityPub::FeatureRequestWorker.perform_async(collection_item.id) + end + end + def build_items return if @accounts_to_add.empty? diff --git a/app/workers/activitypub/feature_request_worker.rb b/app/workers/activitypub/feature_request_worker.rb new file mode 100644 index 0000000000..fa895a546d --- /dev/null +++ b/app/workers/activitypub/feature_request_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureRequestWorker < ActivityPub::RawDistributionWorker + def perform(collection_item_id) + @collection_item = CollectionItem.find(collection_item_id) + @account = @collection_item.collection.account + + distribute! + rescue ActiveRecord::RecordNotFound + true + end + + protected + + def inboxes + @inboxes ||= [@collection_item.account.inbox_url] + end + + def payload + @payload ||= Oj.dump(serialize_payload(@collection_item, ActivityPub::FeatureRequestSerializer, signer: @account)) + end +end diff --git a/spec/models/collection_item_spec.rb b/spec/models/collection_item_spec.rb index f5497d9041..e4905535cf 100644 --- a/spec/models/collection_item_spec.rb +++ b/spec/models/collection_item_spec.rb @@ -16,14 +16,6 @@ RSpec.describe CollectionItem do it { is_expected.to validate_presence_of(:account) } end - context 'when item is local and account is remote' do - subject { Fabricate.build(:collection_item, account: remote_account) } - - let(:remote_account) { Fabricate.build(:remote_account) } - - it { is_expected.to validate_presence_of(:activity_uri) } - end - context 'when item is not local' do subject { Fabricate.build(:collection_item, collection: remote_collection) } @@ -58,5 +50,11 @@ RSpec.describe CollectionItem do expect(unrelated_item.position).to eq 1 expect(custom_item.position).to eq 7 end + + it 'automatically sets `activity_uri` when account is remote' do + item = collection.collection_items.create(account: Fabricate(:remote_account)) + + expect(item.activity_uri).to be_present + end end end diff --git a/spec/serializers/activitypub/feature_request_serializer_spec.rb b/spec/serializers/activitypub/feature_request_serializer_spec.rb new file mode 100644 index 0000000000..45040698f8 --- /dev/null +++ b/spec/serializers/activitypub/feature_request_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FeatureRequestSerializer do + subject { serialized_record_json(collection_item, described_class, adapter: ActivityPub::Adapter) } + + let(:tag_manager) { ActivityPub::TagManager.instance } + let(:target_account) { Fabricate(:remote_account) } + let(:collection) { Fabricate(:collection) } + let(:collection_item) { Fabricate(:collection_item, collection:, account: target_account) } + + it 'serializes to the expected json' do + expect(subject).to include({ + 'id' => collection_item.activity_uri, + 'type' => 'FeatureRequest', + 'instrument' => tag_manager.uri_for(collection_item.collection), + 'object' => tag_manager.uri_for(target_account), + }) + + expect(subject).to_not have_key('published') + expect(subject).to_not have_key('to') + expect(subject).to_not have_key('cc') + expect(subject).to_not have_key('target') + end +end diff --git a/spec/services/add_account_to_collection_service_spec.rb b/spec/services/add_account_to_collection_service_spec.rb index 3085c597a7..35c7432ebe 100644 --- a/spec/services/add_account_to_collection_service_spec.rb +++ b/spec/services/add_account_to_collection_service_spec.rb @@ -21,10 +21,22 @@ RSpec.describe AddAccountToCollectionService do expect(new_item.account).to eq account end - it 'federates an `Add` activity', feature: :collections_federation do - subject.call(collection, account) + context 'when the account is local' do + it 'federates an `Add` activity', feature: :collections_federation do + subject.call(collection, account) - expect(ActivityPub::AccountRawDistributionWorker).to have_enqueued_sidekiq_job + expect(ActivityPub::AccountRawDistributionWorker).to have_enqueued_sidekiq_job + end + end + + context 'when the account is remote', feature: :collections_federation do + let(:account) { Fabricate(:remote_account, feature_approval_policy: (0b10 << 16)) } + + it 'federates a `FeatureRequest` activity' do + subject.call(collection, account) + + expect(ActivityPub::FeatureRequestWorker).to have_enqueued_sidekiq_job + end end end diff --git a/spec/services/create_collection_service_spec.rb b/spec/services/create_collection_service_spec.rb index 8189b01fbe..0d71117e73 100644 --- a/spec/services/create_collection_service_spec.rb +++ b/spec/services/create_collection_service_spec.rb @@ -61,6 +61,16 @@ RSpec.describe CreateCollectionService do end.to raise_error(Mastodon::NotPermittedError) end end + + context 'when some accounts are remote' do + let(:accounts) { Fabricate.times(2, :remote_account, feature_approval_policy: (0b10 << 16)) } + + it 'federates `FeatureRequest` activities', feature: :collections_federation do + subject.call(params, author) + + expect(ActivityPub::FeatureRequestWorker).to have_enqueued_sidekiq_job.exactly(2).times + end + end end context 'when given a tag' do diff --git a/spec/workers/activitypub/feature_request_worker_spec.rb b/spec/workers/activitypub/feature_request_worker_spec.rb new file mode 100644 index 0000000000..23e0524f41 --- /dev/null +++ b/spec/workers/activitypub/feature_request_worker_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FeatureRequestWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account, inbox_url: 'http://example.com', domain: 'example.com') } + let(:collection_owner) { Fabricate(:account) } + let(:collection) { Fabricate(:collection, account: collection_owner) } + let(:collection_item) { Fabricate(:collection_item, collection:, account:) } + + describe '#perform' do + it 'sends the expected `FeatureRequest` activity' do + subject.perform(collection_item.id) + + expect(ActivityPub::DeliveryWorker) + .to have_enqueued_sidekiq_job(expected_json, collection_owner.id, 'http://example.com', {}) + end + + def expected_json + match_json_values( + id: a_string_matching(/^http/), + type: 'FeatureRequest', + object: ActivityPub::TagManager.instance.uri_for(account), + instrument: ActivityPub::TagManager.instance.uri_for(collection_item.collection) + ) + end + end +end