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

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

View File

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

View File

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

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

View File

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

View File

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

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