From 6b88dd492378a6d454365c0f870fe9264a7a499c Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Fri, 27 Feb 2026 12:00:21 +0100 Subject: [PATCH] Issue `FeatureAuthorization`s (#38004) --- .../feature_authorizations_controller.rb | 29 +++++++++++ app/models/collection.rb | 1 + app/models/collection_item.rb | 1 + .../feature_authorization_serializer.rb | 23 +++++++++ .../activitypub/featured_item_serializer.rb | 13 ++++- config/routes.rb | 1 + .../feature_authorizations_spec.rb | 49 +++++++++++++++++++ .../feature_authorization_serializer_spec.rb | 24 +++++++++ .../featured_collection_serializer_spec.rb | 4 ++ .../featured_item_serializer_spec.rb | 35 ++++++++++--- 10 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 app/controllers/activitypub/feature_authorizations_controller.rb create mode 100644 app/serializers/activitypub/feature_authorization_serializer.rb create mode 100644 spec/requests/activitypub/feature_authorizations_spec.rb create mode 100644 spec/serializers/activitypub/feature_authorization_serializer_spec.rb diff --git a/app/controllers/activitypub/feature_authorizations_controller.rb b/app/controllers/activitypub/feature_authorizations_controller.rb new file mode 100644 index 0000000000..ef9f458bf7 --- /dev/null +++ b/app/controllers/activitypub/feature_authorizations_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureAuthorizationsController < ActivityPub::BaseController + include Authorization + + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_collection_item + + def show + expires_in 30.seconds, public: true if public_fetch_mode? + render json: @collection_item, serializer: ActivityPub::FeatureAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def pundit_user + signed_request_account + end + + def set_collection_item + @collection_item = @account.collection_items.accepted.find(params[:id]) + + authorize @collection_item.collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end +end diff --git a/app/models/collection.rb b/app/models/collection.rb index c018dd9fa4..3b8ee82a3c 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -46,6 +46,7 @@ class Collection < ApplicationRecord scope :with_items, -> { includes(:collection_items).merge(CollectionItem.with_accounts) } scope :with_tag, -> { includes(:tag) } scope :discoverable, -> { where(discoverable: true) } + scope :local, -> { where(local: true) } def remote? !local? diff --git a/app/models/collection_item.rb b/app/models/collection_item.rb index 78b5f6a6e2..c5c9ebc16e 100644 --- a/app/models/collection_item.rb +++ b/app/models/collection_item.rb @@ -40,6 +40,7 @@ class CollectionItem < ApplicationRecord scope :ordered, -> { order(position: :asc) } scope :with_accounts, -> { includes(account: [:account_stat, :user]) } scope :not_blocked_by, ->(account) { where.not(accounts: { id: account.blocking }) } + scope :local, -> { joins(:collection).merge(Collection.local) } def local_item_with_remote_account? local? && account&.remote? diff --git a/app/serializers/activitypub/feature_authorization_serializer.rb b/app/serializers/activitypub/feature_authorization_serializer.rb new file mode 100644 index 0000000000..4181025802 --- /dev/null +++ b/app/serializers/activitypub/feature_authorization_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::FeatureAuthorizationSerializer < ActivityPub::Serializer + include RoutingHelper + + attributes :id, :type, :interacting_object, :interaction_target + + def id + ap_account_feature_authorization_url(object.account_id, object) + end + + def type + 'FeatureAuthorization' + end + + def interaction_target + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def interacting_object + ActivityPub::TagManager.instance.uri_for(object.collection) + end +end diff --git a/app/serializers/activitypub/featured_item_serializer.rb b/app/serializers/activitypub/featured_item_serializer.rb index a524d6c25f..56c0b4390f 100644 --- a/app/serializers/activitypub/featured_item_serializer.rb +++ b/app/serializers/activitypub/featured_item_serializer.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true class ActivityPub::FeaturedItemSerializer < ActivityPub::Serializer - attributes :id, :type, :featured_object, :featured_object_type + include RoutingHelper + + attributes :id, :type, :featured_object, :featured_object_type, + :feature_authorization def id ActivityPub::TagManager.instance.uri_for(object) @@ -18,4 +21,12 @@ class ActivityPub::FeaturedItemSerializer < ActivityPub::Serializer def featured_object_type object.account.actor_type || 'Person' end + + def feature_authorization + if object.account.local? + ap_account_feature_authorization_url(object.account_id, object) + else + object.approval_uri + end + end end diff --git a/config/routes.rb b/config/routes.rb index 3ddd3494d3..57ca5923c6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -125,6 +125,7 @@ Rails.application.routes.draw do scope path: 'ap', as: 'ap' do resources :accounts, path: 'users', only: [:show], param: :id, concerns: :account_resources do resources :collection_items, only: [:show] + resources :feature_authorizations, only: [:show], module: :activitypub resources :featured_collections, only: [:index], module: :activitypub resources :statuses, only: [:show] do diff --git a/spec/requests/activitypub/feature_authorizations_spec.rb b/spec/requests/activitypub/feature_authorizations_spec.rb new file mode 100644 index 0000000000..ee4cc0579a --- /dev/null +++ b/spec/requests/activitypub/feature_authorizations_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'ActivityPub FeatureAuthorization endpoint' do + describe 'GET /ap/accounts/:account_id/feature_authorizations/:collection_item_id' do + let(:account) { Fabricate(:account) } + let(:collection) { Fabricate(:collection) } + let(:collection_item) { Fabricate(:collection_item, collection:, account:, state:) } + + context 'with an accepted collection item' do + let(:state) { :accepted } + + it 'returns http success and activity json' do + get ap_account_feature_authorization_path(account.id, collection_item) + + expect(response) + .to have_http_status(200) + expect(response.media_type) + .to eq 'application/activity+json' + + expect(response.parsed_body) + .to include(type: 'FeatureAuthorization') + end + end + + shared_examples 'not found' do + it 'returns http not found' do + get ap_account_feature_authorization_path(collection.account_id, collection_item) + + expect(response) + .to have_http_status(404) + end + end + + context 'with a revoked collection item' do + let(:state) { :revoked } + + it_behaves_like 'not found' + end + + context 'with a collection item featuring a remote account' do + let(:account) { Fabricate(:remote_account) } + let(:state) { :accepted } + + it_behaves_like 'not found' + end + end +end diff --git a/spec/serializers/activitypub/feature_authorization_serializer_spec.rb b/spec/serializers/activitypub/feature_authorization_serializer_spec.rb new file mode 100644 index 0000000000..30fd0e4640 --- /dev/null +++ b/spec/serializers/activitypub/feature_authorization_serializer_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::FeatureAuthorizationSerializer do + include RoutingHelper + + subject { serialized_record_json(collection_item, described_class, adapter: ActivityPub::Adapter) } + + describe 'serializing an object' do + let(:collection_item) { Fabricate(:collection_item) } + let(:tag_manager) { ActivityPub::TagManager.instance } + + it 'returns the expected json structure' do + expect(subject) + .to include( + 'type' => 'FeatureAuthorization', + 'id' => ap_account_feature_authorization_url(collection_item.account_id, collection_item), + 'interactionTarget' => tag_manager.uri_for(collection_item.account), + 'interactingObject' => tag_manager.uri_for(collection_item.collection) + ) + end + end +end diff --git a/spec/serializers/activitypub/featured_collection_serializer_spec.rb b/spec/serializers/activitypub/featured_collection_serializer_spec.rb index 24dd065480..c0ae43abb9 100644 --- a/spec/serializers/activitypub/featured_collection_serializer_spec.rb +++ b/spec/serializers/activitypub/featured_collection_serializer_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe ActivityPub::FeaturedCollectionSerializer do + include RoutingHelper + subject { serialized_record_json(collection, described_class, adapter: ActivityPub::Adapter) } let(:collection) do @@ -35,12 +37,14 @@ RSpec.describe ActivityPub::FeaturedCollectionSerializer do 'type' => 'FeaturedItem', 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.first.account), 'featuredObjectType' => 'Person', + 'featureAuthorization' => ap_account_feature_authorization_url(collection_items.first.account_id, collection_items.first), }, { 'id' => ActivityPub::TagManager.instance.uri_for(collection_items.last), 'type' => 'FeaturedItem', 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_items.last.account), 'featuredObjectType' => 'Person', + 'featureAuthorization' => ap_account_feature_authorization_url(collection_items.last.account_id, collection_items.last), }, ], 'published' => match_api_datetime_format, diff --git a/spec/serializers/activitypub/featured_item_serializer_spec.rb b/spec/serializers/activitypub/featured_item_serializer_spec.rb index f17faf2410..7aca086192 100644 --- a/spec/serializers/activitypub/featured_item_serializer_spec.rb +++ b/spec/serializers/activitypub/featured_item_serializer_spec.rb @@ -3,16 +3,37 @@ require 'rails_helper' RSpec.describe ActivityPub::FeaturedItemSerializer do + include RoutingHelper + subject { serialized_record_json(collection_item, described_class, adapter: ActivityPub::Adapter) } let(:collection_item) { Fabricate(:collection_item) } - it 'serializes to the expected structure' do - expect(subject).to include({ - 'type' => 'FeaturedItem', - 'id' => ActivityPub::TagManager.instance.uri_for(collection_item), - 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_item.account), - 'featuredObjectType' => 'Person', - }) + context 'when a local account is featured' do + it 'serializes to the expected structure' do + expect(subject).to include({ + 'type' => 'FeaturedItem', + 'id' => ActivityPub::TagManager.instance.uri_for(collection_item), + 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_item.account), + 'featuredObjectType' => 'Person', + 'featureAuthorization' => ap_account_feature_authorization_url(collection_item.account_id, collection_item), + }) + end + end + + context 'when a remote account is featured' do + let(:collection) { Fabricate(:collection) } + let(:account) { Fabricate(:remote_account) } + let(:collection_item) { Fabricate(:collection_item, collection:, account:, approval_uri: 'https://example.com/auth/1') } + + it 'serializes to the expected structure' do + expect(subject).to include({ + 'type' => 'FeaturedItem', + 'id' => ActivityPub::TagManager.instance.uri_for(collection_item), + 'featuredObject' => ActivityPub::TagManager.instance.uri_for(collection_item.account), + 'featuredObjectType' => 'Person', + 'featureAuthorization' => 'https://example.com/auth/1', + }) + end end end