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
18 changed files with 126 additions and 54 deletions

View File

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

View File

@@ -174,15 +174,15 @@ class ActivityPub::Parser::StatusParser
allowed_actors = as_array(subpolicy).dup
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 |= Status::QUOTE_APPROVAL_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[: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(@options[:followers_collection])
flags |= InteractionPolicy::POLICY_FLAGS[:following] if allowed_actors.delete(@options[:following_collection])
# Remove the special-meaning actor URI
allowed_actors.delete(@options[:actor_uri])
# 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
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
extend ActiveSupport::Concern
QUOTE_APPROVAL_POLICY_FLAGS = {
unsupported_policy: (1 << 0),
public: (1 << 1),
followers: (1 << 2),
following: (1 << 3),
}.freeze
included do
composed_of :quote_interaction_policy, class_name: 'InteractionPolicy', mapping: { quote_approval_policy: :bitmap }
before_validation :downgrade_quote_policy, if: -> { local? && !distributable? }
end
def quote_policy_as_keys(kind)
case kind
when :automatic
policy = quote_approval_policy >> 16
when :manual
policy = quote_approval_policy & 0xFFFF
end
raise ArgumentError unless kind.in?(%i(automatic manual))
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
# Returns `:automatic`, `:manual`, `:unknown` or `:denied`
@@ -35,35 +26,36 @@ module Status::InteractionPolicyConcern
# Post author is always allowed to quote themselves
return :automatic if account_id == other_account.id
automatic_policy = quote_approval_policy >> 16
manual_policy = quote_approval_policy & 0xFFFF
automatic_policy = quote_interaction_policy.automatic
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?
return :automatic if following_author
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?
return :automatic if followed_by_author
end
# 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?
return :manual if following_author
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?
return :manual if followed_by_author
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
end

View File

@@ -235,10 +235,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
approved_uris = []
# On outgoing posts, only automatic approval is supported
policy = object.quote_approval_policy >> 16
approved_uris << ActivityPub::TagManager::COLLECTIONS[:public] if policy.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[: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.following_uri_for(object.account) if policy.anybits?(Status::QUOTE_APPROVAL_POLICY_FLAGS[:following])
policy = object.quote_interaction_policy.automatic
approved_uris << ActivityPub::TagManager::COLLECTIONS[:public] if policy.public?
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.following?
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
object.params.merge(
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

View File

@@ -87,7 +87,7 @@ RSpec.describe ActivityPub::Activity::QuoteRequest do
context 'when trying to quote a quotable local status' 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' })
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
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') }
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
it 'accepts the quote and sends an Accept activity' do

View File

@@ -148,7 +148,7 @@ RSpec.describe ActivityPub::Parser::StatusParser do
end
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
@@ -174,7 +174,7 @@ RSpec.describe ActivityPub::Parser::StatusParser do
end
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

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
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
account.follow!(status.account)

View File

@@ -152,19 +152,19 @@ RSpec.describe StatusPolicy, type: :model do
end
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)
expect(subject).to permit(viewer, status)
end
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)
expect(subject).to_not permit(viewer, status)
end
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.follow!(status.account)
expect(subject).to permit(viewer, status)

View File

@@ -46,7 +46,7 @@ RSpec.describe 'Interaction policies' do
context 'when changing the interaction policy' do
it 'changes the interaction policy, returns the updated status, and schedules distribution jobs' do
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.content_type)

View File

@@ -264,7 +264,7 @@ RSpec.describe '/api/v1/statuses' do
end
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
{
status: 'Hello, this is a quote',
@@ -283,7 +283,7 @@ RSpec.describe '/api/v1/statuses' do
end
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
{
status: "Hello @#{quoted_status.account.acct}, this is a quote",
@@ -305,7 +305,7 @@ RSpec.describe '/api/v1/statuses' do
end
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(:params) do
{
@@ -501,7 +501,7 @@ RSpec.describe '/api/v1/statuses' do
it 'updates the status', :aggregate_failures do
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.content_type)

View File

@@ -74,7 +74,7 @@ RSpec.describe ActivityPub::NoteSerializer do
end
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
expect(subject).to include({

View File

@@ -10,7 +10,7 @@ RSpec.describe REST::ScheduledStatusSerializer do
)
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
it 'returns expected values and removes application_id from params' do

View File

@@ -330,7 +330,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do
end
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

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
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(: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
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(:approval_uri) { ActivityPub::TagManager.instance.approval_uri_for(quote) }

View File

@@ -161,9 +161,9 @@ RSpec.describe PostStatusService do
end
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
it 'processes mentions' do