Merge branch 'master' into feature-circles

This commit is contained in:
Takeshi Umeda 2021-01-05 23:46:40 +09:00 committed by GitHub
commit 824d1b8893
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
906 changed files with 37100 additions and 11600 deletions

View file

@ -27,7 +27,7 @@ class AccountSearchService < BaseService
return @exact_match if defined?(@exact_match)
@exact_match = begin
match = begin
if options[:resolve]
ResolveAccountService.new.call(query)
elsif domain_is_local?
@ -36,6 +36,10 @@ class AccountSearchService < BaseService
Account.find_remote(query_username, query_domain)
end
end
match = nil if !match.nil? && !account.nil? && options[:following] && !account.following?(match)
@exact_match = match
end
def search_results

View file

@ -28,7 +28,7 @@ class ActivityPub::FetchRemoteAccountService < BaseService
return unless only_key || verified_webfinger?
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key)
ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key)
rescue Oj::ParseError
nil
end
@ -39,17 +39,16 @@ class ActivityPub::FetchRemoteAccountService < BaseService
webfinger = webfinger!("acct:#{@username}@#{@domain}")
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
@username, @domain = split_acct(webfinger.subject)
self_reference = webfinger.link('self')
return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
return false if self_reference&.href != @uri
return false if webfinger.link('self', 'href') != @uri
true
rescue Goldfinger::Error
rescue Webfinger::Error
false
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ActivityPub::PrepareFollowersSynchronizationService < BaseService
include JsonLdHelper
def call(account, params)
@account = account
return if params['collectionId'] != @account.followers_url || invalid_origin?(params['url']) || @account.local_followers_hash == params['digest']
ActivityPub::FollowersSynchronizationWorker.perform_async(@account.id, params['url'])
end
end

View file

@ -18,15 +18,18 @@ class ActivityPub::ProcessAccountService < BaseService
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
@account ||= Account.find_remote(@username, @domain)
@old_public_key = @account&.public_key
@old_protocol = @account&.protocol
@account = Account.remote.find_by(uri: @uri) if @options[:only_key]
@account ||= Account.find_remote(@username, @domain)
@old_public_key = @account&.public_key
@old_protocol = @account&.protocol
@suspension_changed = false
create_account if @account.nil?
update_account
process_tags
process_attachments
process_duplicate_accounts! if @options[:verified_webfinger]
else
raise Mastodon::RaceConditionError
end
@ -37,8 +40,9 @@ class ActivityPub::ProcessAccountService < BaseService
after_protocol_change! if protocol_changed?
after_key_change! if key_changed? && !@options[:signed_with_known_key]
clear_tombstones! if key_changed?
after_suspension_change! if suspension_changed?
unless @options[:only_key]
unless @options[:only_key] || @account.suspended?
check_featured_collection! if @account.featured_collection_url.present?
check_links! unless @account.fields.empty?
end
@ -52,46 +56,57 @@ class ActivityPub::ProcessAccountService < BaseService
def create_account
@account = Account.new
@account.protocol = :activitypub
@account.username = @username
@account.domain = @domain
@account.private_key = nil
@account.suspended_at = domain_block.created_at if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence?
@account.protocol = :activitypub
@account.username = @username
@account.domain = @domain
@account.private_key = nil
@account.suspended_at = domain_block.created_at if auto_suspend?
@account.suspension_origin = :local if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence?
@account.save
end
def update_account
@account.last_webfingered_at = Time.now.utc unless @options[:only_key]
@account.protocol = :activitypub
set_immediate_attributes!
set_fetchable_attributes! unless @options[:only_keys]
set_suspension!
set_immediate_protocol_attributes!
set_fetchable_key! unless @account.suspended? && @account.suspension_origin_local?
set_immediate_attributes! unless @account.suspended?
set_fetchable_attributes! unless @options[:only_key] || @account.suspended?
@account.save_with_optional_media!
end
def set_immediate_attributes!
def set_immediate_protocol_attributes!
@account.inbox_url = @json['inbox'] || ''
@account.outbox_url = @json['outbox'] || ''
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
@account.followers_url = @json['followers'] || ''
@account.featured_collection_url = @json['featured'] || ''
@account.devices_url = @json['devices'] || ''
@account.url = url || @uri
@account.uri = @uri
@account.actor_type = actor_type
end
def set_immediate_attributes!
@account.featured_collection_url = @json['featured'] || ''
@account.devices_url = @json['devices'] || ''
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@account.locked = @json['manuallyApprovesFollowers'] || false
@account.fields = property_values || {}
@account.also_known_as = as_array(@json['alsoKnownAs'] || []).map { |item| value_or_id(item) }
@account.actor_type = actor_type
@account.discoverable = @json['discoverable'] || false
end
def set_fetchable_key!
@account.public_key = public_key || ''
end
def set_fetchable_attributes!
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
@account.header_remote_url = image_url('image') || '' unless skip_download?
@account.public_key = public_key || ''
@account.statuses_count = outbox_total_items if outbox_total_items.present?
@account.following_count = following_total_items if following_total_items.present?
@account.followers_count = followers_total_items if followers_total_items.present?
@ -99,6 +114,18 @@ class ActivityPub::ProcessAccountService < BaseService
@account.moved_to_account = @json['movedTo'].present? ? moved_account : nil
end
def set_suspension!
return if @account.suspended? && @account.suspension_origin_local?
if @account.suspended? && !@json['suspended']
@account.unsuspend!
@suspension_changed = true
elsif !@account.suspended? && @json['suspended']
@account.suspend!(origin: :remote)
@suspension_changed = true
end
end
def after_protocol_change!
ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
end
@ -107,6 +134,14 @@ class ActivityPub::ProcessAccountService < BaseService
RefollowWorker.perform_async(@account.id)
end
def after_suspension_change!
if @account.suspended?
Admin::SuspensionWorker.perform_async(@account.id)
else
Admin::UnsuspensionWorker.perform_async(@account.id)
end
end
def check_featured_collection!
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id)
end
@ -115,6 +150,12 @@ class ActivityPub::ProcessAccountService < BaseService
VerifyAccountLinksWorker.perform_async(@account.id)
end
def process_duplicate_accounts!
return unless Account.where(uri: @account.uri).where.not(id: @account.id).exists?
AccountMergingWorker.perform_async(@account.id)
end
def actor_type
if @json['type'].is_a?(Array)
@json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) }
@ -196,7 +237,7 @@ class ActivityPub::ProcessAccountService < BaseService
total_items = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
has_first_page = collection.is_a?(Hash) && collection['first'].present?
@collections[type] = [total_items, has_first_page]
rescue HTTP::Error, OpenSSL::SSL::SSLError
rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::LengthValidationError
@collections[type] = [nil, nil]
end
@ -227,6 +268,10 @@ class ActivityPub::ProcessAccountService < BaseService
!@old_public_key.nil? && @old_public_key != @account.public_key
end
def suspension_changed?
@suspension_changed
end
def clear_tombstones!
Tombstone.where(account_id: @account.id).delete_all
end

View file

@ -8,7 +8,7 @@ class ActivityPub::ProcessCollectionService < BaseService
@json = Oj.load(body, mode: :strict)
@options = options
return if !supported_context? || (different_actor? && verify_account!.nil?) || @account.suspended? || @account.local?
return if !supported_context? || (different_actor? && verify_account!.nil?) || suspended_actor? || @account.local?
case @json['type']
when 'Collection', 'CollectionPage'
@ -28,6 +28,14 @@ class ActivityPub::ProcessCollectionService < BaseService
@json['actor'].present? && value_or_id(@json['actor']) != @account.uri
end
def suspended_actor?
@account.suspended? && !activity_allowed_while_suspended?
end
def activity_allowed_while_suspended?
%w(Delete Reject Undo Update).include?(@json['type'])
end
def process_items(items)
items.reverse_each.map { |item| process_item(item) }.compact
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
class ActivityPub::SynchronizeFollowersService < BaseService
include JsonLdHelper
include Payloadable
def call(account, partial_collection_url)
@account = account
items = collection_items(partial_collection_url)
return if items.nil?
# There could be unresolved accounts (hence the call to .compact) but this
# should never happen in practice, since in almost all cases we keep an
# Account record, and should we not do that, we should have sent a Delete.
# In any case there is not much we can do if that occurs.
@expected_followers = items.map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }.compact
remove_unexpected_local_followers!
handle_unexpected_outgoing_follows!
end
private
def remove_unexpected_local_followers!
@account.followers.local.where.not(id: @expected_followers.map(&:id)).each do |unexpected_follower|
UnfollowService.new.call(unexpected_follower, @account)
end
end
def handle_unexpected_outgoing_follows!
@expected_followers.each do |expected_follower|
next if expected_follower.following?(@account)
if expected_follower.requested?(@account)
# For some reason the follow request went through but we missed it
expected_follower.follow_requests.find_by(target_account: @account)&.authorize!
else
# Since we were not aware of the follow from our side, we do not have an
# ID for it that we can include in the Undo activity. For this reason,
# the Undo may not work with software that relies exclusively on
# matching activity IDs and not the actor and target
follow = Follow.new(account: expected_follower, target_account: @account)
ActivityPub::DeliveryWorker.perform_async(build_undo_follow_json(follow), follow.account_id, follow.target_account.inbox_url)
end
end
end
def build_undo_follow_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer))
end
def collection_items(collection_or_uri)
collection = fetch_collection(collection_or_uri)
return unless collection.is_a?(Hash)
collection = fetch_collection(collection['first']) if collection['first'].present?
return unless collection.is_a?(Hash)
case collection['type']
when 'Collection', 'CollectionPage'
collection['items']
when 'OrderedCollection', 'OrderedCollectionPage'
collection['orderedItems']
end
end
def fetch_collection(collection_or_uri)
return collection_or_uri if collection_or_uri.is_a?(Hash)
return if invalid_origin?(collection_or_uri)
fetch_resource_without_id_validation(collection_or_uri, nil, true)
end
end

View file

@ -13,7 +13,7 @@ class AfterBlockService < BaseService
private
def clear_home_feed!
FeedManager.instance.clear_from_timeline(@account, @target_account)
FeedManager.instance.clear_from_home(@account, @target_account)
end
def clear_conversations!

View file

@ -3,7 +3,7 @@
class AfterUnallowDomainService < BaseService
def call(domain)
Account.where(domain: domain).find_each do |account|
SuspendAccountService.new.call(account, reserve_username: false)
DeleteAccountService.new.call(account, reserve_username: false)
end
end
end

View file

@ -1,13 +1,13 @@
# frozen_string_literal: true
class AppSignUpService < BaseService
def call(app, params)
def call(app, remote_ip, params)
return unless allowed_registrations?
user_params = params.slice(:email, :password, :agreement, :locale)
account_params = params.slice(:username)
invite_request_params = { text: params[:reason] }
user = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
Doorkeeper::AccessToken.create!(application: app,
resource_owner_id: user.id,

View file

@ -3,32 +3,40 @@
class BatchedRemoveStatusService < BaseService
include Redisable
# Delete given statuses and reblogs of them
# Dispatch PuSH updates of the deleted statuses, but only local ones
# Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
# Remove statuses from home feeds
# Push delete events to streaming API for home feeds and public feeds
# @param [Enumerable<Status>] statuses A preferably batched array of statuses
# Delete multiple statuses and reblogs of them as efficiently as possible
# @param [Enumerable<Status>] statuses An array of statuses
# @param [Hash] options
# @option [Boolean] :skip_side_effects
# @option [Boolean] :skip_side_effects Do not modify feeds and send updates to streaming API
def call(statuses, **options)
statuses = Status.where(id: statuses.map(&:id)).includes(:account).flat_map { |status| [status] + status.reblogs.includes(:account).to_a }
ActiveRecord::Associations::Preloader.new.preload(statuses, options[:skip_side_effects] ? :reblogs : [:account, :tags, reblogs: :account])
@mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a }
@tags = statuses.each_with_object({}) { |s, h| h[s.id] = s.tags.pluck(:name) }
statuses_and_reblogs = statuses.flat_map { |status| [status] + status.reblogs }
@json_payloads = statuses.each_with_object({}) { |s, h| h[s.id] = Oj.dump(event: :delete, payload: s.id.to_s) }
# The conversations for direct visibility statuses also need
# to be manually updated. This part is not efficient but we
# rely on direct visibility statuses being relatively rare.
statuses_with_account_conversations = statuses.select(&:direct_visibility?)
# Ensure that rendered XML reflects destroyed state
statuses.each do |status|
status.mark_for_mass_destruction!
status.destroy
ActiveRecord::Associations::Preloader.new.preload(statuses_with_account_conversations, [mentions: :account])
statuses_with_account_conversations.each do |status|
status.send(:unlink_from_conversations)
end
# We do not batch all deletes into one to avoid having a long-running
# transaction lock the database, but we use the delete method instead
# of destroy to avoid all callbacks. We rely on foreign keys to
# cascade the delete faster without loading the associations.
statuses_and_reblogs.each_slice(50) { |slice| Status.where(id: slice.map(&:id)).delete_all }
# Since we skipped all callbacks, we also need to manually
# deindex the statuses
Chewy.strategy.current.update(StatusesIndex::Status, statuses_and_reblogs) if Chewy.enabled?
return if options[:skip_side_effects]
# Batch by source account
statuses.group_by(&:account_id).each_value do |account_statuses|
statuses_and_reblogs.group_by(&:account_id).each_value do |account_statuses|
account = account_statuses.first.account
next unless account
@ -38,19 +46,18 @@ class BatchedRemoveStatusService < BaseService
end
# Cannot be batched
statuses.each do |status|
unpush_from_public_timelines(status)
@status_id_cutoff = Mastodon::Snowflake.id_at(2.weeks.ago)
redis.pipelined do
statuses.each do |status|
unpush_from_public_timelines(status)
end
end
end
private
def unpush_from_home_timelines(account, statuses)
recipients = account.followers_for_local_distribution.to_a
recipients << account if account.local?
recipients.each do |follower|
account.followers_for_local_distribution.includes(:user).reorder(nil).find_each do |follower|
statuses.each do |status|
FeedManager.instance.unpush_from_home(follower, status)
end
@ -58,7 +65,7 @@ class BatchedRemoveStatusService < BaseService
end
def unpush_from_list_timelines(account, statuses)
account.lists_for_local_distribution.select(:id, :account_id).each do |list|
account.lists_for_local_distribution.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |list|
statuses.each do |status|
FeedManager.instance.unpush_from_list(list, status)
end
@ -66,30 +73,21 @@ class BatchedRemoveStatusService < BaseService
end
def unpush_from_public_timelines(status)
return unless status.public_visibility?
return unless status.public_visibility? && status.id > @status_id_cutoff
payload = @json_payloads[status.id]
payload = Oj.dump(event: :delete, payload: status.id.to_s)
redis.pipelined do
redis.publish('timeline:public', payload)
if status.local?
redis.publish('timeline:public:local', payload)
else
redis.publish('timeline:public:remote', payload)
end
if status.media_attachments.any?
redis.publish('timeline:public:media', payload)
if status.local?
redis.publish('timeline:public:local:media', payload)
else
redis.publish('timeline:public:remote:media', payload)
end
end
redis.publish('timeline:public', payload)
redis.publish(status.local? ? 'timeline:public:local' : 'timeline:public:remote', payload)
@tags[status.id].each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", payload)
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", payload) if status.local?
end
if status.media_attachments.any?
redis.publish('timeline:public:media', payload)
redis.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
end
status.tags.map { |tag| tag.name.mb_chars.downcase }.each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag}", payload)
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
end
end
end

View file

@ -16,7 +16,7 @@ class BlockDomainService < BaseService
scope = Account.by_domain_and_subdomains(domain_block.domain)
scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.silence?
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) unless domain_block.suspend?
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil, suspension_origin: nil) unless domain_block.suspend?
end
def process_domain_block!
@ -34,9 +34,10 @@ class BlockDomainService < BaseService
end
def suspend_accounts!
blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at, suspension_origin: :local)
blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
end
end

View file

@ -0,0 +1,307 @@
# frozen_string_literal: true
class DeleteAccountService < BaseService
include Payloadable
ASSOCIATIONS_ON_SUSPEND = %w(
account_pins
active_relationships
aliases
block_relationships
blocked_by_relationships
conversation_mutes
conversations
custom_filters
devices
domain_blocks
featured_tags
follow_requests
identity_proofs
list_accounts
migrations
mute_relationships
muted_by_relationships
notifications
owned_lists
passive_relationships
report_notes
scheduled_statuses
status_pins
).freeze
# The following associations have no important side-effects
# in callbacks and all of their own associations are secured
# by foreign keys, making them safe to delete without loading
# into memory
ASSOCIATIONS_WITHOUT_SIDE_EFFECTS = %w(
account_pins
aliases
conversation_mutes
conversations
custom_filters
devices
domain_blocks
featured_tags
follow_requests
identity_proofs
list_accounts
migrations
mute_relationships
muted_by_relationships
notifications
owned_lists
scheduled_statuses
status_pins
)
ASSOCIATIONS_ON_DESTROY = %w(
reports
targeted_moderation_notes
targeted_reports
).freeze
# Suspend or remove an account and remove as much of its data
# as possible. If it's a local account and it has not been confirmed
# or never been approved, then side effects are skipped and both
# the user and account records are removed fully. Otherwise,
# it is controlled by options.
# @param [Account]
# @param [Hash] options
# @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
# @option [Boolean] :reserve_username Keep account record
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
# @option [Boolean] :skip_activitypub Skip sending ActivityPub payloads. Implied by :skip_side_effects
# @option [Time] :suspended_at Only applicable when :reserve_username is true
def call(account, **options)
@account = account
@options = { reserve_username: true, reserve_email: true }.merge(options)
if @account.local? && @account.user_unconfirmed_or_pending?
@options[:reserve_email] = false
@options[:reserve_username] = false
@options[:skip_side_effects] = true
end
@options[:skip_activitypub] = true if @options[:skip_side_effects]
distribute_activities!
purge_content!
fulfill_deletion_request!
end
private
def distribute_activities!
return if skip_activitypub?
if @account.local?
delete_actor!
elsif @account.activitypub?
reject_follows!
undo_follows!
end
end
def reject_follows!
# When deleting a remote account, the account obviously doesn't
# actually become deleted on its origin server, i.e. unlike a
# locally deleted account it continues to have access to its home
# feed and other content. To prevent it from being able to continue
# to access toots it would receive because it follows local accounts,
# we have to force it to unfollow them.
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url]
end
end
def undo_follows!
# When deleting a remote account, the account obviously doesn't
# actually become deleted on its origin server, but following relationships
# are severed on our end. Therefore, make the remote server aware that the
# follow relationships are severed to avoid confusion and potential issues
# if the remote account gets un-suspended.
ActivityPub::DeliveryWorker.push_bulk(Follow.where(target_account: @account)) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer)), follow.account_id, @account.inbox_url]
end
end
def purge_user!
return if !@account.local? || @account.user.nil?
if keep_user_record?
@account.user.disable!
@account.user.invites.where(uses: 0).destroy_all
else
@account.user.destroy
end
end
def purge_content!
purge_user!
purge_profile!
purge_statuses!
purge_mentions!
purge_media_attachments!
purge_polls!
purge_generated_notifications!
purge_favourites!
purge_bookmarks!
purge_feeds!
purge_other_associations!
@account.destroy unless keep_account_record?
end
def purge_statuses!
@account.statuses.reorder(nil).where.not(id: reported_status_ids).in_batches do |statuses|
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: skip_side_effects?)
end
end
def purge_mentions!
@account.mentions.reorder(nil).where.not(status_id: reported_status_ids).in_batches.delete_all
end
def purge_media_attachments!
@account.media_attachments.reorder(nil).find_each do |media_attachment|
next if keep_account_record? && reported_status_ids.include?(media_attachment.status_id)
media_attachment.destroy
end
end
def purge_polls!
@account.polls.reorder(nil).where.not(status_id: reported_status_ids).in_batches.delete_all
end
def purge_generated_notifications!
# By deleting polls and statuses without callbacks, we've left behind
# polymorphically associated notifications generated by this account
Notification.where(from_account: @account).in_batches.delete_all
end
def purge_favourites!
@account.favourites.in_batches do |favourites|
ids = favourites.pluck(:status_id)
StatusStat.where(status_id: ids).update_all('favourites_count = GREATEST(0, favourites_count - 1)')
Chewy.strategy.current.update(StatusesIndex::Status, ids) if Chewy.enabled?
# Rails.cache.delete_multi would be better, but we don't have it yet
ids.each { |id| Rails.cache.delete("statuses/#{id}") }
favourites.delete_all
end
end
def purge_bookmarks!
@account.bookmarks.in_batches do |bookmarks|
Chewy.strategy.current.update(StatusesIndex::Status, bookmarks.pluck(:status_id)) if Chewy.enabled?
bookmarks.delete_all
end
end
def purge_other_associations!
associations_for_destruction.each do |association_name|
purge_association(association_name)
end
end
def purge_feeds!
return unless @account.local?
FeedManager.instance.clean_feeds!(:home, [@account.id])
FeedManager.instance.clean_feeds!(:list, @account.owned_lists.pluck(:id))
end
def purge_profile!
# If the account is going to be destroyed
# there is no point wasting time updating
# its values first
return unless keep_account_record?
@account.silenced_at = nil
@account.suspended_at = @options[:suspended_at] || Time.now.utc
@account.suspension_origin = :local
@account.locked = false
@account.memorial = false
@account.discoverable = false
@account.display_name = ''
@account.note = ''
@account.fields = []
@account.statuses_count = 0
@account.followers_count = 0
@account.following_count = 0
@account.moved_to_account = nil
@account.also_known_as = []
@account.trust_level = :untrusted
@account.avatar.destroy
@account.header.destroy
@account.save!
end
def fulfill_deletion_request!
@account.deletion_request&.destroy
end
def purge_association(association_name)
association = @account.public_send(association_name)
if ASSOCIATIONS_WITHOUT_SIDE_EFFECTS.include?(association_name)
association.in_batches.delete_all
else
association.in_batches.destroy_all
end
end
def delete_actor!
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
end
def delete_actor_json
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
end
def delivery_inboxes
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
end
def low_priority_delivery_inboxes
Account.inboxes - delivery_inboxes
end
def reported_status_ids
@reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
end
def associations_for_destruction
if keep_account_record?
ASSOCIATIONS_ON_SUSPEND
else
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
end
end
def keep_user_record?
@options[:reserve_email]
end
def keep_account_record?
@options[:reserve_username]
end
def skip_side_effects?
@options[:skip_side_effects]
end
def skip_activitypub?
@options[:skip_activitypub]
end
end

View file

@ -6,20 +6,22 @@ class FanOutOnWriteService < BaseService
def call(status)
raise Mastodon::RaceConditionError if status.visibility.nil?
render_anonymous_payload(status)
deliver_to_self(status) if status.account.local?
if status.direct_visibility?
deliver_to_mentioned_followers(status)
deliver_to_own_conversation(status)
elsif status.limited_visibility?
deliver_to_mentioned_followers(status)
else
deliver_to_self(status) if status.account.local?
deliver_to_followers(status)
deliver_to_lists(status)
end
return if status.account.silenced? || !status.public_visibility? || status.reblog?
render_anonymous_payload(status)
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id
@ -58,8 +60,10 @@ class FanOutOnWriteService < BaseService
def deliver_to_mentioned_followers(status)
Rails.logger.debug "Delivering status #{status.id} to limited followers"
FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? && mentioned_account.following?(status.account) }) do |follower|
[status.id, follower.id, :home]
status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions|
FeedInsertWorker.push_bulk(mentions) do |mention|
[status.id, mention.account_id, :home]
end
end
end

View file

@ -29,7 +29,7 @@ class FavouriteService < BaseService
status = favourite.status
if status.account.local?
NotifyService.new.call(status.account, favourite)
NotifyService.new.call(status.account, :favourite, favourite)
elsif status.account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
end

View file

@ -78,6 +78,7 @@ class FetchLinkCardService < BaseService
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
end
# rubocop:disable Naming/MethodParameterName
def mention_link?(a)
@status.mentions.any? do |mention|
a['href'] == ActivityPub::TagManager.instance.url_for(mention.account)
@ -88,6 +89,7 @@ class FetchLinkCardService < BaseService
# Avoid links for hashtags and mentions (microformats)
a['rel']&.include?('tag') || a['class']&.match?(/u-url|h-card/) || mention_link?(a)
end
# rubocop:enable Naming/MethodParameterName
def attempt_oembed
service = FetchOEmbedService.new

View file

@ -9,12 +9,14 @@ class FollowService < BaseService
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
# @param [Hash] options
# @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
# @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
# @option [Boolean] :bypass_locked
# @option [Boolean] :bypass_limit Allow following past the total follow number
# @option [Boolean] :with_rate_limit
def call(source_account, target_account, options = {})
@source_account = source_account
@target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
@options = { reblogs: true, bypass_locked: false, with_rate_limit: false }.merge(options)
@options = { bypass_locked: false, bypass_limit: false, with_rate_limit: false }.merge(options)
raise ActiveRecord::RecordNotFound if following_not_possible?
raise Mastodon::NotPermittedError if following_not_allowed?
@ -45,18 +47,18 @@ class FollowService < BaseService
end
def change_follow_options!
@source_account.follow!(@target_account, reblogs: @options[:reblogs])
@source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
end
def change_follow_request_options!
@source_account.request_follow!(@target_account, reblogs: @options[:reblogs])
@source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
end
def request_follow!
follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
if @target_account.local?
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name)
LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, :follow_request)
elsif @target_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
end
@ -65,9 +67,9 @@ class FollowService < BaseService
end
def direct_follow!
follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit])
LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name)
LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow)
MergeWorker.perform_async(@target_account.id, @source_account.id)
follow

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
class HashtagQueryService < BaseService
LIMIT_PER_MODE = 4
def call(tag, params, account = nil, local = false)
tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id)
all = tags_for(params[:all])
none = tags_for(params[:none])
Status.distinct
.as_tag_timeline(tags, account, local)
.tagged_with_all(all)
.tagged_with_none(none)
end
private
def tags_for(names)
Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
end
end

View file

@ -18,6 +18,8 @@ class ImportService < BaseService
import_mutes!
when 'domain_blocking'
import_domain_blocks!
when 'bookmarks'
import_bookmarks!
end
end
@ -25,7 +27,7 @@ class ImportService < BaseService
def import_follows!
parse_import_data!(['Account address'])
import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: 'Show boosts')
import_relationships!('follow', 'unfollow', @account.following, ROWS_PROCESSING_LIMIT, reblogs: { header: 'Show boosts', default: true })
end
def import_blocks!
@ -35,7 +37,7 @@ class ImportService < BaseService
def import_mutes!
parse_import_data!(['Account address'])
import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: 'Hide notifications')
import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: { header: 'Hide notifications', default: true })
end
def import_domain_blocks!
@ -65,7 +67,7 @@ class ImportService < BaseService
def import_relationships!(action, undo_action, overwrite_scope, limit, extra_fields = {})
local_domain_suffix = "@#{Rails.configuration.x.local_domain}"
items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, header| [key, row[header]&.strip] }]] }.reject { |(id, _)| id.blank? }
items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, field_settings| [key, row[field_settings[:header]]&.strip || field_settings[:default]] }]] }.reject { |(id, _)| id.blank? }
if @import.overwrite?
presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
@ -83,11 +85,45 @@ class ImportService < BaseService
head_items = items.uniq { |acct, _| acct.split('@')[1] }
tail_items = items - head_items
Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra|
[@account.id, acct, action, extra]
end
end
def import_bookmarks!
parse_import_data!(['#uri'])
items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#uri'].strip }
if @import.overwrite?
presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
@account.bookmarks.find_each do |bookmark|
if presence_hash[bookmark.status.uri]
items.delete(bookmark.status.uri)
else
bookmark.destroy!
end
end
end
statuses = items.map do |uri|
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
next if status.nil? && ActivityPub::TagManager.instance.local_uri?(uri)
status || ActivityPub::FetchRemoteStatusService.new.call(uri)
end.compact
account_ids = statuses.map(&:account_id)
preloaded_relations = relations_map_for_account(@account, account_ids)
statuses.keep_if { |status| StatusPolicy.new(@account, status, preloaded_relations).show? }
statuses.each do |status|
@account.bookmarks.find_or_create_by!(account: @account, status: status)
end
end
def parse_import_data!(default_headers)
data = CSV.parse(import_data, headers: true)
data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ')
@ -98,7 +134,13 @@ class ImportService < BaseService
Paperclip.io_adapters.for(@import.data).read
end
def follow_limit
FollowLimitValidator.limit_for_account(@account)
def relations_map_for_account(account, account_ids)
{
blocking: {},
blocked_by: Account.blocked_by_map(account_ids, account.id),
muting: {},
following: Account.following_map(account_ids, account.id),
domain_blocking_by_domain: {},
}
end
end

View file

@ -1,10 +1,10 @@
# frozen_string_literal: true
class MuteService < BaseService
def call(account, target_account, notifications: nil)
def call(account, target_account, notifications: nil, duration: 0)
return if account.id == target_account.id
mute = account.mute!(target_account, notifications: notifications)
mute = account.mute!(target_account, notifications: notifications, duration: duration)
if mute.hide_notifications?
BlockWorker.perform_async(account.id, target_account.id)
@ -12,6 +12,8 @@ class MuteService < BaseService
MuteWorker.perform_async(account.id, target_account.id)
end
DeleteMuteWorker.perform_at(duration.seconds, mute.id) if duration != 0
mute
end
end

View file

@ -1,10 +1,10 @@
# frozen_string_literal: true
class NotifyService < BaseService
def call(recipient, activity)
def call(recipient, type, activity)
@recipient = recipient
@activity = activity
@notification = Notification.new(account: @recipient, activity: @activity)
@notification = Notification.new(account: @recipient, type: type, activity: @activity)
return if recipient.user.nil? || blocked?
@ -13,13 +13,17 @@ class NotifyService < BaseService
push_to_conversation! if direct_message?
send_email! if email_enabled?
rescue ActiveRecord::RecordInvalid
return
nil
end
private
def blocked_mention?
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
end
def blocked_status?
false
end
def blocked_favourite?

View file

@ -2,7 +2,7 @@
class PrecomputeFeedService < BaseService
def call(account)
FeedManager.instance.populate_feed(account)
FeedManager.instance.populate_home(account)
ensure
Redis.current.del("account:#{account.id}:regeneration")
end

View file

@ -30,14 +30,15 @@ class ProcessMentionsService < BaseService
if mention_undeliverable?(mentioned_account)
begin
mentioned_account = resolve_account_service.call(Regexp.last_match(1))
rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
mentioned_account = nil
end
end
next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
mention = mentioned_account.mentions.new(status: status)
mentions << mention if mention.save
"@#{mentioned_account.acct}"
end
@ -64,9 +65,9 @@ class ProcessMentionsService < BaseService
mentioned_account = mention.account
if mentioned_account.local?
LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
elsif mentioned_account.activitypub?
ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? })
end
end

View file

@ -45,7 +45,7 @@ class ReblogService < BaseService
reblogged_status = reblog.reblog
if reblogged_status.account.local?
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name)
LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, :reblog)
elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
end

View file

@ -9,44 +9,47 @@ class RemoveStatusService < BaseService
# @param [Hash] options
# @option [Boolean] :redraft
# @option [Boolean] :immediate
# @option [Boolean] :original_removed
# @option [Boolean] :original_removed
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status
@account = status.account
@tags = status.tags.pluck(:name).to_a
@mentions = status.active_mentions.includes(:account).to_a
@reblogs = status.reblogs.includes(:account).to_a
@options = options
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
remove_from_self if status.account.local?
remove_from_self if @account.local?
remove_from_followers
remove_from_lists
remove_from_affected
remove_reblogs
remove_from_hashtags
remove_from_public
remove_from_media if status.media_attachments.any?
remove_from_spam_check
remove_media
# There is no reason to send out Undo activities when the
# cause is that the original object has been removed, since
# original object being removed implicitly removes reblogs
# of it. The Delete activity of the original is forwarded
# separately.
if @account.local? && !@options[:original_removed]
remove_from_remote_followers
remove_from_remote_reach
end
# Since reblogs don't mention anyone, don't get reblogged,
# favourited and don't contain their own media attachments
# or hashtags, this can be skipped
unless @status.reblog?
remove_from_mentions
remove_reblogs
remove_from_hashtags
remove_from_public
remove_from_media if @status.media_attachments.any?
remove_from_spam_check
remove_media
end
@status.destroy! if @options[:immediate] || !@status.reported?
else
raise Mastodon::RaceConditionError
end
end
# There is no reason to send out Undo activities when the
# cause is that the original object has been removed, since
# original object being removed implicitly removes reblogs
# of it. The Delete activity of the original is forwarded
# separately.
return if !@account.local? || @options[:original_removed]
remove_from_remote_followers
remove_from_remote_affected
end
private
@ -67,31 +70,35 @@ class RemoveStatusService < BaseService
end
end
def remove_from_affected
@mentions.map(&:account).select(&:local?).each do |account|
redis.publish("timeline:#{account.id}", @payload)
def remove_from_mentions
# For limited visibility statuses, the mentions that determine
# who receives them in their home feed are a subset of followers
# and therefore the delete is already handled by sending it to all
# followers. Here we send a delete to actively mentioned accounts
# that may not follow the account
@status.active_mentions.find_each do |mention|
redis.publish("timeline:#{mention.account_id}", @payload)
end
end
def remove_from_remote_affected
def remove_from_remote_reach
return if @status.reblog?
# People who got mentioned in the status, or who
# reblogged it from someone else might not follow
# the author and wouldn't normally receive the
# delete notification - so here, we explicitly
# send it to them
target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?))
target_accounts << @status.reblog.account if @status.reblog? && !@status.reblog.account.local?
target_accounts.uniq!(&:id)
status_reach_finder = StatusReachFinder.new(@status)
# ActivityPub
ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account|
[signed_activity_json, @account.id, target_account.preferred_inbox_url]
ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.inboxes) do |inbox_url|
[signed_activity_json, @account.id, inbox_url]
end
end
def remove_from_remote_followers
# ActivityPub
ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
[signed_activity_json, @account.id, inbox_url]
end
@ -118,19 +125,19 @@ class RemoveStatusService < BaseService
# because once original status is gone, reblogs will disappear
# without us being able to do all the fancy stuff
@reblogs.each do |reblog|
@status.reblogs.includes(:account).find_each do |reblog|
RemoveStatusService.new.call(reblog, original_removed: true)
end
end
def remove_from_hashtags
@account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
@account.featured_tags.where(tag_id: @status.tags.map(&:id)).each do |featured_tag|
featured_tag.decrement(@status.id)
end
return unless @status.public_visibility?
@tags.each do |hashtag|
@status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
end
@ -140,22 +147,14 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility?
redis.publish('timeline:public', @payload)
if @status.local?
redis.publish('timeline:public:local', @payload)
else
redis.publish('timeline:public:remote', @payload)
end
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
end
def remove_from_media
return unless @status.public_visibility?
redis.publish('timeline:public:media', @payload)
if @status.local?
redis.publish('timeline:public:local:media', @payload)
else
redis.publish('timeline:public:remote:media', @payload)
end
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
end
def remove_media

View file

@ -24,7 +24,8 @@ class ReportService < BaseService
target_account: @target_account,
status_ids: @status_ids,
comment: @comment,
uri: @options[:uri]
uri: @options[:uri],
forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward])
)
end

View file

@ -5,8 +5,6 @@ class ResolveAccountService < BaseService
include DomainControlHelper
include WebfingerHelper
class WebfingerRedirectError < StandardError; end
# Find or create an account record for a remote user. When creating,
# look up the user's webfinger and fetch ActivityPub data
# @param [String, Account] uri URI in the username@domain format or account record
@ -26,12 +24,12 @@ class ResolveAccountService < BaseService
@account ||= Account.find_remote(@username, @domain)
return @account if @account&.local? || !webfinger_update_due?
return @account if @account&.local? || @domain.nil? || !webfinger_update_due?
# At this point we are in need of a Webfinger query, which may
# yield us a different username/domain through a redirect
process_webfinger!(@uri)
@domain = nil if TagManager.instance.local_domain?(@domain)
# Because the username/domain pair may be different than what
# we already checked, we need to check if we've already got
@ -41,13 +39,18 @@ class ResolveAccountService < BaseService
@account ||= Account.find_remote(@username, @domain)
return @account if @account&.local? || !webfinger_update_due?
if gone_from_origin? && not_yet_deleted?
queue_deletion!
return
end
return @account if @account&.local? || gone_from_origin? || !webfinger_update_due?
# Now it is certain, it is definitely a remote account, and it
# either needs to be created, or updated from fresh data
process_account!
rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e
fetch_account!
rescue Webfinger::Error, Oj::ParseError => e
Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
nil
end
@ -76,33 +79,37 @@ class ResolveAccountService < BaseService
@uri = [@username, @domain].compact.join('@')
end
def process_webfinger!(uri, redirected = false)
def process_webfinger!(uri)
@webfinger = webfinger!("acct:#{uri}")
confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@')
confirmed_username, confirmed_domain = split_acct(@webfinger.subject)
if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
@username = confirmed_username
@domain = confirmed_domain
@uri = uri
elsif !redirected
return process_webfinger!("#{confirmed_username}@#{confirmed_domain}", true)
else
raise WebfingerRedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
return
end
@domain = nil if TagManager.instance.local_domain?(@domain)
# Account doesn't match, so it may have been redirected
@webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
@username, @domain = split_acct(@webfinger.subject)
unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
end
rescue Webfinger::GoneError
@gone = true
end
def process_account!
def split_acct(acct)
acct.gsub(/\Aacct:/, '').split('@')
end
def fetch_account!
return unless activitypub_ready?
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@account = Account.find_remote(@username, @domain)
next if actor_json.nil?
@account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json)
@account = ActivityPub::FetchRemoteAccountService.new.call(actor_url)
else
raise Mastodon::RaceConditionError
end
@ -118,18 +125,23 @@ class ResolveAccountService < BaseService
end
def activitypub_ready?
!@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type)
['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type'))
end
def actor_url
@actor_url ||= @webfinger.link('self').href
@actor_url ||= @webfinger.link('self', 'href')
end
def actor_json
return @actor_json if defined?(@actor_json)
def gone_from_origin?
@gone
end
json = fetch_resource(actor_url, false)
@actor_json = supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ? json : nil
def not_yet_deleted?
@account.present? && !@account.local?
end
def queue_deletion!
AccountDeletionWorker.perform_async(@account.id, reserve_username: false, skip_activitypub: true)
end
def lock_options

View file

@ -34,7 +34,17 @@ class ResolveURLService < BaseService
# It may happen that the resource is a private toot, and thus not fetchable,
# but we can return the toot if we already know about it.
status = Status.find_by(uri: @url) || Status.find_by(url: @url)
scope = Status.where(uri: @url)
# We don't have an index on `url`, so try guessing the `uri` from `url`
parsed_url = Addressable::URI.parse(@url)
parsed_url.path.match(%r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z}) do |matched|
parsed_url.path = "/users/#{matched[:username]}/statuses/#{matched[:status_id]}"
scope = scope.or(Status.where(uri: parsed_url.to_s, url: @url))
end
status = scope.first
authorize_with @on_behalf_of, status, :show? unless status.nil?
status
rescue Mastodon::NotPermittedError

View file

@ -3,173 +3,91 @@
class SuspendAccountService < BaseService
include Payloadable
ASSOCIATIONS_ON_SUSPEND = %w(
account_pins
active_relationships
block_relationships
blocked_by_relationships
conversation_mutes
conversations
custom_filters
domain_blocks
favourites
follow_requests
list_accounts
mute_relationships
muted_by_relationships
notifications
owned_lists
passive_relationships
report_notes
scheduled_statuses
status_pins
).freeze
ASSOCIATIONS_ON_DESTROY = %w(
reports
targeted_moderation_notes
targeted_reports
).freeze
# Suspend or remove an account and remove as much of its data
# as possible. If it's a local account and it has not been confirmed
# or never been approved, then side effects are skipped and both
# the user and account records are removed fully. Otherwise,
# it is controlled by options.
# @param [Account]
# @param [Hash] options
# @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
# @option [Boolean] :reserve_username Keep account record
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
# @option [Time] :suspended_at Only applicable when :reserve_username is true
def call(account, **options)
def call(account)
@account = account
@options = { reserve_username: true, reserve_email: true }.merge(options)
if @account.local? && @account.user_unconfirmed_or_pending?
@options[:reserve_email] = false
@options[:reserve_username] = false
@options[:skip_side_effects] = true
end
reject_follows!
purge_user!
purge_profile!
purge_content!
suspend!
reject_remote_follows!
distribute_update_actor!
unmerge_from_home_timelines!
unmerge_from_list_timelines!
privatize_media_attachments!
end
private
def reject_follows!
def suspend!
@account.suspend! unless @account.suspended?
end
def reject_remote_follows!
return if @account.local? || !@account.activitypub?
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
[build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
# When suspending a remote account, the account obviously doesn't
# actually become suspended on its origin server, i.e. unlike a
# locally suspended account it continues to have access to its home
# feed and other content. To prevent it from being able to continue
# to access toots it would receive because it follows local accounts,
# we have to force it to unfollow them. Unfortunately, there is no
# counterpart to this operation, i.e. you can't then force a remote
# account to re-follow you, so this part is not reversible.
follows = Follow.where(account: @account).to_a
ActivityPub::DeliveryWorker.push_bulk(follows) do |follow|
[Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)), follow.target_account_id, @account.inbox_url]
end
follows.each(&:destroy)
end
def distribute_update_actor!
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) if @account.local?
end
def unmerge_from_home_timelines!
@account.followers_for_local_distribution.find_each do |follower|
FeedManager.instance.unmerge_from_home(@account, follower)
end
end
def purge_user!
return if !@account.local? || @account.user.nil?
if @options[:reserve_email]
@account.user.disable!
@account.user.invites.where(uses: 0).destroy_all
else
@account.user.destroy
def unmerge_from_list_timelines!
@account.lists_for_local_distribution.find_each do |list|
FeedManager.instance.unmerge_from_list(@account, list)
end
end
def purge_content!
distribute_delete_actor! if @account.local? && !@options[:skip_side_effects]
def privatize_media_attachments!
attachment_names = MediaAttachment.attachment_definitions.keys
@account.statuses.reorder(nil).find_in_batches do |statuses|
statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username]
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects])
end
@account.media_attachments.find_each do |media_attachment|
attachment_names.each do |attachment_name|
attachment = media_attachment.public_send(attachment_name)
styles = [:original] | attachment.styles.keys
@account.media_attachments.reorder(nil).find_each do |media_attachment|
next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id)
next if attachment.blank?
media_attachment.destroy
end
styles.each do |style|
case Paperclip::Attachment.default_options[:storage]
when :s3
begin
attachment.s3_object(style).acl.put(acl: 'private')
rescue Aws::S3::Errors::NoSuchKey
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
end
when :fog
# Not supported
when :filesystem
begin
FileUtils.chmod(0o600 & ~File.umask, attachment.path(style)) unless attachment.path(style).nil?
rescue Errno::ENOENT
Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
end
end
@account.polls.reorder(nil).find_each do |poll|
next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id)
poll.destroy
end
associations_for_destruction.each do |association_name|
destroy_all(@account.public_send(association_name))
end
@account.destroy unless @options[:reserve_username]
end
def purge_profile!
# If the account is going to be destroyed
# there is no point wasting time updating
# its values first
return unless @options[:reserve_username]
@account.silenced_at = nil
@account.suspended_at = @options[:suspended_at] || Time.now.utc
@account.locked = false
@account.memorial = false
@account.discoverable = false
@account.display_name = ''
@account.note = ''
@account.fields = []
@account.statuses_count = 0
@account.followers_count = 0
@account.following_count = 0
@account.moved_to_account = nil
@account.trust_level = :untrusted
@account.avatar.destroy
@account.header.destroy
@account.save!
end
def destroy_all(association)
association.in_batches.destroy_all
end
def distribute_delete_actor!
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
[delete_actor_json, @account.id, inbox_url]
end
end
def delete_actor_json
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
end
def build_reject_json(follow)
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
end
def delivery_inboxes
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
end
def low_priority_delivery_inboxes
Account.inboxes - delivery_inboxes
end
def reported_status_ids
@reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
end
def associations_for_destruction
if @options[:reserve_username]
ASSOCIATIONS_ON_SUSPEND
else
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
end
end
end
end
end

View file

@ -13,6 +13,6 @@ class UnblockDomainService < BaseService
scope = Account.by_domain_and_subdomains(domain_block.domain)
scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.noop?
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) if domain_block.suspend?
scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil, suspension_origin: nil) if domain_block.suspend?
end
end

View file

@ -0,0 +1,84 @@
# frozen_string_literal: true
class UnsuspendAccountService < BaseService
def call(account)
@account = account
unsuspend!
refresh_remote_account!
return if @account.nil?
merge_into_home_timelines!
merge_into_list_timelines!
publish_media_attachments!
end
private
def unsuspend!
@account.unsuspend! if @account.suspended?
end
def refresh_remote_account!
return if @account.local?
# While we had the remote account suspended, it could be that
# it got suspended on its origin, too. So, we need to refresh
# it straight away so it gets marked as remotely suspended in
# that case.
@account.update!(last_webfingered_at: nil)
@account = ResolveAccountService.new.call(@account)
# Worth noting that it is possible that the remote has not only
# been suspended, but deleted permanently, in which case
# @account would now be nil.
end
def merge_into_home_timelines!
@account.followers_for_local_distribution.find_each do |follower|
FeedManager.instance.merge_into_home(@account, follower)
end
end
def merge_into_list_timelines!
@account.lists_for_local_distribution.find_each do |list|
FeedManager.instance.merge_into_list(@account, list)
end
end
def publish_media_attachments!
attachment_names = MediaAttachment.attachment_definitions.keys
@account.media_attachments.find_each do |media_attachment|
attachment_names.each do |attachment_name|
attachment = media_attachment.public_send(attachment_name)
styles = [:original] | attachment.styles.keys
next if attachment.blank?
styles.each do |style|
case Paperclip::Attachment.default_options[:storage]
when :s3
begin
attachment.s3_object(style).acl.put(acl: Paperclip::Attachment.default_options[:s3_permissions])
rescue Aws::S3::Errors::NoSuchKey
Rails.logger.warn "Tried to change acl on non-existent key #{attachment.s3_object(style).key}"
end
when :fog
# Not supported
when :filesystem
begin
FileUtils.chmod(0o666 & ~File.umask, attachment.path(style)) unless attachment.path(style).nil?
rescue Errno::ENOENT
Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
end
end
CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
end
end
end
end
end

View file

@ -12,8 +12,8 @@ class UpdateAccountService < BaseService
check_links(account)
process_hashtags(account)
end
rescue Mastodon::DimensionsValidationError => de
account.errors.add(:avatar, de.message)
rescue Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e
account.errors.add(:avatar, e.message)
false
end