Federated "featureable in collections" preference (#37298)

This commit is contained in:
David Roetzel 2025-12-19 14:44:27 +01:00 committed by GitHub
parent f254b47067
commit 4e63958914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 290 additions and 41 deletions

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class ActivityPub::Parser::InteractionPolicyParser
def initialize(json, account)
@json = json
@account = account
end
def bitmap
flags = 0
return flags if @json.blank?
flags |= subpolicy(@json['automaticApproval'])
flags <<= 16
flags |= subpolicy(@json['manualApproval'])
flags
end
private
def subpolicy(partial_json)
flags = 0
allowed_actors = Array(partial_json).dup
allowed_actors.uniq!
flags |= InteractionPolicy::POLICY_FLAGS[:public] if allowed_actors.delete('as:Public') || allowed_actors.delete('Public') || allowed_actors.delete('https://www.w3.org/ns/activitystreams#Public')
flags |= InteractionPolicy::POLICY_FLAGS[:followers] if allowed_actors.delete(@account.followers_url)
flags |= InteractionPolicy::POLICY_FLAGS[:following] if allowed_actors.delete(@account.following_url)
includes_target_actor = allowed_actors.delete(ActivityPub::TagManager.instance.uri_for(@account)).present?
# Any unrecognized actor is marked as unsupported
flags |= InteractionPolicy::POLICY_FLAGS[:unsupported_policy] unless allowed_actors.empty?
flags |= InteractionPolicy::POLICY_FLAGS[:disabled] if flags.zero? && includes_target_actor
flags
end
end

View file

@ -5,54 +5,55 @@
# Table name: accounts
#
# id :bigint(8) not null, primary key
# username :string default(""), not null
# domain :string
# private_key :text
# public_key :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# note :text default(""), not null
# display_name :string default(""), not null
# uri :string default(""), not null
# url :string
# avatar_file_name :string
# actor_type :string
# also_known_as :string is an Array
# attribution_domains :string default([]), is an Array
# avatar_content_type :string
# avatar_file_name :string
# avatar_file_size :integer
# avatar_updated_at :datetime
# header_file_name :string
# header_content_type :string
# header_file_size :integer
# header_updated_at :datetime
# avatar_remote_url :string
# locked :boolean default(FALSE), not null
# header_remote_url :string default(""), not null
# last_webfingered_at :datetime
# inbox_url :string default(""), not null
# outbox_url :string default(""), not null
# shared_inbox_url :string default(""), not null
# followers_url :string default(""), not null
# following_url :string default(""), not null
# protocol :integer default("ostatus"), not null
# memorial :boolean default(FALSE), not null
# moved_to_account_id :bigint(8)
# avatar_storage_schema_version :integer
# avatar_updated_at :datetime
# discoverable :boolean
# display_name :string default(""), not null
# domain :string
# feature_approval_policy :integer default(0), not null
# featured_collection_url :string
# fields :jsonb
# actor_type :string
# discoverable :boolean
# also_known_as :string is an Array
# followers_url :string default(""), not null
# following_url :string default(""), not null
# header_content_type :string
# header_file_name :string
# header_file_size :integer
# header_remote_url :string default(""), not null
# header_storage_schema_version :integer
# header_updated_at :datetime
# hide_collections :boolean
# id_scheme :integer default("numeric_ap_id")
# inbox_url :string default(""), not null
# indexable :boolean default(FALSE), not null
# last_webfingered_at :datetime
# locked :boolean default(FALSE), not null
# memorial :boolean default(FALSE), not null
# note :text default(""), not null
# outbox_url :string default(""), not null
# private_key :text
# protocol :integer default("ostatus"), not null
# public_key :text default(""), not null
# requested_review_at :datetime
# reviewed_at :datetime
# sensitized_at :datetime
# shared_inbox_url :string default(""), not null
# silenced_at :datetime
# suspended_at :datetime
# hide_collections :boolean
# avatar_storage_schema_version :integer
# header_storage_schema_version :integer
# suspension_origin :integer
# sensitized_at :datetime
# trendable :boolean
# reviewed_at :datetime
# requested_review_at :datetime
# indexable :boolean default(FALSE), not null
# attribution_domains :string default([]), is an Array
# id_scheme :integer default("numeric_ap_id")
# uri :string default(""), not null
# url :string
# username :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# moved_to_account_id :bigint(8)
#
class Account < ApplicationRecord

View file

@ -10,12 +10,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
:moved_to, :property_value, :discoverable, :suspended,
:memorial, :indexable, :attribution_domains
context_extensions :interaction_policies if Mastodon::Feature.collections_enabled?
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags,
:preferred_username, :name, :summary,
:url, :manually_approves_followers,
:discoverable, :indexable, :published, :memorial
attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? }
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
has_many :virtual_tags, key: :tag
@ -163,6 +167,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
object.created_at.midnight.iso8601
end
def interaction_policy
uri = object.discoverable? ? ActivityPub::TagManager::COLLECTIONS[:public] : ActivityPub::TagManager.instance.uri_for(object)
{
canFeature: {
automaticApproval: [uri],
},
}
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end

View file

@ -107,6 +107,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.uri = @uri
@account.actor_type = actor_type
@account.created_at = @json['published'] if @json['published'].present?
@account.feature_approval_policy = feature_approval_policy if Mastodon::Feature.collections_enabled?
end
def valid_collection_uri(uri)
@ -360,4 +361,8 @@ class ActivityPub::ProcessAccountService < BaseService
emoji.image_remote_url = image_url
emoji.save
end
def feature_approval_policy
ActivityPub::Parser::InteractionPolicyParser.new(@json.dig('interactionPolicy', 'canFeature'), @account).bitmap
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddFeatureApprovalPolicyToAccounts < ActiveRecord::Migration[8.0]
def change
add_column :accounts, :feature_approval_policy, :integer, null: false, default: 0
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_12_09_093813) do
ActiveRecord::Schema[8.0].define(version: 2025_12_17_091936) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -200,6 +200,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_09_093813) do
t.string "attribution_domains", default: [], array: true
t.string "following_url", default: "", null: false
t.integer "id_scheme", default: 1
t.integer "feature_approval_policy", default: 0, null: false
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["domain", "id"], name: "index_accounts_on_domain_and_id"

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::Parser::InteractionPolicyParser do
subject { described_class.new(json_policy, account) }
let(:account) do
Fabricate(:account,
uri: 'https://foo.test',
domain: 'foo.test',
followers_url: 'https://foo.test/followers',
following_url: 'https://foo.test/following')
end
describe '#bitmap' do
context 'when no policy is given' do
let(:json_policy) { nil }
it 'returns zero' do
expect(subject.bitmap).to be_zero
end
end
context 'with special public URI' do
let(:json_policy) do
{
'manualApproval' => [public_uri],
}
end
shared_examples 'setting the public bit' do
it 'sets the public bit' do
expect(subject.bitmap).to eq 0b10
end
end
context 'when public URI is given in full' do
let(:public_uri) { 'https://www.w3.org/ns/activitystreams#Public' }
it_behaves_like 'setting the public bit'
end
context 'when public URI is abbreviated using namespace' do
let(:public_uri) { 'as:Public' }
it_behaves_like 'setting the public bit'
end
context 'when public URI is abbreviated without namespace' do
let(:public_uri) { 'Public' }
it_behaves_like 'setting the public bit'
end
end
context 'when mixing array and scalar values' do
let(:json_policy) do
{
'automaticApproval' => 'https://foo.test',
'manualApproval' => [
'https://foo.test/followers',
'https://foo.test/following',
],
}
end
it 'sets the correct flags' do
expect(subject.bitmap).to eq 0b100000000000000001100
end
end
context 'when including individual actor URIs' do
let(:json_policy) do
{
'automaticApproval' => ['https://example.com/actor', 'https://masto.example.com/@user'],
'manualApproval' => ['https://masto.example.com/@other'],
}
end
it 'sets the unsupported bit' do
expect(subject.bitmap).to eq 0b10000000000000001
end
end
context "when giving the affected actor's URI in addition to other supported URIs" do
let(:json_policy) do
{
'manualApproval' => [
'https://foo.test/followers',
'https://foo.test/following',
'https://foo.test',
],
}
end
it 'is being ignored' do
expect(subject.bitmap).to eq 0b1100
end
end
end
end

View file

@ -3,7 +3,7 @@
require 'rails_helper'
RSpec.describe ActivityPub::ActorSerializer do
subject { serialized_record_json(record, described_class) }
subject { serialized_record_json(record, described_class, adapter: ActivityPub::Adapter) }
describe '#type' do
context 'with the instance actor' do
@ -36,4 +36,39 @@ RSpec.describe ActivityPub::ActorSerializer do
it { is_expected.to include('type' => 'Person') }
end
end
describe '#interactionPolicy' do
let(:record) { Fabricate(:account) }
# TODO: Remove when feature flag is removed
context 'when collections feature is disabled?' do
it 'is not present' do
expect(subject).to_not have_key('interactionPolicy')
end
end
context 'when collections feature is enabled', feature: :collections do
context 'when actor is discoverable' do
it 'includes an automatic policy allowing everyone' do
expect(subject).to include('interactionPolicy' => {
'canFeature' => {
'automaticApproval' => ['https://www.w3.org/ns/activitystreams#Public'],
},
})
end
end
context 'when actor is not discoverable' do
let(:record) { Fabricate(:account, discoverable: false) }
it 'includes an automatic policy limited to the actor itself' do
expect(subject).to include('interactionPolicy' => {
'canFeature' => {
'automaticApproval' => [ActivityPub::TagManager.instance.uri_for(record)],
},
})
end
end
end
end
end

View file

@ -272,6 +272,49 @@ RSpec.describe ActivityPub::ProcessAccountService do
end
end
context 'with interaction policy' do
let(:payload) do
{
id: 'https://foo.test',
type: 'Actor',
inbox: 'https://foo.test/inbox',
followers: 'https://foo.test/followers',
following: 'https://foo.test/following',
interactionPolicy: {
canFeature: {
automaticApproval: 'https://foo.test',
manualApproval: [
'https://foo.test/followers',
'https://foo.test/following',
],
},
},
}.with_indifferent_access
end
before do
stub_request(:get, %r{^https://foo\.test/follow})
.to_return(status: 200, body: '', headers: {})
end
# TODO: Remove when feature flag is removed
context 'when collections feature is disabled' do
it 'does not set the interaction policy' do
account = subject.call('user1', 'foo.test', payload)
expect(account.feature_approval_policy).to be_zero
end
end
context 'when collections feature is enabled', feature: :collections do
it 'sets the interaction policy to the correct value' do
account = subject.call('user1', 'foo.test', payload)
expect(account.feature_approval_policy).to eq 0b100000000000000001100
end
end
end
private
def create_some_remote_accounts