maintenance.rb 30 KB

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