Extract interaction policy class (#37277)

This commit is contained in:
David Roetzel 2025-12-17 11:12:43 +01:00 committed by GitHub
parent db62d70492
commit 5e8b8f9c23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 126 additions and 54 deletions

View File

@ -6,9 +6,9 @@ module Api::InteractionPoliciesConcern
def quote_approval_policy def quote_approval_policy
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
when 'public' when 'public'
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 InteractionPolicy::POLICY_FLAGS[:public] << 16
when 'followers' when 'followers'
Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16 InteractionPolicy::POLICY_FLAGS[:followers] << 16
when 'nobody' when 'nobody'
0 0
else else

View File

@ -174,15 +174,15 @@ class ActivityPub::Parser::StatusParser
allowed_actors = as_array(subpolicy).dup allowed_actors = as_array(subpolicy).dup
allowed_actors.uniq! allowed_actors.uniq!
flags |= Status::QUOTE_APPROVAL_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[:public] if allowed_actors.delete('as:Public') || allowed_actors.delete('Public') || allowed_actors.delete('https://www.w3.org/ns/activitystreams#Public')
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] if allowed_actors.delete(@options[:followers_collection]) flags |= InteractionPolicy::POLICY_FLAGS[:followers] if allowed_actors.delete(@options[:followers_collection])
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:following] if allowed_actors.delete(@options[:following_collection]) flags |= InteractionPolicy::POLICY_FLAGS[:following] if allowed_actors.delete(@options[:following_collection])
# Remove the special-meaning actor URI # Remove the special-meaning actor URI
allowed_actors.delete(@options[:actor_uri]) allowed_actors.delete(@options[:actor_uri])
# Any unrecognized actor is marked as unsupported # Any unrecognized actor is marked as unsupported
flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:unsupported_policy] unless allowed_actors.empty? flags |= InteractionPolicy::POLICY_FLAGS[:unsupported_policy] unless allowed_actors.empty?
flags flags
end end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
class InteractionPolicy
POLICY_FLAGS = {
unsupported_policy: (1 << 0), # Not supported by Mastodon
public: (1 << 1), # Everyone is allowed to interact
followers: (1 << 2), # Only followers may interact
following: (1 << 3), # Only accounts followed by the target may interact
disabled: (1 << 4), # All interaction explicitly disabled
}.freeze
class SubPolicy
def initialize(bitmap)
@bitmap = bitmap
end
def as_keys
POLICY_FLAGS.keys.select { |key| @bitmap.anybits?(POLICY_FLAGS[key]) }.map(&:to_s)
end
POLICY_FLAGS.each_key do |key|
define_method :"#{key}?" do
@bitmap.anybits?(POLICY_FLAGS[key])
end
end
def missing?
@bitmap.zero?
end
end
attr_reader :automatic, :manual
def initialize(bitmap)
@bitmap = bitmap
@automatic = SubPolicy.new(@bitmap >> 16)
@manual = SubPolicy.new(@bitmap & 0xFFFF)
end
end

View File

@ -3,26 +3,17 @@
module Status::InteractionPolicyConcern module Status::InteractionPolicyConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
QUOTE_APPROVAL_POLICY_FLAGS = {
unsupported_policy: (1 << 0),
public: (1 << 1),
followers: (1 << 2),
following: (1 << 3),
}.freeze
included do included do
composed_of :quote_interaction_policy, class_name: 'InteractionPolicy', mapping: { quote_approval_policy: :bitmap }
before_validation :downgrade_quote_policy, if: -> { local? && !distributable? } before_validation :downgrade_quote_policy, if: -> { local? && !distributable? }
end end
def quote_policy_as_keys(kind) def quote_policy_as_keys(kind)
case kind raise ArgumentError unless kind.in?(%i(automatic manual))
when :automatic
policy = quote_approval_policy >> 16
when :manual
policy = quote_approval_policy & 0xFFFF
end
QUOTE_APPROVAL_POLICY_FLAGS.keys.select { |key| policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[key]) }.map(&:to_s) sub_policy = quote_interaction_policy.send(kind)
sub_policy.as_keys
end end
# Returns `:automatic`, `:manual`, `:unknown` or `:denied` # Returns `:automatic`, `:manual`, `:unknown` or `:denied`
@ -35,35 +26,36 @@ module Status::InteractionPolicyConcern
# Post author is always allowed to quote themselves # Post author is always allowed to quote themselves
return :automatic if account_id == other_account.id return :automatic if account_id == other_account.id
automatic_policy = quote_approval_policy >> 16 automatic_policy = quote_interaction_policy.automatic
manual_policy = quote_approval_policy & 0xFFFF
return :automatic if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) return :automatic if automatic_policy.public?
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) if automatic_policy.followers?
following_author = other_account.following?(account) if following_author.nil? following_author = other_account.following?(account) if following_author.nil?
return :automatic if following_author return :automatic if following_author
end end
if automatic_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:following]) if automatic_policy.following?
followed_by_author = account.following?(other_account) if followed_by_author.nil? followed_by_author = account.following?(other_account) if followed_by_author.nil?
return :automatic if followed_by_author return :automatic if followed_by_author
end end
# We don't know we are allowed by the automatic policy, considering the manual one # We don't know we are allowed by the automatic policy, considering the manual one
return :manual if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:public]) manual_policy = quote_interaction_policy.manual
if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:followers]) return :manual if manual_policy.public?
if manual_policy.followers?
following_author = other_account.following?(account) if following_author.nil? following_author = other_account.following?(account) if following_author.nil?
return :manual if following_author return :manual if following_author
end end
if manual_policy.anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:following]) if manual_policy.following?
followed_by_author = account.following?(other_account) if followed_by_author.nil? followed_by_author = account.following?(other_account) if followed_by_author.nil?
return :manual if followed_by_author return :manual if followed_by_author
end end
return :unknown if (automatic_policy | manual_policy).anybits?(QUOTE_APPROVAL_POLICY_FLAGS[:unsupported_policy]) return :unknown if [automatic_policy, manual_policy].any?(&:unsupported_policy?)
:denied :denied
end end

View File

@ -235,10 +235,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
approved_uris = [] approved_uris = []
# On outgoing posts, only automatic approval is supported # On outgoing posts, only automatic approval is supported
policy = object.quote_approval_policy >> 16 policy = object.quote_interaction_policy.automatic
approved_uris << ActivityPub::TagManager::COLLECTIONS[:public] if policy.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public]) approved_uris << ActivityPub::TagManager::COLLECTIONS[:public] if policy.public?
approved_uris << ActivityPub::TagManager.instance.followers_uri_for(object.account) if policy.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers]) approved_uris << ActivityPub::TagManager.instance.followers_uri_for(object.account) if policy.followers?
approved_uris << ActivityPub::TagManager.instance.following_uri_for(object.account) if policy.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[:following]) approved_uris << ActivityPub::TagManager.instance.following_uri_for(object.account) if policy.following?
approved_uris << ActivityPub::TagManager.instance.uri_for(object.account) if approved_uris.empty? approved_uris << ActivityPub::TagManager.instance.uri_for(object.account) if approved_uris.empty?
{ {

View File

@ -12,7 +12,7 @@ class REST::ScheduledStatusSerializer < ActiveModel::Serializer
def params def params
object.params.merge( object.params.merge(
quoted_status_id: object.params['quoted_status_id']&.to_s, quoted_status_id: object.params['quoted_status_id']&.to_s,
quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS.keys.find { |key| object.params['quote_approval_policy']&.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[key] << 16) }&.to_s || 'nobody' quote_approval_policy: InteractionPolicy::POLICY_FLAGS.keys.find { |key| object.params['quote_approval_policy']&.anybits?(InteractionPolicy::POLICY_FLAGS[key] << 16) }&.to_s || 'nobody'
) )
end end
end end

View File

@ -87,7 +87,7 @@ RSpec.describe ActivityPub::Activity::QuoteRequest do
context 'when trying to quote a quotable local status' do context 'when trying to quote a quotable local status' do
before do before do
stub_request(:get, 'https://example.com/unknown-status').to_return(status: 200, body: Oj.dump(status_json), headers: { 'Content-Type': 'application/activity+json' }) stub_request(:get, 'https://example.com/unknown-status').to_return(status: 200, body: Oj.dump(status_json), headers: { 'Content-Type': 'application/activity+json' })
quoted_post.update(quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) quoted_post.update(quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16)
end end
it 'accepts the quote and sends an Accept activity' do it 'accepts the quote and sends an Accept activity' do
@ -105,7 +105,7 @@ RSpec.describe ActivityPub::Activity::QuoteRequest do
let(:instrument) { status_json.without('@context') } let(:instrument) { status_json.without('@context') }
before do before do
quoted_post.update(quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) quoted_post.update(quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16)
end end
it 'accepts the quote and sends an Accept activity' do it 'accepts the quote and sends an Accept activity' do

View File

@ -148,7 +148,7 @@ RSpec.describe ActivityPub::Parser::StatusParser do
end end
it 'returns a policy not allowing anyone to quote' do it 'returns a policy not allowing anyone to quote' do
expect(subject).to eq(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) expect(subject).to eq(InteractionPolicy::POLICY_FLAGS[:public] << 16)
end end
end end
@ -174,7 +174,7 @@ RSpec.describe ActivityPub::Parser::StatusParser do
end end
it 'returns a policy allowing everyone including followers' do it 'returns a policy allowing everyone including followers' do
expect(subject).to eq Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] | (Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) expect(subject).to eq InteractionPolicy::POLICY_FLAGS[:public] | (InteractionPolicy::POLICY_FLAGS[:followers] << 16)
end end
end end
end end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InteractionPolicy do
subject { described_class.new(bitmap) }
let(:bitmap) { (0b0101 << 16) | 0b0010 }
describe described_class::SubPolicy do
subject { InteractionPolicy.new(bitmap) }
describe '#as_keys' do
it 'returns the expected values' do
expect(subject.automatic.as_keys).to eq ['unsupported_policy', 'followers']
expect(subject.manual.as_keys).to eq ['public']
end
end
describe '#public?' do
it 'returns the expected values' do
expect(subject.automatic.public?).to be false
expect(subject.manual.public?).to be true
end
end
describe '#unsupported_policy?' do
it 'returns the expected values' do
expect(subject.automatic.unsupported_policy?).to be true
expect(subject.manual.unsupported_policy?).to be false
end
end
describe '#followers?' do
it 'returns the expected values' do
expect(subject.automatic.followers?).to be true
expect(subject.manual.followers?).to be false
end
end
end
end

View File

@ -29,7 +29,7 @@ RSpec.describe StatusCacheHydrator do
end end
context 'when handling a status with a quote policy' do context 'when handling a status with a quote policy' do
let(:status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } let(:status) { Fabricate(:status, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:followers] << 16) }
before do before do
account.follow!(status.account) account.follow!(status.account)

View File

@ -152,19 +152,19 @@ RSpec.describe StatusPolicy, type: :model do
end end
it 'grants access when public and policy allows everyone' do it 'grants access when public and policy allows everyone' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] status.quote_approval_policy = InteractionPolicy::POLICY_FLAGS[:public]
viewer = Fabricate(:account) viewer = Fabricate(:account)
expect(subject).to permit(viewer, status) expect(subject).to permit(viewer, status)
end end
it 'denies access when public and policy allows followers but viewer is not one' do it 'denies access when public and policy allows followers but viewer is not one' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] status.quote_approval_policy = InteractionPolicy::POLICY_FLAGS[:followers]
viewer = Fabricate(:account) viewer = Fabricate(:account)
expect(subject).to_not permit(viewer, status) expect(subject).to_not permit(viewer, status)
end end
it 'grants access when public and policy allows followers and viewer is one' do it 'grants access when public and policy allows followers and viewer is one' do
status.quote_approval_policy = Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] status.quote_approval_policy = InteractionPolicy::POLICY_FLAGS[:followers]
viewer = Fabricate(:account) viewer = Fabricate(:account)
viewer.follow!(status.account) viewer.follow!(status.account)
expect(subject).to permit(viewer, status) expect(subject).to permit(viewer, status)

View File

@ -46,7 +46,7 @@ RSpec.describe 'Interaction policies' do
context 'when changing the interaction policy' do context 'when changing the interaction policy' do
it 'changes the interaction policy, returns the updated status, and schedules distribution jobs' do it 'changes the interaction policy, returns the updated status, and schedules distribution jobs' do
expect { subject } expect { subject }
.to change { status.reload.quote_approval_policy }.to(Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) .to change { status.reload.quote_approval_policy }.to(InteractionPolicy::POLICY_FLAGS[:followers] << 16)
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)

View File

@ -264,7 +264,7 @@ RSpec.describe '/api/v1/statuses' do
end end
context 'with a quote to a non-mentioned user in a Private Mention' do context 'with a quote to a non-mentioned user in a Private Mention' do
let!(:quoted_status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } let!(:quoted_status) { Fabricate(:status, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16) }
let(:params) do let(:params) do
{ {
status: 'Hello, this is a quote', status: 'Hello, this is a quote',
@ -283,7 +283,7 @@ RSpec.describe '/api/v1/statuses' do
end end
context 'with a quote to a mentioned user in a Private Mention' do context 'with a quote to a mentioned user in a Private Mention' do
let!(:quoted_status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } let!(:quoted_status) { Fabricate(:status, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16) }
let(:params) do let(:params) do
{ {
status: "Hello @#{quoted_status.account.acct}, this is a quote", status: "Hello @#{quoted_status.account.acct}, this is a quote",
@ -305,7 +305,7 @@ RSpec.describe '/api/v1/statuses' do
end end
context 'with a quote of a reblog' do context 'with a quote of a reblog' do
let(:quoted_status) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } let(:quoted_status) { Fabricate(:status, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16) }
let(:reblog) { Fabricate(:status, reblog: quoted_status) } let(:reblog) { Fabricate(:status, reblog: quoted_status) }
let(:params) do let(:params) do
{ {
@ -501,7 +501,7 @@ RSpec.describe '/api/v1/statuses' do
it 'updates the status', :aggregate_failures do it 'updates the status', :aggregate_failures do
expect { subject } expect { subject }
.to change { status.reload.quote_approval_policy }.to(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) .to change { status.reload.quote_approval_policy }.to(InteractionPolicy::POLICY_FLAGS[:public] << 16)
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(response.content_type) expect(response.content_type)

View File

@ -74,7 +74,7 @@ RSpec.describe ActivityPub::NoteSerializer do
end end
context 'with a quote policy' do context 'with a quote policy' do
let(:parent) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } let(:parent) { Fabricate(:status, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:followers] << 16) }
it 'has the expected shape' do it 'has the expected shape' do
expect(subject).to include({ expect(subject).to include({

View File

@ -10,7 +10,7 @@ RSpec.describe REST::ScheduledStatusSerializer do
) )
end end
let(:scheduled_status) { Fabricate.build(:scheduled_status, scheduled_at: 4.minutes.from_now, params: { application_id: 123, quoted_status_id: 456, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16 }) } let(:scheduled_status) { Fabricate.build(:scheduled_status, scheduled_at: 4.minutes.from_now, params: { application_id: 123, quoted_status_id: 456, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16 }) }
describe 'serialization' do describe 'serialization' do
it 'returns expected values and removes application_id from params' do it 'returns expected values and removes application_id from params' do

View File

@ -330,7 +330,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
end end
it 'updates status' do it 'updates status' do
expect(existing_status.reload.quote_approval_policy).to eq(Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) expect(existing_status.reload.quote_approval_policy).to eq(InteractionPolicy::POLICY_FLAGS[:public] << 16)
end end
end end
end end

View File

@ -602,7 +602,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
context 'when an approved quote of a local post gets updated through an explicit update, removing text' do context 'when an approved quote of a local post gets updated through an explicit update, removing text' do
let(:quoted_account) { Fabricate(:account) } let(:quoted_account) { Fabricate(:account) }
let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) } let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) }
let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) }
@ -638,7 +638,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
context 'when an approved quote of a local post gets updated through an explicit update' do context 'when an approved quote of a local post gets updated through an explicit update' do
let(:quoted_account) { Fabricate(:account) } let(:quoted_account) { Fabricate(:account) }
let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16) } let(:quoted_status) { Fabricate(:status, account: quoted_account, quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:public] << 16) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) } let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted) }
let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) } let(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) }

View File

@ -161,9 +161,9 @@ RSpec.describe PostStatusService do
end end
it 'creates a status with the quote approval policy set' do it 'creates a status with the quote approval policy set' do
status = create_status_with_options(quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) status = create_status_with_options(quote_approval_policy: InteractionPolicy::POLICY_FLAGS[:followers] << 16)
expect(status.quote_approval_policy).to eq(Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) expect(status.quote_approval_policy).to eq(InteractionPolicy::POLICY_FLAGS[:followers] << 16)
end end
it 'processes mentions' do it 'processes mentions' do