# 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(ids.map { |id| "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