mirror of
https://github.com/mastodon/mastodon.git
synced 2026-01-11 19:56:37 +00:00
Federated "featureable in collections" preference (#37298)
This commit is contained in:
parent
f254b47067
commit
4e63958914
9 changed files with 290 additions and 41 deletions
41
app/lib/activitypub/parser/interaction_policy_parser.rb
Normal file
41
app/lib/activitypub/parser/interaction_policy_parser.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
102
spec/lib/activitypub/parser/interaction_policy_parser_spec.rb
Normal file
102
spec/lib/activitypub/parser/interaction_policy_parser_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue