require_relative '../../lib/mastodon/migration_warning' class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2] include Mastodon::MigrationWarning class Account < ApplicationRecord # Dummy class, to make migration possible across version changes has_one :user, inverse_of: :account def local? domain.nil? end def acct local? ? username : "#{username}@#{domain}" end end class StreamEntry < ApplicationRecord # Dummy class, to make migration possible across version changes belongs_to :account, inverse_of: :stream_entries end class Status < ApplicationRecord # Dummy class, to make migration possible across version changes belongs_to :account end class Mention < ApplicationRecord # Dummy class, to make migration possible across version changes belongs_to :account end class StatusPin < ApplicationRecord # Dummy class, to make migration possible across version changes belongs_to :account end disable_ddl_transaction! def up migration_duration_warning(<<~EXPLANATION) This migration will irreversibly delete user accounts with duplicate usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames` task to manually deal with such accounts before running this migration. EXPLANATION duplicates = Account.connection.select_all('SELECT string_agg(id::text, \',\') AS ids FROM accounts GROUP BY lower(username), lower(domain) HAVING count(*) > 1').to_ary duplicates.each do |row| deduplicate_account!(row['ids'].split(',')) end remove_index :accounts, name: 'index_accounts_on_username_and_domain_lower' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain_lower') safety_assured { execute 'CREATE UNIQUE INDEX CONCURRENTLY index_accounts_on_username_and_domain_lower ON accounts (lower(username), lower(domain))' } remove_index :accounts, name: 'index_accounts_on_username_and_domain' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain') end def down raise ActiveRecord::IrreversibleMigration end private def deduplicate_account!(account_ids) accounts = Account.where(id: account_ids).to_a accounts = accounts.first.local? ? accounts.sort_by(&:created_at) : accounts.sort_by(&:updated_at).reverse reference_account = accounts.shift say_with_time "Deduplicating @#{reference_account.acct} (#{accounts.size} duplicates)..." do accounts.each do |other_account| if other_account.public_key == reference_account.public_key # The accounts definitely point to the same resource, so # it's safe to re-attribute content and relationships merge_accounts!(reference_account, other_account) elsif other_account.local? # Since domain is in the GROUP BY clause, both accounts # are always either going to be local or not local, so only # one check is needed. Since we cannot support two users with # the same username locally, one has to go. 😢 other_account.user&.destroy end other_account.destroy end end end def merge_accounts!(main_account, duplicate_account) [Status, Mention, StatusPin, StreamEntry].each do |klass| klass.where(account_id: duplicate_account.id).in_batches.update_all(account_id: main_account.id) end # Since it's the same remote resource, the remote resource likely # already believes we are following/blocking, so it's safe to # re-attribute the relationships too. However, during the presence # of the index bug users could have *also* followed the reference # account already, therefore mass update will not work and we need # to check for (and skip past) uniqueness errors [Favourite, Follow, FollowRequest, Block, Mute].each do |klass| klass.where(account_id: duplicate_account.id).find_each do |record| record.update_attribute(:account_id, main_account.id) rescue ActiveRecord::RecordNotUnique next end end [Follow, FollowRequest, Block, Mute].each do |klass| klass.where(target_account_id: duplicate_account.id).find_each do |record| record.update_attribute(:target_account_id, main_account.id) rescue ActiveRecord::RecordNotUnique next end end end end