This commit is contained in:
Matt Jankowski 2026-03-22 13:23:20 -04:00
parent b7a64120f4
commit f32649cb90
3 changed files with 7 additions and 763 deletions

View File

@ -99,6 +99,7 @@ class Account < ApplicationRecord
include Attachmentable # Load prior to Avatar & Header concerns
include Account::Associations
include Account::AttributionDomains
include Account::Avatar
include Account::Counters
include Account::FaspConcern
@ -113,9 +114,14 @@ class Account < ApplicationRecord
include Account::Silences
include Account::StatusesSearch
include Account::Suspensions
include Account::AttributionDomains
include Blocking
include DomainBlocking
include DomainMaterializable
include DomainNormalizable
include FollowerHashing
include Following
include Muting
include MutingConversations
include Paginable
include Reviewable

View File

@ -4,17 +4,6 @@ module Account::Interactions
extend ActiveSupport::Concern
included do
# Follow relations
has_many :follow_requests, dependent: :destroy
with_options class_name: 'Follow', dependent: :destroy do
has_many :active_relationships, foreign_key: 'account_id', inverse_of: :account
has_many :passive_relationships, foreign_key: 'target_account_id', inverse_of: :target_account
end
has_many :following, -> { order(follows: { id: :desc }) }, through: :active_relationships, source: :target_account
has_many :followers, -> { order(follows: { id: :desc }) }, through: :passive_relationships, source: :account
with_options class_name: 'SeveredRelationship', dependent: :destroy do
has_many :severed_relationships, foreign_key: 'local_account_id', inverse_of: :local_account
has_many :remote_severed_relationships, foreign_key: 'remote_account_id', inverse_of: :remote_account
@ -23,144 +12,9 @@ module Account::Interactions
# Hashtag follows
has_many :tag_follows, inverse_of: :account, dependent: :destroy
# Block relationships
with_options class_name: 'Block', dependent: :destroy do
has_many :block_relationships, foreign_key: 'account_id', inverse_of: :account
has_many :blocked_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
end
has_many :blocking, -> { order(blocks: { id: :desc }) }, through: :block_relationships, source: :target_account
has_many :blocked_by, -> { order(blocks: { id: :desc }) }, through: :blocked_by_relationships, source: :account
# Mute relationships
with_options class_name: 'Mute', dependent: :destroy do
has_many :mute_relationships, foreign_key: 'account_id', inverse_of: :account
has_many :muted_by_relationships, foreign_key: :target_account_id, inverse_of: :target_account
end
has_many :muting, -> { order(mutes: { id: :desc }) }, through: :mute_relationships, source: :target_account
has_many :muted_by, -> { order(mutes: { id: :desc }) }, through: :muted_by_relationships, source: :account
has_many :conversation_mutes, dependent: :destroy
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
has_many :announcement_mutes, dependent: :destroy
end
def follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
rel = active_relationships.create_with(show_reblogs: reblogs.nil? || reblogs, notify: notify.nil? ? false : notify, languages: languages, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
.find_or_create_by!(target_account: other_account)
rel.show_reblogs = reblogs unless reblogs.nil?
rel.notify = notify unless notify.nil?
rel.languages = languages unless languages.nil?
rel.save! if rel.changed?
rel
end
def request_follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
rel = follow_requests.create_with(show_reblogs: reblogs.nil? || reblogs, notify: notify.nil? ? false : notify, uri: uri, languages: languages, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
.find_or_create_by!(target_account: other_account)
rel.show_reblogs = reblogs unless reblogs.nil?
rel.notify = notify unless notify.nil?
rel.languages = languages unless languages.nil?
rel.save! if rel.changed?
rel
end
def block!(other_account, uri: nil)
block_relationships.create_with(uri: uri)
.find_or_create_by!(target_account: other_account)
end
def mute!(other_account, notifications: nil, duration: 0)
notifications = true if notifications.nil?
mute = mute_relationships.create_with(hide_notifications: notifications).find_or_initialize_by(target_account: other_account)
mute.expires_in = duration.zero? ? nil : duration
mute.save!
# When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
mute.update!(hide_notifications: notifications) if mute.hide_notifications? != notifications
mute
end
def mute_conversation!(conversation)
conversation_mutes.find_or_create_by!(conversation: conversation)
end
def block_domain!(other_domain)
domain_blocks.find_or_create_by!(domain: other_domain)
end
def unfollow!(other_account)
follow = active_relationships.find_by(target_account: other_account)
follow&.destroy
end
def unblock!(other_account)
block = block_relationships.find_by(target_account: other_account)
block&.destroy
end
def unmute!(other_account)
mute = mute_relationships.find_by(target_account: other_account)
mute&.destroy
end
def unmute_conversation!(conversation)
mute = conversation_mutes.find_by(conversation: conversation)
mute&.destroy!
end
def unblock_domain!(other_domain)
block = domain_blocks.find_by(domain: normalized_domain(other_domain))
block&.destroy
end
def following?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:following, other_id) do
active_relationships.exists?(target_account: other_account)
end
end
def following_anyone?
active_relationships.exists?
end
def not_following_anyone?
!following_anyone?
end
def followed_by?(other_account)
other_account.following?(self)
end
def blocking?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:blocking, other_id) do
block_relationships.exists?(target_account: other_account)
end
end
def blocked_by?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:blocked_by, other_id) do
other_account.block_relationships.exists?(target_account: self)
end
end
def domain_blocking?(other_domain)
preloaded_relation(:domain_blocking_by_domain, other_domain) do
domain_blocks.exists?(domain: other_domain)
end
end
def blocking_or_domain_blocking?(other_account)
return true if blocking?(other_account)
return false if other_account.domain.blank?
@ -168,18 +22,6 @@ module Account::Interactions
domain_blocking?(other_account.domain)
end
def muting?(other_account)
other_id = other_account.is_a?(Account) ? other_account.id : other_account
preloaded_relation(:muting, other_id) do
mute_relationships.exists?(target_account: other_account)
end
end
def muting_conversation?(conversation)
conversation_mutes.exists?(conversation: conversation)
end
def muting_notifications?(other_account)
mute_relationships.exists?(target_account: other_account, hide_notifications: true)
end
@ -188,10 +30,6 @@ module Account::Interactions
active_relationships.exists?(target_account: other_account, show_reblogs: false)
end
def requested?(other_account)
follow_requests.exists?(target_account: other_account)
end
def favourited?(status)
status.proper.favourites.exists?(account: self)
end
@ -213,46 +51,12 @@ module Account::Interactions
CustomFilter.apply_cached_filters(active_filters, status)
end
def followers_for_local_distribution
followers.local
.joins(:user)
.merge(User.signed_in_recently)
end
def lists_for_local_distribution
scope = lists.joins(account: :user)
scope.where.not(list_accounts: { follow_id: nil }).or(scope.where(account_id: id))
.merge(User.signed_in_recently)
end
def remote_followers_hash(url)
url_prefix = url[Account::URL_PREFIX_RE]
return if url_prefix.blank?
Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}/") do
digest = "\x00" * 32
followers.matches_uri_prefix(url_prefix).pluck_each(:uri) do |uri|
Xorcist.xor!(digest, Digest::SHA256.digest(uri))
end
digest.unpack1('H*')
end
end
def local_followers_hash
Rails.cache.fetch("followers_hash:#{id}:local") do
digest = "\x00" * 32
followers.where(domain: nil).pluck_each(:id_scheme, :id, :username) do |id_scheme, id, username|
uri = id_scheme == 'numeric_ap_id' ? ActivityPub::TagManager.instance.uri_for_account_id(id) : ActivityPub::TagManager.instance.uri_for_username(username)
Xorcist.xor!(digest, Digest::SHA256.digest(uri))
end
digest.unpack1('H*')
end
end
def normalized_domain(domain)
TagManager.instance.normalize_domain(domain)
end
private
def preloaded_relation(type, key)

View File

@ -6,450 +6,6 @@ RSpec.describe Account::Interactions do
let(:account) { Fabricate(:account) }
let(:target_account) { Fabricate(:account) }
describe '#follow!' do
it 'creates and returns Follow' do
expect do
expect(account.follow!(target_account)).to be_a Follow
end.to change { account.following.count }.by 1
end
end
describe '#block' do
it 'creates and returns Block' do
expect do
expect(account.block!(target_account)).to be_a Block
end.to change { account.block_relationships.count }.by 1
end
end
describe '#mute!' do
subject { account.mute!(target_account, notifications: arg_notifications) }
context 'when Mute does not exist yet' do
context 'when arg :notifications is nil' do
let(:arg_notifications) { nil }
it 'creates Mute, and returns Mute' do
expect do
expect(subject).to be_a Mute
end.to change { account.mute_relationships.count }.by 1
end
end
context 'when arg :notifications is false' do
let(:arg_notifications) { false }
it 'creates Mute, and returns Mute' do
expect do
expect(subject).to be_a Mute
end.to change { account.mute_relationships.count }.by 1
end
end
context 'when arg :notifications is true' do
let(:arg_notifications) { true }
it 'creates Mute, and returns Mute' do
expect do
expect(subject).to be_a Mute
end.to change { account.mute_relationships.count }.by 1
end
end
end
context 'when Mute already exists' do
before do
account.mute_relationships << mute
end
let(:mute) do
Fabricate(:mute,
account: account,
target_account: target_account,
hide_notifications: hide_notifications)
end
context 'when mute.hide_notifications is true' do
let(:hide_notifications) { true }
context 'when arg :notifications is nil' do
let(:arg_notifications) { nil }
it 'returns Mute without updating mute.hide_notifications' do
expect do
expect(subject).to be_a Mute
end.to_not change { mute.reload.hide_notifications? }.from(true)
end
end
context 'when arg :notifications is false' do
let(:arg_notifications) { false }
it 'returns Mute, and updates mute.hide_notifications false' do
expect do
expect(subject).to be_a Mute
end.to change { mute.reload.hide_notifications? }.from(true).to(false)
end
end
context 'when arg :notifications is true' do
let(:arg_notifications) { true }
it 'returns Mute without updating mute.hide_notifications' do
expect do
expect(subject).to be_a Mute
end.to_not change { mute.reload.hide_notifications? }.from(true)
end
end
end
context 'when mute.hide_notifications is false' do
let(:hide_notifications) { false }
context 'when arg :notifications is nil' do
let(:arg_notifications) { nil }
it 'returns Mute, and updates mute.hide_notifications true' do
expect do
expect(subject).to be_a Mute
end.to change { mute.reload.hide_notifications? }.from(false).to(true)
end
end
context 'when arg :notifications is false' do
let(:arg_notifications) { false }
it 'returns Mute without updating mute.hide_notifications' do
expect do
expect(subject).to be_a Mute
end.to_not change { mute.reload.hide_notifications? }.from(false)
end
end
context 'when arg :notifications is true' do
let(:arg_notifications) { true }
it 'returns Mute, and updates mute.hide_notifications true' do
expect do
expect(subject).to be_a Mute
end.to change { mute.reload.hide_notifications? }.from(false).to(true)
end
end
end
end
end
describe '#mute_conversation!' do
subject { account.mute_conversation!(conversation) }
let(:conversation) { Fabricate(:conversation) }
it 'creates and returns ConversationMute' do
expect do
expect(subject).to be_a ConversationMute
end.to change { account.conversation_mutes.count }.by 1
end
end
describe '#block_domain!' do
subject { account.block_domain!(domain) }
let(:domain) { 'example.com' }
it 'creates and returns AccountDomainBlock' do
expect do
expect(subject).to be_a AccountDomainBlock
end.to change { account.domain_blocks.count }.by 1
end
end
describe '#block_idna_domain!' do
subject do
[
account.block_domain!(idna_domain),
account.block_domain!(punycode_domain),
]
end
let(:idna_domain) { '대한민국.한국' }
let(:punycode_domain) { 'xn--3e0bs9hfvinn1a.xn--3e0b707e' }
it 'creates single AccountDomainBlock' do
expect do
expect(subject).to all(be_a AccountDomainBlock)
end.to change { account.domain_blocks.count }.by 1
end
end
describe '#unfollow!' do
subject { account.unfollow!(target_account) }
context 'when following target_account' do
it 'returns destroyed Follow' do
account.active_relationships.create(target_account: target_account)
expect(subject).to be_a Follow
expect(subject).to be_destroyed
end
end
context 'when not following target_account' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#unblock!' do
subject { account.unblock!(target_account) }
context 'when blocking target_account' do
it 'returns destroyed Block' do
account.block_relationships.create(target_account: target_account)
expect(subject).to be_a Block
expect(subject).to be_destroyed
end
end
context 'when not blocking target_account' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#unmute!' do
subject { account.unmute!(target_account) }
context 'when muting target_account' do
it 'returns destroyed Mute' do
account.mute_relationships.create(target_account: target_account)
expect(subject).to be_a Mute
expect(subject).to be_destroyed
end
end
context 'when not muting target_account' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#unmute_conversation!' do
subject { account.unmute_conversation!(conversation) }
let(:conversation) { Fabricate(:conversation) }
context 'when muting the conversation' do
it 'returns destroyed ConversationMute' do
account.conversation_mutes.create(conversation: conversation)
expect(subject).to be_a ConversationMute
expect(subject).to be_destroyed
end
end
context 'when not muting the conversation' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#unblock_domain!' do
subject { account.unblock_domain!(domain) }
let(:domain) { 'example.com' }
context 'when blocking the domain' do
it 'returns destroyed AccountDomainBlock' do
account_domain_block = Fabricate(:account_domain_block, domain: domain)
account.domain_blocks << account_domain_block
expect(subject).to be_a AccountDomainBlock
expect(subject).to be_destroyed
end
end
context 'when unblocking the domain' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#unblock_idna_domain!' do
subject { account.unblock_domain!(punycode_domain) }
let(:idna_domain) { '대한민국.한국' }
let(:punycode_domain) { 'xn--3e0bs9hfvinn1a.xn--3e0b707e' }
context 'when blocking the domain' do
it 'returns destroyed AccountDomainBlock' do
account_domain_block = Fabricate(:account_domain_block, domain: idna_domain)
account.domain_blocks << account_domain_block
expect(subject).to be_a AccountDomainBlock
expect(subject).to be_destroyed
end
end
context 'when unblocking idna domain' do
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '#following?' do
subject { account.following?(target_account) }
context 'when following target_account' do
before do
account.active_relationships.create(target_account: target_account)
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not following target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#followed_by?' do
subject { account.followed_by?(target_account) }
context 'when followed by target_account' do
it 'returns true' do
account.passive_relationships.create(account: target_account)
expect(subject).to be true
end
end
context 'when not followed by target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#blocking?' do
subject { account.blocking?(target_account) }
context 'when blocking target_account' do
before do
account.block_relationships.create(target_account: target_account)
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not blocking target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#blocked_by?' do
subject { account.blocked_by?(target_account) }
context 'when blocked by target_account' do
before do
target_account.block_relationships.create(target_account: account)
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not blocked by target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#domain_blocking?' do
subject { account.domain_blocking?(domain) }
let(:domain) { 'example.com' }
context 'when blocking the domain' do
before do
account_domain_block = Fabricate(:account_domain_block, domain: domain)
account.domain_blocks << account_domain_block
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([], [domain])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not blocking the domain' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#blocking_or_domain_blocking?' do
subject { account.blocking_or_domain_blocking?(target_account) }
@ -488,58 +44,6 @@ RSpec.describe Account::Interactions do
end
end
describe '#muting?' do
subject { account.muting?(target_account) }
context 'when muting target_account' do
before do
mute = Fabricate(:mute, account: account, target_account: target_account)
account.mute_relationships << mute
end
it 'returns true' do
result = nil
expect { result = subject }.to execute_queries
expect(result).to be true
end
context 'when relations are preloaded' do
it 'does not query the database to get the result' do
account.preload_relations!([target_account.id])
result = nil
expect { result = subject }.to_not execute_queries
expect(result).to be true
end
end
end
context 'when not muting target_account' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#muting_conversation?' do
subject { account.muting_conversation?(conversation) }
let(:conversation) { Fabricate(:conversation) }
context 'when muting the conversation' do
it 'returns true' do
account.conversation_mutes.create(conversation: conversation)
expect(subject).to be true
end
end
context 'when not muting the conversation' do
it 'returns false' do
expect(subject).to be false
end
end
end
describe '#muting_notifications?' do
subject { account.muting_notifications?(target_account) }
@ -645,76 +149,6 @@ RSpec.describe Account::Interactions do
end
end
describe '#remote_followers_hash' do
let(:me) { Fabricate(:account, username: 'Me') }
let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') }
let(:remote_bob) { Fabricate(:account, username: 'bob', domain: 'example.org', uri: 'https://example.org/users/bob') }
let(:remote_instance_actor) { Fabricate(:account, username: 'instance-actor', domain: 'example.org', uri: 'https://example.org') }
let(:remote_eve) { Fabricate(:account, username: 'eve', domain: 'foo.org', uri: 'https://foo.org/users/eve') }
before do
remote_alice.follow!(me)
remote_bob.follow!(me)
remote_instance_actor.follow!(me)
remote_eve.follow!(me)
me.follow!(remote_alice)
end
it 'returns correct hash for remote domains' do
expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7'
expect(me.remote_followers_hash('https://foo.org/')).to eq 'ccb9c18a67134cfff9d62c7f7e7eb88e6b803446c244b84265565f4eba29df0e'
expect(me.remote_followers_hash('https://foo.org.evil.com/')).to eq '0000000000000000000000000000000000000000000000000000000000000000'
expect(me.remote_followers_hash('https://foo')).to eq '0000000000000000000000000000000000000000000000000000000000000000'
end
it 'invalidates cache as needed when removing or adding followers' do
expect(me.remote_followers_hash('https://example.org/')).to eq '20aecbe774b3d61c25094370baf370012b9271c5b172ecedb05caff8d79ef0c7'
remote_instance_actor.unfollow!(me)
expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec'
remote_alice.unfollow!(me)
expect(me.remote_followers_hash('https://example.org/')).to eq '241b00794ce9b46aa864f3220afadef128318da2659782985bac5ed5bd436bff'
remote_alice.follow!(me)
expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec'
end
end
describe '#local_followers_hash' do
let(:me) { Fabricate(:account, username: 'Me') }
let(:remote_alice) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') }
before do
me.follow!(remote_alice)
end
it 'returns correct hash for local users' do
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
end
it 'invalidates cache as needed when removing or adding followers' do
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
me.unfollow!(remote_alice)
expect(remote_alice.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000'
me.follow!(remote_alice)
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
end
context 'when using numeric ID based scheme' do
let(:me) { Fabricate(:account, username: 'Me', id_scheme: :numeric_ap_id) }
it 'returns correct hash for local users' do
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
end
it 'invalidates cache as needed when removing or adding followers' do
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
me.unfollow!(remote_alice)
expect(remote_alice.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000'
me.follow!(remote_alice)
expect(remote_alice.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
end
end
end
describe 'muting an account' do
let(:me) { Fabricate(:account, username: 'Me') }
let(:you) { Fabricate(:account, username: 'You') }