Federated "featureable in collections" preference (#37298)
This commit is contained in:
parent
f254b47067
commit
4e63958914
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
|
# Table name: accounts
|
||||||
#
|
#
|
||||||
# id :bigint(8) not null, primary key
|
# id :bigint(8) not null, primary key
|
||||||
# username :string default(""), not null
|
# actor_type :string
|
||||||
# domain :string
|
# also_known_as :string is an Array
|
||||||
# private_key :text
|
# attribution_domains :string default([]), is an Array
|
||||||
# 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
|
|
||||||
# avatar_content_type :string
|
# avatar_content_type :string
|
||||||
|
# avatar_file_name :string
|
||||||
# avatar_file_size :integer
|
# 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
|
# avatar_remote_url :string
|
||||||
# locked :boolean default(FALSE), not null
|
# avatar_storage_schema_version :integer
|
||||||
# header_remote_url :string default(""), not null
|
# avatar_updated_at :datetime
|
||||||
# last_webfingered_at :datetime
|
# discoverable :boolean
|
||||||
# inbox_url :string default(""), not null
|
# display_name :string default(""), not null
|
||||||
# outbox_url :string default(""), not null
|
# domain :string
|
||||||
# shared_inbox_url :string default(""), not null
|
# feature_approval_policy :integer default(0), 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)
|
|
||||||
# featured_collection_url :string
|
# featured_collection_url :string
|
||||||
# fields :jsonb
|
# fields :jsonb
|
||||||
# actor_type :string
|
# followers_url :string default(""), not null
|
||||||
# discoverable :boolean
|
# following_url :string default(""), not null
|
||||||
# also_known_as :string is an Array
|
# 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
|
# silenced_at :datetime
|
||||||
# suspended_at :datetime
|
# suspended_at :datetime
|
||||||
# hide_collections :boolean
|
|
||||||
# avatar_storage_schema_version :integer
|
|
||||||
# header_storage_schema_version :integer
|
|
||||||
# suspension_origin :integer
|
# suspension_origin :integer
|
||||||
# sensitized_at :datetime
|
|
||||||
# trendable :boolean
|
# trendable :boolean
|
||||||
# reviewed_at :datetime
|
# uri :string default(""), not null
|
||||||
# requested_review_at :datetime
|
# url :string
|
||||||
# indexable :boolean default(FALSE), not null
|
# username :string default(""), not null
|
||||||
# attribution_domains :string default([]), is an Array
|
# created_at :datetime not null
|
||||||
# id_scheme :integer default("numeric_ap_id")
|
# updated_at :datetime not null
|
||||||
|
# moved_to_account_id :bigint(8)
|
||||||
#
|
#
|
||||||
|
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
|
|||||||
@ -10,12 +10,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||||||
:moved_to, :property_value, :discoverable, :suspended,
|
:moved_to, :property_value, :discoverable, :suspended,
|
||||||
:memorial, :indexable, :attribution_domains
|
:memorial, :indexable, :attribution_domains
|
||||||
|
|
||||||
|
context_extensions :interaction_policies if Mastodon::Feature.collections_enabled?
|
||||||
|
|
||||||
attributes :id, :type, :following, :followers,
|
attributes :id, :type, :following, :followers,
|
||||||
:inbox, :outbox, :featured, :featured_tags,
|
:inbox, :outbox, :featured, :featured_tags,
|
||||||
:preferred_username, :name, :summary,
|
:preferred_username, :name, :summary,
|
||||||
:url, :manually_approves_followers,
|
:url, :manually_approves_followers,
|
||||||
:discoverable, :indexable, :published, :memorial
|
:discoverable, :indexable, :published, :memorial
|
||||||
|
|
||||||
|
attribute :interaction_policy, if: -> { Mastodon::Feature.collections_enabled? }
|
||||||
|
|
||||||
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
|
||||||
|
|
||||||
has_many :virtual_tags, key: :tag
|
has_many :virtual_tags, key: :tag
|
||||||
@ -163,6 +167,16 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
|
|||||||
object.created_at.midnight.iso8601
|
object.created_at.midnight.iso8601
|
||||||
end
|
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
|
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
@account.uri = @uri
|
@account.uri = @uri
|
||||||
@account.actor_type = actor_type
|
@account.actor_type = actor_type
|
||||||
@account.created_at = @json['published'] if @json['published'].present?
|
@account.created_at = @json['published'] if @json['published'].present?
|
||||||
|
@account.feature_approval_policy = feature_approval_policy if Mastodon::Feature.collections_enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_collection_uri(uri)
|
def valid_collection_uri(uri)
|
||||||
@ -360,4 +361,8 @@ class ActivityPub::ProcessAccountService < BaseService
|
|||||||
emoji.image_remote_url = image_url
|
emoji.image_remote_url = image_url
|
||||||
emoji.save
|
emoji.save
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def feature_approval_policy
|
||||||
|
ActivityPub::Parser::InteractionPolicyParser.new(@json.dig('interactionPolicy', 'canFeature'), @account).bitmap
|
||||||
|
end
|
||||||
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
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 "attribution_domains", default: [], array: true
|
||||||
t.string "following_url", default: "", null: false
|
t.string "following_url", default: "", null: false
|
||||||
t.integer "id_scheme", default: 1
|
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 "(((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 "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"
|
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'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe ActivityPub::ActorSerializer do
|
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
|
describe '#type' do
|
||||||
context 'with the instance actor' do
|
context 'with the instance actor' do
|
||||||
@ -36,4 +36,39 @@ RSpec.describe ActivityPub::ActorSerializer do
|
|||||||
it { is_expected.to include('type' => 'Person') }
|
it { is_expected.to include('type' => 'Person') }
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@ -272,6 +272,49 @@ RSpec.describe ActivityPub::ProcessAccountService do
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def create_some_remote_accounts
|
def create_some_remote_accounts
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user