From c21884920409cbc691735bc55807507649479275 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Wed, 14 Jan 2026 11:08:29 +0100 Subject: [PATCH] Add collection endpoint (#37468) --- .../activitypub/collections_controller.rb | 2 + app/controllers/collections_controller.rb | 46 ++++++ app/lib/activitypub/tag_manager.rb | 4 +- app/policies/collection_policy.rb | 20 ++- config/routes.rb | 4 +- spec/lib/activitypub/tag_manager_spec.rb | 2 +- spec/policies/collection_policy_spec.rb | 14 +- spec/requests/activitypub/collections_spec.rb | 13 +- spec/requests/collections_spec.rb | 138 ++++++++++++++++++ 9 files changed, 223 insertions(+), 20 deletions(-) create mode 100644 app/controllers/collections_controller.rb create mode 100644 spec/requests/collections_spec.rb diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index c80db3500d..a03f424e0f 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::CollectionsController < ActivityPub::BaseController + SUPPORTED_COLLECTIONS = %w(featured tags).freeze + vary_by -> { 'Signature' if authorized_fetch_mode? } before_action :require_account_signature!, if: :authorized_fetch_mode? diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb new file mode 100644 index 0000000000..3e2ba71470 --- /dev/null +++ b/app/controllers/collections_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class CollectionsController < ApplicationController + include WebAppControllerConcern + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :set_collection + + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode? + + def show + respond_to do |format| + # TODO: format.html + + format.json do + expires_in expiration_duration, public: true if public_fetch_mode? + render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def set_collection + @collection = @account.collections.find(params[:id]) + authorize @collection, :show? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError + not_found + end + + def expiration_duration + recently_updated = @collection.updated_at > 15.minutes.ago + recently_updated ? 30.seconds : 5.minutes + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 9f01b00578..e6714c51ab 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -63,7 +63,7 @@ class ActivityPub::TagManager when :flag target.uri when :featured_collection - ap_account_featured_collection_url(target.account.id, target) + ap_account_collection_url(target.account.id, target) end end @@ -135,7 +135,7 @@ class ActivityPub::TagManager def collection_uri_for(target, ...) raise ArgumentError, 'target must be a local account' unless target.local? - target.numeric_ap_id? ? ap_account_collection_url(target.id, ...) : account_collection_url(target, ...) + target.numeric_ap_id? ? ap_account_actor_collection_url(target.id, ...) : account_actor_collection_url(target, ...) end def inbox_uri_for(target) diff --git a/app/policies/collection_policy.rb b/app/policies/collection_policy.rb index 12adfbcad1..70a869d16a 100644 --- a/app/policies/collection_policy.rb +++ b/app/policies/collection_policy.rb @@ -6,7 +6,7 @@ class CollectionPolicy < ApplicationPolicy end def show? - true + current_account.nil? || (!owner_blocking? && !owner_blocking_domain?) end def create? @@ -24,6 +24,22 @@ class CollectionPolicy < ApplicationPolicy private def owner? - current_account == record.account + current_account == owner + end + + def owner_blocking_domain? + return false if current_account.nil? || current_account.domain.nil? + + owner.domain_blocking?(current_account.domain) + end + + def owner_blocking? + return false if current_account.nil? + + current_account.blocked_by?(owner) + end + + def owner + record.account end end diff --git a/config/routes.rb b/config/routes.rb index ff79c758fb..b3338a725e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,14 +96,14 @@ Rails.application.routes.draw do get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } concern :account_resources do - resources :featured_collections, only: [:show] + resources :collections, only: [:show], constraints: { id: /\d+/ } resources :followers, only: [:index], controller: :follower_accounts resources :following, only: [:index], controller: :following_accounts scope module: :activitypub do resource :outbox, only: [:show] resource :inbox, only: [:create] - resources :collections, only: [:show] + resources :collections, only: [:show], as: :actor_collections, constraints: { id: Regexp.union(ActivityPub::CollectionsController::SUPPORTED_COLLECTIONS) } resource :followers_synchronization, only: [:show] resources :quote_authorizations, only: [:show] end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index bacbb3251c..55e54ede5e 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -198,7 +198,7 @@ RSpec.describe ActivityPub::TagManager do it 'returns a string starting with web domain and with the expected path' do expect(subject.uri_for(collection)) - .to eq("#{host_prefix}/ap/users/#{collection.account.id}/featured_collections/#{collection.id}") + .to eq("#{host_prefix}/ap/users/#{collection.account.id}/collections/#{collection.id}") end end diff --git a/spec/policies/collection_policy_spec.rb b/spec/policies/collection_policy_spec.rb index 156e1a7657..ecef6e899d 100644 --- a/spec/policies/collection_policy_spec.rb +++ b/spec/policies/collection_policy_spec.rb @@ -16,11 +16,23 @@ RSpec.describe CollectionPolicy do end permissions :show? do - it 'permits everyone to show' do + it 'permits when no user is given' do expect(policy).to permit(nil, collection) + end + + it 'permits unblocked users' do expect(policy).to permit(owner, collection) expect(policy).to permit(other_user, collection) end + + it 'denies blocked users' do + domain_blocked_user = Fabricate(:remote_account) + owner.block_domain!(domain_blocked_user.domain) + owner.block!(other_user) + + expect(policy).to_not permit(domain_blocked_user, collection) + expect(policy).to_not permit(other_user, collection) + end end permissions :create? do diff --git a/spec/requests/activitypub/collections_spec.rb b/spec/requests/activitypub/collections_spec.rb index d2761f98ea..39bd2252e7 100644 --- a/spec/requests/activitypub/collections_spec.rb +++ b/spec/requests/activitypub/collections_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'ActivityPub Collections' do end describe 'GET #show' do - subject { get account_collection_path(id: id, account_username: account.username), headers: nil, sign_with: remote_account } + subject { get account_actor_collection_path(id: id, account_username: account.username), headers: nil, sign_with: remote_account } context 'when id is "featured"' do let(:id) { 'featured' } @@ -131,16 +131,5 @@ RSpec.describe 'ActivityPub Collections' do end end end - - context 'when id is not "featured"' do - let(:id) { 'hoge' } - - it 'returns http not found' do - subject - - expect(response) - .to have_http_status(404) - end - end end end diff --git a/spec/requests/collections_spec.rb b/spec/requests/collections_spec.rb new file mode 100644 index 0000000000..fece4b62b8 --- /dev/null +++ b/spec/requests/collections_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Collections' do + describe 'GET /@:account_username/collections/:id', feature: :collections do + subject { get account_collection_path(account, collection, format: :json) } + + let(:collection) { Fabricate(:collection) } + let(:account) { collection.account } + + context 'when signed out' do + context 'when account is permanently suspended' do + before do + account.suspend! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is temporarily suspended' do + before { account.suspend! } + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end + + context 'when account is accessible' do + context 'with JSON' do + subject { get ap_account_collection_path(account.id, collection, format: :json) } + + it 'renders ActivityPub FeaturedCollection object successfully', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + .and have_cacheable_headers.with_vary('Accept, Accept-Language, Cookie') + + expect(response.headers).to include( + 'Content-Type' => include('application/activity+json') + ) + expect(response.parsed_body) + .to include({ + 'type' => 'FeaturedCollection', + 'name' => collection.name, + }) + end + end + end + end + + context 'when signed in' do + let(:user) { Fabricate(:user) } + + before do + post user_session_path, params: { user: { email: user.email, password: user.password } } + end + + context 'when account blocks user' do + before { account.block!(user.account) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + end + + context 'with "HTTP Signature" access signed by a remote account' do + subject do + get account_collection_path(account, collection, format: :json), + headers: nil, + sign_with: remote_account + end + + let(:remote_account) { Fabricate(:account, domain: 'host.example') } + + context 'when account blocks the remote account' do + before { account.block!(remote_account) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + + context 'when account domain blocks the domain of the remote account' do + before { account.block_domain!(remote_account.domain) } + + it 'returns http not found' do + subject + + expect(response) + .to have_http_status(404) + end + end + + context 'with JSON' do + subject do + get ap_account_collection_path(account.id, collection, format: :json), + headers: nil, + sign_with: remote_account + end + + it 'renders ActivityPub FeaturedCollection object successfully', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + .and have_cacheable_headers.with_vary('Accept, Accept-Language, Cookie') + + expect(response.headers).to include( + 'Content-Type' => include('application/activity+json') + ) + expect(response.parsed_body) + .to include({ + 'type' => 'FeaturedCollection', + 'name' => collection.name, + }) + end + end + end + end +end