You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

624 lines
27 KiB

  1. # frozen_string_literal: true
  2. require 'tty-prompt'
  3. require_relative '../../config/boot'
  4. require_relative '../../config/environment'
  5. require_relative 'cli_helper'
  6. module Mastodon
  7. class MaintenanceCLI < Thor
  8. include CLIHelper
  9. def self.exit_on_failure?
  10. true
  11. end
  12. MIN_SUPPORTED_VERSION = 2019_10_01_213028
  13. MAX_SUPPORTED_VERSION = 2021_03_08_133107
  14. # Stubs to enjoy ActiveRecord queries while not depending on a particular
  15. # version of the code/database
  16. class Status < ApplicationRecord; end
  17. class StatusPin < ApplicationRecord; end
  18. class Poll < ApplicationRecord; end
  19. class Report < ApplicationRecord; end
  20. class Tombstone < ApplicationRecord; end
  21. class Favourite < ApplicationRecord; end
  22. class Follow < ApplicationRecord; end
  23. class FollowRequest < ApplicationRecord; end
  24. class Block < ApplicationRecord; end
  25. class Mute < ApplicationRecord; end
  26. class AccountIdentityProof < ApplicationRecord; end
  27. class AccountModerationNote < ApplicationRecord; end
  28. class AccountPin < ApplicationRecord; end
  29. class ListAccount < ApplicationRecord; end
  30. class PollVote < ApplicationRecord; end
  31. class Mention < ApplicationRecord; end
  32. class AccountDomainBlock < ApplicationRecord; end
  33. class AnnouncementReaction < ApplicationRecord; end
  34. class FeaturedTag < ApplicationRecord; end
  35. class CustomEmoji < ApplicationRecord; end
  36. class CustomEmojiCategory < ApplicationRecord; end
  37. class Bookmark < ApplicationRecord; end
  38. class WebauthnCredential < ApplicationRecord; end
  39. class PreviewCard < ApplicationRecord
  40. self.inheritance_column = false
  41. end
  42. class MediaAttachment < ApplicationRecord
  43. self.inheritance_column = nil
  44. end
  45. class AccountStat < ApplicationRecord
  46. belongs_to :account, inverse_of: :account_stat
  47. end
  48. # Dummy class, to make migration possible across version changes
  49. class Account < ApplicationRecord
  50. has_one :user, inverse_of: :account
  51. has_one :account_stat, inverse_of: :account
  52. scope :local, -> { where(domain: nil) }
  53. def local?
  54. domain.nil?
  55. end
  56. def acct
  57. local? ? username : "#{username}@#{domain}"
  58. end
  59. # This is a duplicate of the AccountMerging concern because we need it to
  60. # be independent from code version.
  61. def merge_with!(other_account)
  62. # Since it's the same remote resource, the remote resource likely
  63. # already believes we are following/blocking, so it's safe to
  64. # re-attribute the relationships too. However, during the presence
  65. # of the index bug users could have *also* followed the reference
  66. # account already, therefore mass update will not work and we need
  67. # to check for (and skip past) uniqueness errors
  68. owned_classes = [
  69. Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
  70. Follow, FollowRequest, Block, Mute, AccountIdentityProof,
  71. AccountModerationNote, AccountPin, AccountStat, ListAccount,
  72. PollVote, Mention
  73. ]
  74. owned_classes << AccountDeletionRequest if ActiveRecord::Base.connection.table_exists?(:account_deletion_requests)
  75. owned_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes)
  76. owned_classes.each do |klass|
  77. klass.where(account_id: other_account.id).find_each do |record|
  78. begin
  79. record.update_attribute(:account_id, id)
  80. rescue ActiveRecord::RecordNotUnique
  81. next
  82. end
  83. end
  84. end
  85. target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin]
  86. target_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes)
  87. target_classes.each do |klass|
  88. klass.where(target_account_id: other_account.id).find_each do |record|
  89. begin
  90. record.update_attribute(:target_account_id, id)
  91. rescue ActiveRecord::RecordNotUnique
  92. next
  93. end
  94. end
  95. end
  96. end
  97. end
  98. class User < ApplicationRecord
  99. belongs_to :account, inverse_of: :user
  100. end
  101. desc 'fix-duplicates', 'Fix duplicates in database and rebuild indexes'
  102. long_desc <<~LONG_DESC
  103. Delete or merge duplicate accounts, statuses, emojis, etc. and rebuild indexes.
  104. This is useful if your database indexes are corrupted because of issues such as https://wiki.postgresql.org/wiki/Locale_data_changes
  105. Mastodon has to be stopped to run this task, which will take a long time and may be destructive.
  106. LONG_DESC
  107. def fix_duplicates
  108. @prompt = TTY::Prompt.new
  109. if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION
  110. @prompt.warn 'Your version of the database schema is too old and is not supported by this script.'
  111. @prompt.warn 'Please update to at least Mastodon 3.0.0 before running this script.'
  112. exit(1)
  113. elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION
  114. @prompt.warn 'Your version of the database schema is more recent than this script, this may cause unexpected errors.'
  115. exit(1) unless @prompt.yes?('Continue anyway?')
  116. end
  117. @prompt.warn 'This task will take a long time to run and is potentially destructive.'
  118. @prompt.warn 'Please make sure to stop Mastodon and have a backup.'
  119. exit(1) unless @prompt.yes?('Continue?')
  120. deduplicate_users!
  121. deduplicate_account_domain_blocks!
  122. deduplicate_account_identity_proofs!
  123. deduplicate_announcement_reactions!
  124. deduplicate_conversations!
  125. deduplicate_custom_emojis!
  126. deduplicate_custom_emoji_categories!
  127. deduplicate_domain_allows!
  128. deduplicate_domain_blocks!
  129. deduplicate_unavailable_domains!
  130. deduplicate_email_domain_blocks!
  131. deduplicate_media_attachments!
  132. deduplicate_preview_cards!
  133. deduplicate_statuses!
  134. deduplicate_accounts!
  135. deduplicate_tags!
  136. deduplicate_webauthn_credentials!
  137. Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) if ActiveRecord::Migrator.current_version >= 2020_12_06_004238
  138. Rails.cache.clear
  139. @prompt.say 'Finished!'
  140. end
  141. private
  142. def deduplicate_accounts!
  143. remove_index_if_exists!(:accounts, 'index_accounts_on_username_and_domain_lower')
  144. @prompt.say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.'
  145. find_duplicate_accounts.each do |row|
  146. accounts = Account.where(id: row['ids'].split(',')).to_a
  147. if accounts.first.local?
  148. deduplicate_local_accounts!(accounts)
  149. else
  150. deduplicate_remote_accounts!(accounts)
  151. end
  152. end
  153. @prompt.say 'Restoring index_accounts_on_username_and_domain_lower…'
  154. if ActiveRecord::Migrator.current_version < 20200620164023
  155. ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
  156. else
  157. ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
  158. end
  159. @prompt.say 'Reindexing textual indexes on accounts…'
  160. ActiveRecord::Base.connection.execute('REINDEX INDEX search_index;')
  161. ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_uri;')
  162. ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_url;')
  163. end
  164. def deduplicate_users!
  165. remove_index_if_exists!(:users, 'index_users_on_confirmation_token')
  166. remove_index_if_exists!(:users, 'index_users_on_email')
  167. remove_index_if_exists!(:users, 'index_users_on_remember_token')
  168. remove_index_if_exists!(:users, 'index_users_on_reset_password_token')
  169. @prompt.say 'Deduplicating user records…'
  170. # Deduplicating email
  171. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
  172. users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
  173. ref_user = users.shift
  174. @prompt.warn "Multiple users registered with e-mail address #{ref_user.email}."
  175. @prompt.warn "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}"
  176. @prompt.warn 'Please reach out to them and set another address with `tootctl account modify` or delete them.'
  177. i = 0
  178. users.each do |user|
  179. user.update!(email: "#{i} " + user.email)
  180. end
  181. end
  182. ActiveRecord::Base.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|
  183. users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1)
  184. @prompt.warn "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
  185. users.each do |user|
  186. user.update!(confirmation_token: nil)
  187. end
  188. end
  189. ActiveRecord::Base.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|
  190. users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
  191. @prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
  192. users.each do |user|
  193. user.update!(remember_token: nil)
  194. end
  195. end
  196. ActiveRecord::Base.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|
  197. users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
  198. @prompt.warn "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
  199. users.each do |user|
  200. user.update!(reset_password_token: nil)
  201. end
  202. end
  203. @prompt.say 'Restoring users indexes…'
  204. ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
  205. ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
  206. ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true
  207. ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
  208. end
  209. def deduplicate_account_domain_blocks!
  210. remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain')
  211. @prompt.say 'Removing duplicate account domain blocks…'
  212. ActiveRecord::Base.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|
  213. AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all
  214. end
  215. @prompt.say 'Restoring account domain blocks indexes…'
  216. ActiveRecord::Base.connection.add_index :account_domain_blocks, ['account_id', 'domain'], name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true
  217. end
  218. def deduplicate_account_identity_proofs!
  219. remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username')
  220. @prompt.say 'Removing duplicate account identity proofs…'
  221. ActiveRecord::Base.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|
  222. AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  223. end
  224. @prompt.say 'Restoring account identity proofs indexes…'
  225. ActiveRecord::Base.connection.add_index :account_identity_proofs, ['account_id', 'provider', 'provider_username'], name: 'index_account_proofs_on_account_and_provider_and_username', unique: true
  226. end
  227. def deduplicate_announcement_reactions!
  228. return unless ActiveRecord::Base.connection.table_exists?(:announcement_reactions)
  229. remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
  230. @prompt.say 'Removing duplicate account identity proofs…'
  231. ActiveRecord::Base.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|
  232. AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  233. end
  234. @prompt.say 'Restoring announcement_reactions indexes…'
  235. ActiveRecord::Base.connection.add_index :announcement_reactions, ['account_id', 'announcement_id', 'name'], name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true
  236. end
  237. def deduplicate_conversations!
  238. remove_index_if_exists!(:conversations, 'index_conversations_on_uri')
  239. @prompt.say 'Deduplicating conversations…'
  240. ActiveRecord::Base.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|
  241. conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse
  242. ref_conversation = conversations.shift
  243. conversations.each do |other|
  244. merge_conversations!(ref_conversation, other)
  245. other.destroy
  246. end
  247. end
  248. @prompt.say 'Restoring conversations indexes…'
  249. ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
  250. end
  251. def deduplicate_custom_emojis!
  252. remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain')
  253. @prompt.say 'Deduplicating custom_emojis…'
  254. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row|
  255. emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse
  256. ref_emoji = emojis.shift
  257. emojis.each do |other|
  258. merge_custom_emojis!(ref_emoji, other)
  259. other.destroy
  260. end
  261. end
  262. @prompt.say 'Restoring custom_emojis indexes…'
  263. ActiveRecord::Base.connection.add_index :custom_emojis, ['shortcode', 'domain'], name: 'index_custom_emojis_on_shortcode_and_domain', unique: true
  264. end
  265. def deduplicate_custom_emoji_categories!
  266. remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name')
  267. @prompt.say 'Deduplicating custom_emoji_categories…'
  268. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row|
  269. categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse
  270. ref_category = categories.shift
  271. categories.each do |other|
  272. merge_custom_emoji_categories!(ref_category, other)
  273. other.destroy
  274. end
  275. end
  276. @prompt.say 'Restoring custom_emoji_categories indexes…'
  277. ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true
  278. end
  279. def deduplicate_domain_allows!
  280. remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain')
  281. @prompt.say 'Deduplicating domain_allows…'
  282. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
  283. DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  284. end
  285. @prompt.say 'Restoring domain_allows indexes…'
  286. ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true
  287. end
  288. def deduplicate_domain_blocks!
  289. remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
  290. @prompt.say 'Deduplicating domain_allows…'
  291. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
  292. domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
  293. reject_media = domain_blocks.any?(&:reject_media?)
  294. reject_reports = domain_blocks.any?(&:reject_reports?)
  295. reference_block = domain_blocks.shift
  296. private_comment = domain_blocks.reduce(reference_block.private_comment.presence) { |a, b| a || b.private_comment.presence }
  297. public_comment = domain_blocks.reduce(reference_block.public_comment.presence) { |a, b| a || b.public_comment.presence }
  298. reference_block.update!(reject_media: reject_media, reject_reports: reject_reports, private_comment: private_comment, public_comment: public_comment)
  299. domain_blocks.each(&:destroy)
  300. end
  301. @prompt.say 'Restoring domain_blocks indexes…'
  302. ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true
  303. end
  304. def deduplicate_unavailable_domains!
  305. return unless ActiveRecord::Base.connection.table_exists?(:unavailable_domains)
  306. remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain')
  307. @prompt.say 'Deduplicating unavailable_domains…'
  308. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
  309. UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  310. end
  311. @prompt.say 'Restoring domain_allows indexes…'
  312. ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
  313. end
  314. def deduplicate_email_domain_blocks!
  315. remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain')
  316. @prompt.say 'Deduplicating email_domain_blocks…'
  317. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
  318. domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a
  319. domain_blocks.drop(1).each(&:destroy)
  320. end
  321. @prompt.say 'Restoring email_domain_blocks indexes…'
  322. ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true
  323. end
  324. def deduplicate_media_attachments!
  325. remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode')
  326. @prompt.say 'Deduplicating media_attachments…'
  327. ActiveRecord::Base.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|
  328. MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil)
  329. end
  330. @prompt.say 'Restoring media_attachments indexes…'
  331. ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
  332. end
  333. def deduplicate_preview_cards!
  334. remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url')
  335. @prompt.say 'Deduplicating preview_cards…'
  336. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
  337. PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  338. end
  339. @prompt.say 'Restoring preview_cards indexes…'
  340. ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true
  341. end
  342. def deduplicate_statuses!
  343. remove_index_if_exists!(:statuses, 'index_statuses_on_uri')
  344. @prompt.say 'Deduplicating statuses…'
  345. ActiveRecord::Base.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|
  346. statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id)
  347. ref_status = statuses.shift
  348. statuses.each do |status|
  349. merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id
  350. status.destroy
  351. end
  352. end
  353. @prompt.say 'Restoring statuses indexes…'
  354. ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
  355. end
  356. def deduplicate_tags!
  357. remove_index_if_exists!(:tags, 'index_tags_on_name_lower')
  358. @prompt.say 'Deduplicating tags…'
  359. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
  360. tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) }
  361. ref_tag = tags.shift
  362. tags.each do |tag|
  363. merge_tags!(ref_tag, tag)
  364. tag.destroy
  365. end
  366. end
  367. @prompt.say 'Restoring tags indexes…'
  368. ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
  369. end
  370. def deduplicate_webauthn_credentials!
  371. return unless ActiveRecord::Base.connection.table_exists?(:webauthn_credentials)
  372. remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id')
  373. @prompt.say 'Deduplicating webauthn_credentials…'
  374. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row|
  375. WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
  376. end
  377. @prompt.say 'Restoring webauthn_credentials indexes…'
  378. ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
  379. end
  380. def deduplicate_local_accounts!(accounts)
  381. accounts = accounts.sort_by(&:id).reverse
  382. @prompt.warn "Multiple local accounts were found for username '#{accounts.first.username}'."
  383. @prompt.warn 'All those accounts are distinct accounts but only the most recently-created one is fully-functionnal.'
  384. accounts.each_with_index do |account, idx|
  385. @prompt.say '%2d. %s: created at: %s; updated at: %s; last logged in at: %s; statuses: %5d; last status at: %s' % [idx, account.username, account.created_at, account.updated_at, account.user&.last_sign_in_at&.to_s || 'N/A', account.account_stat&.statuses_count || 0, account.account_stat&.last_status_at || 'N/A']
  386. end
  387. @prompt.say 'Please chose the one to keep unchanged, other ones will be automatically renamed.'
  388. ref_id = @prompt.ask('Account to keep unchanged:') do |q|
  389. q.required true
  390. q.default 0
  391. q.convert :int
  392. end
  393. accounts.delete_at(ref_id)
  394. i = 0
  395. accounts.each do |account|
  396. i += 1
  397. username = account.username + "_#{i}"
  398. while Account.local.exists?(username: username)
  399. i += 1
  400. username = account.username + "_#{i}"
  401. end
  402. account.update!(username: username)
  403. end
  404. end
  405. def deduplicate_remote_accounts!(accounts)
  406. accounts = accounts.sort_by(&:updated_at).reverse
  407. reference_account = accounts.shift
  408. accounts.each do |other_account|
  409. if other_account.public_key == reference_account.public_key
  410. # The accounts definitely point to the same resource, so
  411. # it's safe to re-attribute content and relationships
  412. reference_account.merge_with!(other_account)
  413. end
  414. other_account.destroy
  415. end
  416. end
  417. def merge_conversations!(main_conv, duplicate_conv)
  418. owned_classes = [ConversationMute, AccountConversation]
  419. owned_classes.each do |klass|
  420. klass.where(conversation_id: duplicate_conv.id).find_each do |record|
  421. begin
  422. record.update_attribute(:account_id, main_conv.id)
  423. rescue ActiveRecord::RecordNotUnique
  424. next
  425. end
  426. end
  427. end
  428. end
  429. def merge_custom_emojis!(main_emoji, duplicate_emoji)
  430. owned_classes = [AnnouncementReaction]
  431. owned_classes.each do |klass|
  432. klass.where(custom_emoji_id: duplicate_emoji.id).update_all(custom_emoji_id: main_emoji.id)
  433. end
  434. end
  435. def merge_custom_emoji_categories!(main_category, duplicate_category)
  436. owned_classes = [CustomEmoji]
  437. owned_classes.each do |klass|
  438. klass.where(category_id: duplicate_category.id).update_all(category_id: main_category.id)
  439. end
  440. end
  441. def merge_statuses!(main_status, duplicate_status)
  442. owned_classes = [Favourite, Mention, Poll]
  443. owned_classes << Bookmark if ActiveRecord::Base.connection.table_exists?(:bookmarks)
  444. owned_classes.each do |klass|
  445. klass.where(status_id: duplicate_status.id).find_each do |record|
  446. begin
  447. record.update_attribute(:status_id, main_status.id)
  448. rescue ActiveRecord::RecordNotUnique
  449. next
  450. end
  451. end
  452. end
  453. StatusPin.where(account_id: main_status.account_id, status_id: duplicate_status.id).find_each do |record|
  454. begin
  455. record.update_attribute(:status_id, main_status.id)
  456. rescue ActiveRecord::RecordNotUnique
  457. next
  458. end
  459. end
  460. Status.where(in_reply_to_id: duplicate_status.id).find_each do |record|
  461. begin
  462. record.update_attribute(:in_reply_to_id, main_status.id)
  463. rescue ActiveRecord::RecordNotUnique
  464. next
  465. end
  466. end
  467. Status.where(reblog_of_id: duplicate_status.id).find_each do |record|
  468. begin
  469. record.update_attribute(:reblog_of_id, main_status.id)
  470. rescue ActiveRecord::RecordNotUnique
  471. next
  472. end
  473. end
  474. end
  475. def merge_tags!(main_tag, duplicate_tag)
  476. [FeaturedTag].each do |klass|
  477. klass.where(tag_id: duplicate_tag.id).find_each do |record|
  478. begin
  479. record.update_attribute(:tag_id, main_tag.id)
  480. rescue ActiveRecord::RecordNotUnique
  481. next
  482. end
  483. end
  484. end
  485. end
  486. def find_duplicate_accounts
  487. ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1")
  488. end
  489. def remove_index_if_exists!(table, name)
  490. ActiveRecord::Base.connection.remove_index(table, name: name)
  491. rescue ArgumentError
  492. nil
  493. rescue ActiveRecord::StatementInvalid
  494. nil
  495. end
  496. end
  497. end