# frozen_string_literal: true require_relative 'base' module Mastodon::CLI class Maintenance < Base MIN_SUPPORTED_VERSION = 2019_10_01_213028 MAX_SUPPORTED_VERSION = 2023_10_23_105620 # Stubs to enjoy ActiveRecord queries while not depending on a particular # version of the code/database class Status < ApplicationRecord; end class StatusPin < ApplicationRecord; end class Poll < ApplicationRecord; end class Report < ApplicationRecord; end class Tombstone < ApplicationRecord; end class Favourite < ApplicationRecord; end class Follow < ApplicationRecord; end class FollowRequest < ApplicationRecord; end class Block < ApplicationRecord; end class Mute < ApplicationRecord; end class AccountIdentityProof < ApplicationRecord; end class AccountModerationNote < ApplicationRecord; end class AccountPin < ApplicationRecord; end class ListAccount < ApplicationRecord; end class PollVote < ApplicationRecord; end class Mention < ApplicationRecord; end class Notification < ApplicationRecord; end class NotificationPermission < ApplicationRecord; end class NotificationRequest < ApplicationRecord; end class AccountDomainBlock < ApplicationRecord; end class AnnouncementReaction < ApplicationRecord; end class FeaturedTag < ApplicationRecord; end class CustomEmoji < ApplicationRecord; end class CustomEmojiCategory < ApplicationRecord; end class Bookmark < ApplicationRecord; end class WebauthnCredential < ApplicationRecord; end class FollowRecommendationSuppression < ApplicationRecord; end class CanonicalEmailBlock < ApplicationRecord; end class Appeal < ApplicationRecord; end class Webhook < ApplicationRecord; end class BulkImport < ApplicationRecord; end class SoftwareUpdate < ApplicationRecord; end class SeveredRelationship < ApplicationRecord; end class DomainBlock < ApplicationRecord enum :severity, { silence: 0, suspend: 1, noop: 2 } scope :by_severity, -> { in_order_of(:severity, %w(noop silence suspend)).order(:domain) } end class PreviewCard < ApplicationRecord self.inheritance_column = false end class MediaAttachment < ApplicationRecord self.inheritance_column = nil end class AccountStat < ApplicationRecord belongs_to :account, inverse_of: :account_stat end # Dummy class, to make migration possible across version changes class Account < ApplicationRecord has_one :user, inverse_of: :account has_one :account_stat, inverse_of: :account scope :local, -> { where(domain: nil) } def local? domain.nil? end def acct local? ? username : "#{username}@#{domain}" end def db_table_exists?(table) ActiveRecord::Base.connection.table_exists?(table) end # This is a duplicate of the Account::Merging concern because we need it # to be independent from code version. def merge_with!(other_account) # 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 owned_classes = [ Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite, Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin, AccountStat, ListAccount, PollVote, Mention ] owned_classes << AccountDeletionRequest if db_table_exists?(:account_deletion_requests) owned_classes << AccountNote if db_table_exists?(:account_notes) owned_classes << FollowRecommendationSuppression if db_table_exists?(:follow_recommendation_suppressions) owned_classes << AccountIdentityProof if db_table_exists?(:account_identity_proofs) owned_classes << Appeal if db_table_exists?(:appeals) owned_classes << BulkImport if db_table_exists?(:bulk_imports) owned_classes.each do |klass| klass.where(account_id: other_account.id).find_each do |record| record.update_attribute(:account_id, id) rescue ActiveRecord::RecordNotUnique next end end from_classes = [Notification] from_classes << NotificationPermission if db_table_exists?(:notification_permissions) from_classes << NotificationRequest if db_table_exists?(:notification_requests) from_classes.each do |klass| klass.where(from_account_id: other_account.id).find_each do |record| record.update_attribute(:from_account_id, id) rescue ActiveRecord::RecordNotUnique next end end target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin] target_classes << AccountNote if db_table_exists?(:account_notes) target_classes.each do |klass| klass.where(target_account_id: other_account.id).find_each do |record| record.update_attribute(:target_account_id, id) rescue ActiveRecord::RecordNotUnique next end end if db_table_exists?(:canonical_email_blocks) CanonicalEmailBlock.where(reference_account_id: other_account.id).find_each do |record| record.update_attribute(:reference_account_id, id) end end if db_table_exists?(:appeals) Appeal.where(account_warning_id: other_account.id).find_each do |record| record.update_attribute(:account_warning_id, id) end end if db_table_exists?(:severed_relationships) SeveredRelationship.where(local_account_id: other_account.id).reorder(nil).find_each do |record| record.update_attribute(:local_account_id, id) rescue ActiveRecord::RecordNotUnique next end SeveredRelationship.where(remote_account_id: other_account.id).reorder(nil).find_each do |record| record.update_attribute(:remote_account_id, id) rescue ActiveRecord::RecordNotUnique next end end end end class User < ApplicationRecord belongs_to :account, inverse_of: :user end desc 'fix-duplicates', 'Fix duplicates in database and rebuild indexes' long_desc <<~LONG_DESC Delete or merge duplicate accounts, statuses, emojis, etc. and rebuild indexes. This is useful if your database indexes are corrupted because of issues such as https://wiki.postgresql.org/wiki/Locale_data_changes Mastodon has to be stopped to run this task, which will take a long time and may be destructive. LONG_DESC def fix_duplicates verify_system_ready! process_deduplications deduplication_cleanup_tasks say 'Finished!' end private def verify_system_ready! verify_schema_version! verify_sidekiq_not_active! verify_backup_warning! end def process_deduplications deduplicate_users! deduplicate_account_domain_blocks! deduplicate_account_identity_proofs! deduplicate_announcement_reactions! deduplicate_conversations! deduplicate_custom_emojis! deduplicate_custom_emoji_categories! deduplicate_domain_allows! deduplicate_domain_blocks! deduplicate_unavailable_domains! deduplicate_email_domain_blocks! deduplicate_media_attachments! deduplicate_preview_cards! deduplicate_statuses! deduplicate_accounts! deduplicate_tags! deduplicate_webauthn_credentials! deduplicate_webhooks! deduplicate_software_updates! end def deduplication_cleanup_tasks refresh_instances_view if schema_has_instances_view? Rails.cache.clear end def refresh_instances_view Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) end def schema_has_instances_view? migrator_version >= 2020_12_06_004238 end def verify_schema_version! if migrator_version < MIN_SUPPORTED_VERSION fail_with_message <<~ERROR Your version of the database schema is too old and is not supported by this script. Please update to at least Mastodon 3.0.0 before running this script. ERROR elsif migrator_version > MAX_SUPPORTED_VERSION say 'Your version of the database schema is more recent than this script, this may cause unexpected errors.', :yellow fail_with_message 'Stopping maintenance script because data is more recent than script version.' unless yes?('Continue anyway? (Yes/No)') end end def verify_sidekiq_not_active! fail_with_message 'It seems Sidekiq is running. All Mastodon processes need to be stopped when using this script.' if Sidekiq::ProcessSet.new.any? end def verify_backup_warning! say 'This task will take a long time to run and is potentially destructive.', :yellow say 'Please make sure to stop Mastodon and have a backup.', :yellow fail_with_message 'Maintenance process stopped.' unless yes?('Continue? (Yes/No)') end def deduplicate_accounts! remove_index_if_exists!(:accounts, 'index_accounts_on_username_and_domain_lower') say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.' find_duplicate_accounts.each do |row| accounts = Account.where(id: row['ids'].split(',')) if accounts.first.local? deduplicate_local_accounts!(accounts) else deduplicate_remote_accounts!(accounts) end end say 'Restoring index_accounts_on_username_and_domain_lower…' if migrator_version < 2020_06_20_164023 database_connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true else database_connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true end say 'Reindexing textual indexes on accounts…' rebuild_index(:search_index) rebuild_index(:index_accounts_on_uri) rebuild_index(:index_accounts_on_url) rebuild_index(:index_accounts_on_domain_and_id) if migrator_version >= 2023_05_24_190515 end def deduplicate_users! remove_index_if_exists!(:users, 'index_users_on_confirmation_token') remove_index_if_exists!(:users, 'index_users_on_email') remove_index_if_exists!(:users, 'index_users_on_remember_token') remove_index_if_exists!(:users, 'index_users_on_reset_password_token') say 'Deduplicating user records…' deduplicate_users_process_email deduplicate_users_process_confirmation_token deduplicate_users_process_remember_token deduplicate_users_process_password_token say 'Restoring users indexes…' database_connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true database_connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true database_connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if migrator_version < 2022_01_18_183010 if migrator_version < 2022_03_10_060641 database_connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true else database_connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops end rebuild_index(:index_users_on_unconfirmed_email) if migrator_version >= 2023_07_02_151753 end def deduplicate_users_process_email database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row| users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a ref_user = users.shift say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow say "e-mail will be disabled for the following accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow say 'Please reach out to them and set another address with `tootctl account modify` or delete them.', :yellow users.each_with_index do |user, index| user.update!(email: "#{index} " + user.email) end end end def deduplicate_users_process_confirmation_token database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row| users = User.where(id: row['ids'].split(',')).order(created_at: :desc).includes(:account).to_a.drop(1) say "Unsetting confirmation token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow users.each do |user| user.update!(confirmation_token: nil) end end end def deduplicate_users_process_remember_token if migrator_version < 2022_01_18_183010 database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row| users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).to_a.drop(1) say "Unsetting remember token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow users.each do |user| user.update!(remember_token: nil) end end end end def deduplicate_users_process_password_token database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row| users = User.where(id: row['ids'].split(',')).order(updated_at: :desc).includes(:account).to_a.drop(1) say "Unsetting password reset token for those accounts: #{users.map { |user| user.account.acct }.join(', ')}", :yellow users.each do |user| user.update!(reset_password_token: nil) end end end def deduplicate_account_domain_blocks! remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain') say 'Removing duplicate account domain blocks…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_domain_blocks GROUP BY account_id, domain HAVING count(*) > 1").each do |row| AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all end say 'Restoring account domain blocks indexes…' database_connection.add_index :account_domain_blocks, %w(account_id domain), name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true end def deduplicate_account_identity_proofs! return unless db_table_exists?(:account_identity_proofs) remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username') say 'Removing duplicate account identity proofs…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row| AccountIdentityProof.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring account identity proofs indexes…' database_connection.add_index :account_identity_proofs, %w(account_id provider provider_username), name: 'index_account_proofs_on_account_and_provider_and_username', unique: true end def deduplicate_announcement_reactions! return unless db_table_exists?(:announcement_reactions) remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id') say 'Removing duplicate announcement reactions…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row| AnnouncementReaction.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring announcement_reactions indexes…' database_connection.add_index :announcement_reactions, %w(account_id announcement_id name), name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true end def deduplicate_conversations! remove_index_if_exists!(:conversations, 'index_conversations_on_uri') say 'Deduplicating conversations…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row| conversations = Conversation.where(id: row['ids'].split(',')).order(id: :desc).to_a ref_conversation = conversations.shift conversations.each do |other| merge_conversations!(ref_conversation, other) other.destroy end end say 'Restoring conversations indexes…' if migrator_version < 2022_03_07_083603 database_connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true else database_connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops end end def deduplicate_custom_emojis! remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain') say 'Deduplicating custom_emojis…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row| emojis = CustomEmoji.where(id: row['ids'].split(',')).order(id: :desc).to_a ref_emoji = emojis.shift emojis.each do |other| merge_custom_emojis!(ref_emoji, other) other.destroy end end say 'Restoring custom_emojis indexes…' database_connection.add_index :custom_emojis, %w(shortcode domain), name: 'index_custom_emojis_on_shortcode_and_domain', unique: true end def deduplicate_custom_emoji_categories! remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name') say 'Deduplicating custom_emoji_categories…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row| categories = CustomEmojiCategory.where(id: row['ids'].split(',')).order(id: :desc).to_a ref_category = categories.shift categories.each do |other| merge_custom_emoji_categories!(ref_category, other) other.destroy end end say 'Restoring custom_emoji_categories indexes…' database_connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true end def deduplicate_domain_allows! remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain') say 'Deduplicating domain_allows…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row| DomainAllow.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring domain_allows indexes…' database_connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true end def deduplicate_domain_blocks! remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain') say 'Deduplicating domain_blocks…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row| domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a reject_media = domain_blocks.any?(&:reject_media?) reject_reports = domain_blocks.any?(&:reject_reports?) reference_block = domain_blocks.shift private_comment = domain_blocks.reduce(reference_block.private_comment.presence) { |a, b| a || b.private_comment.presence } public_comment = domain_blocks.reduce(reference_block.public_comment.presence) { |a, b| a || b.public_comment.presence } reference_block.update!(reject_media: reject_media, reject_reports: reject_reports, private_comment: private_comment, public_comment: public_comment) domain_blocks.each(&:destroy) end say 'Restoring domain_blocks indexes…' database_connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true end def deduplicate_unavailable_domains! return unless db_table_exists?(:unavailable_domains) remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain') say 'Deduplicating unavailable_domains…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row| UnavailableDomain.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring unavailable_domains indexes…' database_connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true end def deduplicate_email_domain_blocks! remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain') say 'Deduplicating email_domain_blocks…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row| domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).order(EmailDomainBlock.arel_table[:parent_id].asc.nulls_first).to_a domain_blocks.drop(1).each(&:destroy) end say 'Restoring email_domain_blocks indexes…' database_connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true end def deduplicate_media_attachments! remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode') say 'Deduplicating media_attachments…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM media_attachments WHERE shortcode IS NOT NULL GROUP BY shortcode HAVING count(*) > 1").each do |row| MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil) end say 'Restoring media_attachments indexes…' if migrator_version < 2022_03_10_060626 database_connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true else database_connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true, where: 'shortcode IS NOT NULL', opclass: :text_pattern_ops end end def deduplicate_preview_cards! remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url') say 'Deduplicating preview_cards…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row| PreviewCard.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring preview_cards indexes…' database_connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true end def deduplicate_statuses! remove_index_if_exists!(:statuses, 'index_statuses_on_uri') say 'Deduplicating statuses…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row| statuses = Status.where(id: row['ids'].split(',')).order(id: :asc).to_a ref_status = statuses.shift statuses.each do |status| merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id status.destroy end end say 'Restoring statuses indexes…' if migrator_version < 2022_03_10_060706 database_connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true else database_connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true, where: 'uri IS NOT NULL', opclass: :text_pattern_ops end end def deduplicate_tags! remove_index_if_exists!(:tags, 'index_tags_on_name_lower') remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree') say 'Deduplicating tags…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row| tags = Tag.where(id: row['ids'].split(',')).order(Arel.sql('(usable::int + trendable::int + listable::int) desc')).to_a ref_tag = tags.shift tags.each do |tag| merge_tags!(ref_tag, tag) tag.destroy end end say 'Restoring tags indexes…' if migrator_version < 2021_04_21_121431 database_connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true else database_connection.execute 'CREATE UNIQUE INDEX index_tags_on_name_lower_btree ON tags (lower(name) text_pattern_ops)' end end def deduplicate_webauthn_credentials! return unless db_table_exists?(:webauthn_credentials) remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id') say 'Deduplicating webauthn_credentials…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row| WebauthnCredential.where(id: row['ids'].split(',')).order(id: :desc).to_a.drop(1).each(&:destroy) end say 'Restoring webauthn_credentials indexes…' database_connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true end def deduplicate_webhooks! return unless db_table_exists?(:webhooks) remove_index_if_exists!(:webhooks, 'index_webhooks_on_url') say 'Deduplicating webhooks…' database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row| Webhook.where(id: row['ids'].split(',')).order(id: :desc).drop(1).each(&:destroy) end say 'Restoring webhooks indexes…' database_connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true end def deduplicate_software_updates! # Not bothering with this, it's data that will be recovered with the scheduler SoftwareUpdate.delete_all end def deduplicate_local_accounts!(scope) accounts = scope.order(id: :desc).includes(:account_stat, :user).to_a say "Multiple local accounts were found for username '#{accounts.first.username}'.", :yellow say 'All those accounts are distinct accounts but only the most recently-created one is fully-functional.', :yellow accounts.each_with_index do |account, idx| say format( '%2d. %s: created at: %s; updated at: %s; last logged in at: %s; statuses: %5d; last status at: %s', index: idx, username: account.username, created_at: account.created_at, updated_at: account.updated_at, last_log_in_at: account.user&.last_sign_in_at&.to_s || 'N/A', status_count: account.account_stat&.statuses_count || 0, last_status_at: account.account_stat&.last_status_at || 'N/A' ) end say 'Please chose the one to keep unchanged, other ones will be automatically renamed.' ref_id = ask('Account to keep unchanged:', required: true, default: 0).to_i accounts.delete_at(ref_id) i = 0 accounts.each do |account| i += 1 username = account.username + "_#{i}" while Account.local.exists?(username: username) i += 1 username = account.username + "_#{i}" end account.update!(username: username) end end def deduplicate_remote_accounts!(scope) accounts = scope.order(updated_at: :desc).to_a reference_account = accounts.shift 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 reference_account.merge_with!(other_account) end other_account.destroy end end def merge_conversations!(main_conv, duplicate_conv) owned_classes = [ConversationMute, AccountConversation] owned_classes.each do |klass| klass.where(conversation_id: duplicate_conv.id).find_each do |record| record.update_attribute(:account_id, main_conv.id) rescue ActiveRecord::RecordNotUnique next end end end def merge_custom_emojis!(main_emoji, duplicate_emoji) owned_classes = [AnnouncementReaction] owned_classes.each do |klass| klass.where(custom_emoji_id: duplicate_emoji.id).update_all(custom_emoji_id: main_emoji.id) end end def merge_custom_emoji_categories!(main_category, duplicate_category) owned_classes = [CustomEmoji] owned_classes.each do |klass| klass.where(category_id: duplicate_category.id).update_all(category_id: main_category.id) end end def merge_statuses!(main_status, duplicate_status) owned_classes = [Favourite, Mention, Poll] owned_classes << Bookmark if db_table_exists?(:bookmarks) owned_classes.each do |klass| klass.where(status_id: duplicate_status.id).find_each do |record| record.update_attribute(:status_id, main_status.id) rescue ActiveRecord::RecordNotUnique next end end StatusPin.where(account_id: main_status.account_id, status_id: duplicate_status.id).find_each do |record| record.update_attribute(:status_id, main_status.id) rescue ActiveRecord::RecordNotUnique next end Status.where(in_reply_to_id: duplicate_status.id).find_each do |record| record.update_attribute(:in_reply_to_id, main_status.id) rescue ActiveRecord::RecordNotUnique next end Status.where(reblog_of_id: duplicate_status.id).find_each do |record| record.update_attribute(:reblog_of_id, main_status.id) rescue ActiveRecord::RecordNotUnique next end end def merge_tags!(main_tag, duplicate_tag) [FeaturedTag].each do |klass| klass.where(tag_id: duplicate_tag.id).find_each do |record| record.update_attribute(:tag_id, main_tag.id) rescue ActiveRecord::RecordNotUnique next end end end def migrator_version ActiveRecord::Migrator.current_version end def find_duplicate_accounts database_connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1") end def remove_index_if_exists!(table, name) database_connection.remove_index(table, name: name) if database_connection.index_name_exists?(table, name) rescue ArgumentError, ActiveRecord::StatementInvalid nil end def database_connection ActiveRecord::Base.connection end def db_table_exists?(table) database_connection.table_exists?(table) end def rebuild_index(name) database_connection.execute("REINDEX INDEX #{name}") end end end