diff --git a/app/controllers/activitypub/featured_collections_controller.rb b/app/controllers/activitypub/featured_collections_controller.rb new file mode 100644 index 00000000000..872d03423d2 --- /dev/null +++ b/app/controllers/activitypub/featured_collections_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class ActivityPub::FeaturedCollectionsController < ApplicationController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + PER_PAGE = 5 + + vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' } + + before_action :check_feature_enabled + before_action :require_account_signature!, if: -> { authorized_fetch_mode? } + before_action :set_collections + + skip_around_action :set_locale + skip_before_action :require_functional!, unless: :limited_federation_mode? + + def index + respond_to do |format| + format.json do + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) + + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + end + end + + private + + def set_collections + authorize @account, :index_collections? + @collections = @account.collections.page(params[:page]).per(PER_PAGE) + rescue Mastodon::NotPermittedError + not_found + end + + def page_requested? + params[:page].present? + end + + def next_page_url + ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page) + end + + def prev_page_url + ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page) + end + + def collection_presenter + if page_requested? + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)), + type: :unordered, + size: @account.collections.count, + items: @collections, + part_of: ap_account_featured_collections_url(@account), + next: next_page_url, + prev: prev_page_url + ) + else + ActivityPub::CollectionPresenter.new( + id: ap_account_featured_collections_url(@account), + type: :unordered, + size: @account.collections.count, + first: ap_account_featured_collections_url(@account, page: 1) + ) + end + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/models/concerns/account/interactions.rb b/app/models/concerns/account/interactions.rb index c51ccf1229e..d4a415ee31b 100644 --- a/app/models/concerns/account/interactions.rb +++ b/app/models/concerns/account/interactions.rb @@ -164,6 +164,13 @@ module Account::Interactions end end + def blocking_or_domain_blocking?(other_account) + return true if blocking?(other_account) + return false if other_account.domain.blank? + + domain_blocking?(other_account.domain) + end + def muting?(other_account) other_id = other_account.is_a?(Account) ? other_account.id : other_account diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index ab3b41d6280..1fef35714cd 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy def feature? record.featureable? && !current_account.blocking?(record) && !current_account.blocked_by?(record) end + + def index_collections? + current_account.nil? || !record.blocking_or_domain_blocking?(current_account) + end end diff --git a/app/policies/collection_policy.rb b/app/policies/collection_policy.rb index 70a869d16ad..4d100c0e32f 100644 --- a/app/policies/collection_policy.rb +++ b/app/policies/collection_policy.rb @@ -6,7 +6,7 @@ class CollectionPolicy < ApplicationPolicy end def show? - current_account.nil? || (!owner_blocking? && !owner_blocking_domain?) + current_account.nil? || !owner.blocking_or_domain_blocking?(current_account) end def create? @@ -27,18 +27,6 @@ class CollectionPolicy < ApplicationPolicy 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 diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index c19d42bfb43..ff1a70104b8 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -19,6 +19,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer :discoverable, :indexable, :published, :memorial attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? } + attribute :featured_collections, if: -> { Mastodon::Feature.collections_enabled? } has_one :public_key, serializer: ActivityPub::PublicKeySerializer @@ -177,6 +178,12 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer } end + def featured_collections + return nil if instance_actor? + + ap_account_featured_collections_url(object.id) + end + class CustomEmojiSerializer < ActivityPub::EmojiSerializer end diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb index 1b410cecaef..ba0d17f5408 100644 --- a/app/serializers/activitypub/collection_serializer.rb +++ b/app/serializers/activitypub/collection_serializer.rb @@ -18,6 +18,8 @@ class ActivityPub::CollectionSerializer < ActivityPub::Serializer ActivityPub::HashtagSerializer when 'ActivityPub::CollectionPresenter' ActivityPub::CollectionSerializer + when 'Collection' + ActivityPub::FeaturedCollectionSerializer when 'String' StringSerializer else diff --git a/config/routes.rb b/config/routes.rb index b3338a725eb..bf50b67fe13 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -124,6 +124,8 @@ Rails.application.routes.draw do scope path: 'ap', as: 'ap' do resources :accounts, path: 'users', only: [:show], param: :id, concerns: :account_resources do + resources :featured_collections, only: [:index], module: :activitypub + resources :statuses, only: [:show] do member do get :activity diff --git a/spec/models/concerns/account/interactions_spec.rb b/spec/models/concerns/account/interactions_spec.rb index cc50c465517..5bca7959080 100644 --- a/spec/models/concerns/account/interactions_spec.rb +++ b/spec/models/concerns/account/interactions_spec.rb @@ -450,6 +450,44 @@ RSpec.describe Account::Interactions do end end + describe '#blocking_or_domain_blocking?' do + subject { account.blocking_or_domain_blocking?(target_account) } + + context 'when blocking target_account' do + before do + account.block_relationships.create(target_account: target_account) + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + + expect(result).to be true + end + end + + context 'when blocking the domain' do + let(:target_account) { Fabricate(:remote_account) } + + before do + account_domain_block = Fabricate(:account_domain_block, domain: target_account.domain) + account.domain_blocks << account_domain_block + end + + it 'returns true' do + result = nil + expect { result = subject }.to execute_queries + expect(result).to be true + end + end + + context 'when blocking neither target_account nor its domain' do + it 'returns false' do + expect(subject).to be false + end + end + end + describe '#muting?' do subject { account.muting?(target_account) } diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index f877bded252..96fcbdb4d82 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -188,4 +188,24 @@ RSpec.describe AccountPolicy do end end end + + permissions :index_collections? do + it 'permits when no user is given' do + expect(subject).to permit(nil, john) + end + + it 'permits unblocked users' do + expect(subject).to permit(john, john) + expect(subject).to permit(alice, john) + end + + it 'denies blocked users' do + domain_blocked_user = Fabricate(:remote_account) + john.block_domain!(domain_blocked_user.domain) + john.block!(alice) + + expect(subject).to_not permit(domain_blocked_user, john) + expect(subject).to_not permit(alice, john) + end + end end diff --git a/spec/requests/activitypub/featured_collections_spec.rb b/spec/requests/activitypub/featured_collections_spec.rb new file mode 100644 index 00000000000..09a17c53bea --- /dev/null +++ b/spec/requests/activitypub/featured_collections_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Collections' do + describe 'GET /ap/users/@:account_id/featured_collections', feature: :collections do + subject { get ap_account_featured_collections_path(account.id, 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 + it 'renders ActivityPub Collection 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' => 'Collection', + 'totalItems' => 1, + 'first' => match(%r{^https://.*page=1.*$}), + }) + end + + context 'when requesting the first page' do + subject { get ap_account_featured_collections_path(account.id, page: 1, format: :json) } + + context 'when account has many collections' do + before do + Fabricate.times(5, :collection, account:) + end + + it 'includes a link to the next page', :aggregate_failures do + subject + + expect(response) + .to have_http_status(200) + + expect(response.parsed_body) + .to include({ + 'type' => 'CollectionPage', + 'totalItems' => 6, + 'next' => match(%r{^https://.*page=2.*$}), + }) + end + 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 ap_account_featured_collections_path(account.id, 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 + 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' => 'Collection', + 'totalItems' => 1, + }) + end + end + end + end +end diff --git a/spec/serializers/activitypub/collection_serializer_spec.rb b/spec/serializers/activitypub/collection_serializer_spec.rb index 7726df914f2..d7099ba3d5a 100644 --- a/spec/serializers/activitypub/collection_serializer_spec.rb +++ b/spec/serializers/activitypub/collection_serializer_spec.rb @@ -35,5 +35,11 @@ RSpec.describe ActivityPub::CollectionSerializer do it { is_expected.to eq(ActiveModel::Serializer::CollectionSerializer) } end + + context 'with a Collection' do + let(:model) { Collection.new } + + it { is_expected.to eq(ActivityPub::FeaturedCollectionSerializer) } + end end end