Add collection endpoint (#37468)

This commit is contained in:
David Roetzel 2026-01-14 11:08:29 +01:00 committed by GitHub
parent 2f91d9755d
commit c218849204
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 223 additions and 20 deletions

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class ActivityPub::CollectionsController < ActivityPub::BaseController class ActivityPub::CollectionsController < ActivityPub::BaseController
SUPPORTED_COLLECTIONS = %w(featured tags).freeze
vary_by -> { 'Signature' if authorized_fetch_mode? } vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode? before_action :require_account_signature!, if: :authorized_fetch_mode?

View File

@ -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

View File

@ -63,7 +63,7 @@ class ActivityPub::TagManager
when :flag when :flag
target.uri target.uri
when :featured_collection when :featured_collection
ap_account_featured_collection_url(target.account.id, target) ap_account_collection_url(target.account.id, target)
end end
end end
@ -135,7 +135,7 @@ class ActivityPub::TagManager
def collection_uri_for(target, ...) def collection_uri_for(target, ...)
raise ArgumentError, 'target must be a local account' unless target.local? 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 end
def inbox_uri_for(target) def inbox_uri_for(target)

View File

@ -6,7 +6,7 @@ class CollectionPolicy < ApplicationPolicy
end end
def show? def show?
true current_account.nil? || (!owner_blocking? && !owner_blocking_domain?)
end end
def create? def create?
@ -24,6 +24,22 @@ class CollectionPolicy < ApplicationPolicy
private private
def owner? 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
end end

View File

@ -96,14 +96,14 @@ Rails.application.routes.draw do
get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" } get '/authorize_follow', to: redirect { |_, request| "/authorize_interaction?#{request.params.to_query}" }
concern :account_resources do concern :account_resources do
resources :featured_collections, only: [:show] resources :collections, only: [:show], constraints: { id: /\d+/ }
resources :followers, only: [:index], controller: :follower_accounts resources :followers, only: [:index], controller: :follower_accounts
resources :following, only: [:index], controller: :following_accounts resources :following, only: [:index], controller: :following_accounts
scope module: :activitypub do scope module: :activitypub do
resource :outbox, only: [:show] resource :outbox, only: [:show]
resource :inbox, only: [:create] 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] resource :followers_synchronization, only: [:show]
resources :quote_authorizations, only: [:show] resources :quote_authorizations, only: [:show]
end end

View File

@ -198,7 +198,7 @@ RSpec.describe ActivityPub::TagManager do
it 'returns a string starting with web domain and with the expected path' do it 'returns a string starting with web domain and with the expected path' do
expect(subject.uri_for(collection)) 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
end end

View File

@ -16,11 +16,23 @@ RSpec.describe CollectionPolicy do
end end
permissions :show? do permissions :show? do
it 'permits everyone to show' do it 'permits when no user is given' do
expect(policy).to permit(nil, collection) expect(policy).to permit(nil, collection)
end
it 'permits unblocked users' do
expect(policy).to permit(owner, collection) expect(policy).to permit(owner, collection)
expect(policy).to permit(other_user, collection) expect(policy).to permit(other_user, collection)
end 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 end
permissions :create? do permissions :create? do

View File

@ -14,7 +14,7 @@ RSpec.describe 'ActivityPub Collections' do
end end
describe 'GET #show' do 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 context 'when id is "featured"' do
let(:id) { 'featured' } let(:id) { 'featured' }
@ -131,16 +131,5 @@ RSpec.describe 'ActivityPub Collections' do
end end
end 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
end end

View File

@ -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