- Some associations were missing from the clean-up - Some attributes were not reset on suspension - Skip federation and streaming deletes when purging a dead domain - Move account association definitions to concernmaster
@@ -49,6 +49,7 @@ class Account < ApplicationRecord | |||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i | |||
MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i | |||
include AccountAssociations | |||
include AccountAvatar | |||
include AccountFinderConcern | |||
include AccountHeader | |||
@@ -59,9 +60,6 @@ class Account < ApplicationRecord | |||
enum protocol: [:ostatus, :activitypub] | |||
# Local users | |||
has_one :user, inverse_of: :account | |||
validates :username, presence: true | |||
# Remote user validations | |||
@@ -76,45 +74,6 @@ class Account < ApplicationRecord | |||
validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? } | |||
validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? } | |||
# Timelines | |||
has_many :stream_entries, inverse_of: :account, dependent: :destroy | |||
has_many :statuses, inverse_of: :account, dependent: :destroy | |||
has_many :favourites, inverse_of: :account, dependent: :destroy | |||
has_many :mentions, inverse_of: :account, dependent: :destroy | |||
has_many :notifications, inverse_of: :account, dependent: :destroy | |||
# Pinned statuses | |||
has_many :status_pins, inverse_of: :account, dependent: :destroy | |||
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status | |||
# Endorsements | |||
has_many :account_pins, inverse_of: :account, dependent: :destroy | |||
has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account | |||
# Media | |||
has_many :media_attachments, dependent: :destroy | |||
# PuSH subscriptions | |||
has_many :subscriptions, dependent: :destroy | |||
# Report relationships | |||
has_many :reports | |||
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id | |||
has_many :report_notes, dependent: :destroy | |||
has_many :custom_filters, inverse_of: :account, dependent: :destroy | |||
# Moderation notes | |||
has_many :account_moderation_notes, dependent: :destroy | |||
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy | |||
# Lists | |||
has_many :list_accounts, inverse_of: :account, dependent: :destroy | |||
has_many :lists, through: :list_accounts | |||
# Account migrations | |||
belongs_to :moved_to_account, class_name: 'Account', optional: true | |||
scope :remote, -> { where.not(domain: nil) } | |||
scope :local, -> { where(domain: nil) } | |||
scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) } | |||
@@ -452,6 +411,7 @@ class Account < ApplicationRecord | |||
before_create :generate_keys | |||
before_validation :normalize_domain | |||
before_validation :prepare_contents, if: :local? | |||
before_destroy :clean_feed_manager | |||
private | |||
@@ -477,4 +437,19 @@ class Account < ApplicationRecord | |||
def emojifiable_text | |||
[note, display_name, fields.map(&:value)].join(' ') | |||
end | |||
def clean_feed_manager | |||
reblog_key = FeedManager.instance.key(:home, id, 'reblogs') | |||
reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1) | |||
Redis.current.pipelined do | |||
Redis.current.del(FeedManager.instance.key(:home, id)) | |||
Redis.current.del(reblog_key) | |||
reblogged_id_set.each do |reblogged_id| | |||
reblog_set_key = FeedManager.instance.key(:home, id, "reblogs:#{reblogged_id}") | |||
Redis.current.del(reblog_set_key) | |||
end | |||
end | |||
end | |||
end |
@@ -0,0 +1,53 @@ | |||
# frozen_string_literal: true | |||
module AccountAssociations | |||
extend ActiveSupport::Concern | |||
included do | |||
# Local users | |||
has_one :user, inverse_of: :account, dependent: :destroy | |||
# Timelines | |||
has_many :stream_entries, inverse_of: :account, dependent: :destroy | |||
has_many :statuses, inverse_of: :account, dependent: :destroy | |||
has_many :favourites, inverse_of: :account, dependent: :destroy | |||
has_many :mentions, inverse_of: :account, dependent: :destroy | |||
has_many :notifications, inverse_of: :account, dependent: :destroy | |||
has_many :conversations, class_name: 'AccountConversation', dependent: :destroy, inverse_of: :account | |||
# Pinned statuses | |||
has_many :status_pins, inverse_of: :account, dependent: :destroy | |||
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status | |||
# Endorsements | |||
has_many :account_pins, inverse_of: :account, dependent: :destroy | |||
has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account | |||
# Media | |||
has_many :media_attachments, dependent: :destroy | |||
# PuSH subscriptions | |||
has_many :subscriptions, dependent: :destroy | |||
# Report relationships | |||
has_many :reports, dependent: :destroy, inverse_of: :account | |||
has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account | |||
has_many :report_notes, dependent: :destroy | |||
has_many :custom_filters, inverse_of: :account, dependent: :destroy | |||
# Moderation notes | |||
has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account | |||
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account | |||
# Lists (that the account is on, not owned by the account) | |||
has_many :list_accounts, inverse_of: :account, dependent: :destroy | |||
has_many :lists, through: :list_accounts | |||
# Lists (owned by the account) | |||
has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account | |||
# Account migrations | |||
belongs_to :moved_to_account, class_name: 'Account', optional: true | |||
end | |||
end |
@@ -9,7 +9,9 @@ class BatchedRemoveStatusService < BaseService | |||
# Remove statuses from home feeds | |||
# Push delete events to streaming API for home feeds and public feeds | |||
# @param [Status] statuses A preferably batched array of statuses | |||
def call(statuses) | |||
# @param [Hash] options | |||
# @option [Boolean] :skip_side_effects | |||
def call(statuses, **options) | |||
statuses = Status.where(id: statuses.map(&:id)).includes(:account, :stream_entry).flat_map { |status| [status] + status.reblogs.includes(:account, :stream_entry).to_a } | |||
@mentions = statuses.each_with_object({}) { |s, h| h[s.id] = s.active_mentions.includes(:account).to_a } | |||
@@ -26,6 +28,8 @@ class BatchedRemoveStatusService < BaseService | |||
status.destroy | |||
end | |||
return if options[:skip_side_effects] | |||
# Batch by source account | |||
statuses.group_by(&:account_id).each_value do |account_statuses| | |||
account = account_statuses.first.account | |||
@@ -1,6 +1,41 @@ | |||
# frozen_string_literal: true | |||
class SuspendAccountService < BaseService | |||
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 | |||
media_attachments | |||
mute_relationships | |||
muted_by_relationships | |||
notifications | |||
owned_lists | |||
passive_relationships | |||
report_notes | |||
status_pins | |||
stream_entries | |||
subscriptions | |||
).freeze | |||
ASSOCIATIONS_ON_DESTROY = %w( | |||
reports | |||
targeted_moderation_notes | |||
targeted_reports | |||
).freeze | |||
# Suspend an account and remove as much of its data as possible | |||
# @param [Account] | |||
# @param [Hash] options | |||
# @option [Boolean] :including_user Remove the user record as well | |||
# @option [Boolean] :destroy Remove the account record instead of suspending | |||
def call(account, **options) | |||
@account = account | |||
@options = options | |||
@@ -8,60 +43,66 @@ class SuspendAccountService < BaseService | |||
purge_user! | |||
purge_profile! | |||
purge_content! | |||
unsubscribe_push_subscribers! | |||
end | |||
private | |||
def purge_user! | |||
if @options[:remove_user] | |||
@account.user&.destroy | |||
return if !@account.local? || @account.user.nil? | |||
if @options[:including_user] | |||
@account.user.destroy | |||
else | |||
@account.user&.disable! | |||
@account.user.disable! | |||
end | |||
end | |||
def purge_content! | |||
if @account.local? | |||
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| | |||
[delete_actor_json, @account.id, inbox_url] | |||
end | |||
end | |||
distribute_delete_actor! if @account.local? | |||
@account.statuses.reorder(nil).find_in_batches do |statuses| | |||
BatchedRemoveStatusService.new.call(statuses) | |||
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:destroy]) | |||
end | |||
[ | |||
@account.media_attachments, | |||
@account.stream_entries, | |||
@account.notifications, | |||
@account.favourites, | |||
@account.active_relationships, | |||
@account.passive_relationships, | |||
].each do |association| | |||
destroy_all(association) | |||
associations_for_destruction.each do |association_name| | |||
destroy_all(@account.public_send(association_name)) | |||
end | |||
@account.destroy if @options[:destroy] | |||
end | |||
def purge_profile! | |||
@account.suspended = true | |||
@account.display_name = '' | |||
@account.note = '' | |||
@account.statuses_count = 0 | |||
# If the account is going to be destroyed | |||
# there is no point wasting time updating | |||
# its values first | |||
return if @options[:destroy] | |||
@account.silenced = false | |||
@account.suspended = true | |||
@account.locked = 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.avatar.destroy | |||
@account.header.destroy | |||
@account.save! | |||
end | |||
def unsubscribe_push_subscribers! | |||
destroy_all(@account.subscriptions) | |||
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 | |||
end | |||
def delete_actor_json | |||
return @delete_actor_json if defined?(@delete_actor_json) | |||
@@ -77,4 +118,12 @@ class SuspendAccountService < BaseService | |||
def delivery_inboxes | |||
Account.inboxes + Relay.enabled.pluck(:inbox_url) | |||
end | |||
def associations_for_destruction | |||
if @options[:destroy] | |||
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY | |||
else | |||
ASSOCIATIONS_ON_SUSPEND | |||
end | |||
end | |||
end |
@@ -6,6 +6,6 @@ class Admin::SuspensionWorker | |||
sidekiq_options queue: 'pull' | |||
def perform(account_id, remove_user = false) | |||
SuspendAccountService.new.call(Account.find(account_id), remove_user: remove_user) | |||
SuspendAccountService.new.call(Account.find(account_id), including_user: remove_user) | |||
end | |||
end |
@@ -22,11 +22,7 @@ module Mastodon | |||
dry_run = options[:dry_run] ? ' (DRY RUN)' : '' | |||
Account.where(domain: domain).find_each do |account| | |||
unless options[:dry_run] | |||
SuspendAccountService.new.call(account) | |||
account.destroy | |||
end | |||
SuspendAccountService.new.call(account, destroy: true) unless options[:dry_run] | |||
removed += 1 | |||
say('.', :green, false) | |||
end | |||