diff --git a/app/models/account.rb b/app/models/account.rb index 22c87557d2..deb1589a09 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -91,6 +91,7 @@ class Account < ApplicationRecord include Account::FaspConcern include Account::FinderConcern include Account::Header + include Account::InteractionPolicyConcern include Account::Interactions include Account::Mappings include Account::Merging diff --git a/app/models/concerns/account/interaction_policy_concern.rb b/app/models/concerns/account/interaction_policy_concern.rb new file mode 100644 index 0000000000..8fe9eda1ba --- /dev/null +++ b/app/models/concerns/account/interaction_policy_concern.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Account::InteractionPolicyConcern + extend ActiveSupport::Concern + + included do + composed_of :feature_interaction_policy, class_name: 'InteractionPolicy', mapping: { feature_approval_policy: :bitmap } + end + + def feature_policy_as_keys(kind) + raise ArgumentError unless kind.in?(%i(automatic manual)) + return local_feature_policy(kind) if local? + + sub_policy = feature_interaction_policy.send(kind) + sub_policy.as_keys + end + + # Returns `:automatic`, `:manual`, `:unknown`, ':missing` or `:denied` + def feature_policy_for_account(other_account) + return :denied if other_account.nil? || (local? && !discoverable?) + return :automatic if local? + # Post author is always allowed to feature themselves + return :automatic if self == other_account + return :missing if feature_approval_policy.zero? + + automatic_policy = feature_interaction_policy.automatic + following_self = nil + followed_by_self = nil + + return :automatic if automatic_policy.public? + + if automatic_policy.followers? + following_self = followed_by?(other_account) + return :automatic if following_self + end + + if automatic_policy.following? + followed_by_self = following?(other_account) + return :automatic if followed_by_self + end + + # We don't know we are allowed by the automatic policy, considering the manual one + manual_policy = feature_interaction_policy.manual + + return :manual if manual_policy.public? + + if manual_policy.followers? + following_self = followed_by?(other_account) if following_self.nil? + return :manual if following_self + end + + if manual_policy.following? + followed_by_self = following?(other_account) if followed_by_self.nil? + return :manual if followed_by_self + end + + return :unknown if [automatic_policy, manual_policy].any?(&:unsupported_policy?) + + :denied + end + + private + + def local_feature_policy(kind) + return [] if kind == :manual || !discoverable? + + [:public] + end +end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index b102f79fdb..26715dd103 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -20,6 +20,8 @@ class REST::AccountSerializer < ActiveModel::Serializer attribute :memorial, if: :memorial? + attribute :feature_approval, if: -> { Mastodon::Feature.collections_enabled? } + class AccountDecorator < SimpleDelegator def self.model_name Account.model_name @@ -157,4 +159,12 @@ class REST::AccountSerializer < ActiveModel::Serializer def moved_and_not_nested? object.moved? end + + def feature_approval + { + automatic: object.feature_policy_as_keys(:automatic), + manual: object.feature_policy_as_keys(:manual), + current_user: object.feature_policy_for_account(current_user&.account), + } + end end diff --git a/spec/models/concerns/account/interaction_policy_concern_spec.rb b/spec/models/concerns/account/interaction_policy_concern_spec.rb new file mode 100644 index 0000000000..586a33b77f --- /dev/null +++ b/spec/models/concerns/account/interaction_policy_concern_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Account::InteractionPolicyConcern do + describe '#feature_policy_as_keys' do + context 'when account is local' do + context 'when account is discoverable' do + let(:account) { Fabricate(:account) } + + it 'returns public for automtatic and nothing for manual' do + expect(account.feature_policy_as_keys(:automatic)).to eq [:public] + expect(account.feature_policy_as_keys(:manual)).to eq [] + end + end + + context 'when account is not discoverable' do + let(:account) { Fabricate(:account, discoverable: false) } + + it 'returns empty arrays for both inputs' do + expect(account.feature_policy_as_keys(:automatic)).to eq [] + expect(account.feature_policy_as_keys(:manual)).to eq [] + end + end + end + + context 'when account is remote' do + let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy: (0b0101 << 16) | 0b0010) } + + it 'returns the expected values' do + expect(account.feature_policy_as_keys(:automatic)).to eq ['unsupported_policy', 'followers'] + expect(account.feature_policy_as_keys(:manual)).to eq ['public'] + end + end + end + + describe '#feature_policy_for_account' do + context 'when account is remote' do + let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy:) } + let(:feature_approval_policy) { (0b0101 << 16) | 0b0010 } + let(:other_account) { Fabricate(:account) } + + context 'when no policy is available' do + let(:feature_approval_policy) { 0 } + + context 'when both accounts are the same' do + it 'returns :automatic' do + expect(account.feature_policy_for_account(account)).to eq :automatic + end + end + + context 'with two different accounts' do + it 'returns :missing' do + expect(account.feature_policy_for_account(other_account)).to eq :missing + end + end + end + + context 'when the other account is not following the account' do + it 'returns :manual because of the public entry in the manual policy' do + expect(account.feature_policy_for_account(other_account)).to eq :manual + end + end + + context 'when the other account is following the account' do + before do + other_account.follow!(account) + end + + it 'returns :automatic because of the followers entry in the automatic policy' do + expect(account.feature_policy_for_account(other_account)).to eq :automatic + end + end + + context 'when the account falls into the unknown bucket' do + let(:feature_approval_policy) { (0b0001 << 16) | 0b0100 } + + it 'returns :automatic because of the followers entry in the automatic policy' do + expect(account.feature_policy_for_account(other_account)).to eq :unknown + end + end + end + end +end diff --git a/spec/serializers/rest/account_serializer_spec.rb b/spec/serializers/rest/account_serializer_spec.rb index 5fd4f8d706..998de6b0fb 100644 --- a/spec/serializers/rest/account_serializer_spec.rb +++ b/spec/serializers/rest/account_serializer_spec.rb @@ -3,12 +3,18 @@ require 'rails_helper' RSpec.describe REST::AccountSerializer do - subject { serialized_record_json(account, described_class) } + subject do + serialized_record_json(account, described_class, options: { + scope: current_user, + scope_name: :current_user, + }) + end let(:default_datetime) { DateTime.new(2024, 11, 28, 16, 20, 0) } let(:role) { Fabricate(:user_role, name: 'Role', highlighted: true) } let(:user) { Fabricate(:user, role: role) } let(:account) { user.account } + let(:current_user) { Fabricate(:user) } context 'when the account is suspended' do before do @@ -68,4 +74,51 @@ RSpec.describe REST::AccountSerializer do expect(subject['last_status_at']).to eq('2024-11-28') end end + + describe '#feature_approval' do + # TODO: Remove when feature flag is removed + context 'when collections feature is disabled' do + it 'does not include the approval policy' do + expect(subject).to_not have_key('feature_approval') + end + end + + context 'when collections feature is enabled', feature: :collections do + context 'when account is local' do + context 'when account is discoverable' do + it 'includes a policy that allows featuring' do + expect(subject['feature_approval']).to include({ + 'automatic' => ['public'], + 'manual' => [], + 'current_user' => 'automatic', + }) + end + end + + context 'when account is not discoverable' do + let(:account) { Fabricate(:account, discoverable: false) } + + it 'includes a policy that disallows featuring' do + expect(subject['feature_approval']).to include({ + 'automatic' => [], + 'manual' => [], + 'current_user' => 'denied', + }) + end + end + end + + context 'when account is remote' do + let(:account) { Fabricate(:account, domain: 'example.com', feature_approval_policy: 0b11000000000000000010) } + + it 'includes the matching policy' do + expect(subject['feature_approval']).to include({ + 'automatic' => ['followers', 'following'], + 'manual' => ['public'], + 'current_user' => 'manual', + }) + end + end + end + end end