# frozen_string_literal: true require 'set' require_relative 'base' module Mastodon::CLI class Accounts < Base option :all, type: :boolean desc 'rotate [USERNAME]', 'Generate and broadcast new keys' long_desc <<-LONG_DESC Generate and broadcast new RSA keys as part of security maintenance. With the --all option, all local accounts will be subject to the rotation. Otherwise, and by default, only a single account specified by the USERNAME argument will be processed. LONG_DESC def rotate(username = nil) if options[:all] processed = 0 delay = 0 scope = Account.local.without_suspended progress = create_progress_bar(scope.count) scope.find_in_batches do |accounts| accounts.each do |account| rotate_keys_for_account(account, delay) progress.increment processed += 1 end delay += 5.minutes end progress.finish say("OK, rotated keys for #{processed} accounts", :green) elsif username.present? rotate_keys_for_account(Account.find_local(username)) say('OK', :green) else fail_with_message 'No account(s) given' end end option :email, required: true option :confirmed, type: :boolean option :role option :reattach, type: :boolean option :force, type: :boolean option :approve, type: :boolean desc 'create USERNAME', 'Create a new user account' long_desc <<-LONG_DESC Create a new user account with a given USERNAME and an e-mail address provided with --email. With the --confirmed option, the confirmation e-mail will be skipped and the account will be active straight away. With the --role option, the role can be supplied. With the --reattach option, the new user will be reattached to a given existing username of an old account. If the old account is still in use by someone else, you can supply the --force option to delete the old record and reattach the username to the new account anyway. With the --approve option, the account will be approved. LONG_DESC def create(username) role_id = nil if options[:role] role = UserRole.find_by(name: options[:role]) fail_with_message 'Cannot find user role with that name' if role.nil? role_id = role.id end account = Account.new(username: username) password = SecureRandom.hex user = User.new(email: options[:email], password: password, agreement: true, role_id: role_id, confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true) if options[:reattach] account = Account.find_local(username) || Account.new(username: username) if account.user.present? && !options[:force] say('The chosen username is currently in use', :red) say('Use --force to reattach it anyway and delete the other user') return elsif account.user.present? DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) account = Account.new(username: username) end end account.suspended_at = nil user.account = account if user.save if options[:confirmed] user.confirmed_at = nil user.mark_email_as_confirmed! end user.approve! if options[:approve] say('OK', :green) say("New password: #{password}") else report_errors(user.errors) end end option :role option :remove_role, type: :boolean option :email option :confirm, type: :boolean option :enable, type: :boolean option :disable, type: :boolean option :disable_2fa, type: :boolean option :approve, type: :boolean option :reset_password, type: :boolean desc 'modify USERNAME', 'Modify a user account' long_desc <<-LONG_DESC Modify a user account. With the --role option, update the user's role. To remove the user's role, i.e. demote to normal user, use --remove-role. With the --email option, update the user's e-mail address. With the --confirm option, mark the user's e-mail as confirmed. With the --disable option, lock the user out of their account. The --enable option is the opposite. With the --approve option, the account will be approved, if it was previously not due to not having open registrations. With the --disable-2fa option, the two-factor authentication requirement for the user can be removed. With the --reset-password option, the user's password is replaced by a randomly-generated one, printed in the output. LONG_DESC def modify(username) user = Account.find_local(username)&.user fail_with_message 'No user with such username' if user.nil? if options[:role] role = UserRole.find_by(name: options[:role]) fail_with_message 'Cannot find user role with that name' if role.nil? user.role_id = role.id elsif options[:remove_role] user.role_id = nil end password = SecureRandom.hex if options[:reset_password] user.password = password if options[:reset_password] user.email = options[:email] if options[:email] user.disabled = false if options[:enable] user.disabled = true if options[:disable] user.approved = true if options[:approve] user.otp_required_for_login = false if options[:disable_2fa] if user.save user.confirm if options[:confirm] say('OK', :green) say("New password: #{password}") if options[:reset_password] else report_errors(user.errors) end end option :email option :dry_run, type: :boolean desc 'delete [USERNAME]', 'Delete a user' long_desc <<-LONG_DESC Remove a user account with a given USERNAME. With the --email option, the user is selected based on email rather than username. LONG_DESC def delete(username = nil) if username.present? && options[:email].present? fail_with_message 'Use username or --email, not both' elsif username.blank? && options[:email].blank? fail_with_message 'No username provided' end account = nil if username.present? account = Account.find_local(username) fail_with_message 'No user with such username' if account.nil? else account = Account.left_joins(:user).find_by(user: { email: options[:email] }) fail_with_message 'No user with such email' if account.nil? end say("Deleting user with #{account.statuses_count} statuses, this might take a while...#{dry_run_mode_suffix}") DeleteAccountService.new.call(account, reserve_email: false) unless dry_run? say("OK#{dry_run_mode_suffix}", :green) end option :force, type: :boolean, aliases: [:f], description: 'Override public key check' desc 'merge FROM TO', 'Merge two remote accounts into one' long_desc <<-LONG_DESC Merge two remote accounts specified by their username@domain into one, whereby the TO account is the one being merged into and kept, while the FROM one is removed. It is primarily meant to fix duplicates caused by other servers changing their domain. The command by default only works if both accounts have the same public key to prevent mistakes. To override this, use the --force. LONG_DESC def merge(from_acct, to_acct) username, domain = from_acct.split('@') from_account = Account.find_remote(username, domain) fail_with_message "No such account (#{from_acct})" if from_account.nil? || from_account.local? username, domain = to_acct.split('@') to_account = Account.find_remote(username, domain) fail_with_message "No such account (#{to_acct})" if to_account.nil? || to_account.local? if from_account.public_key != to_account.public_key && !options[:force] fail_with_message <<~ERROR Accounts don't have the same public key, might not be duplicates! Override with --force ERROR end to_account.merge_with!(from_account) from_account.destroy say('OK', :green) end desc 'fix-duplicates', 'Find duplicate remote accounts and merge them' option :dry_run, type: :boolean long_desc <<-LONG_DESC Merge known remote accounts sharing an ActivityPub actor identifier. Such duplicates can occur when a remote server admin misconfigures their domain configuration. LONG_DESC def fix_duplicates Account.remote.select(:uri, 'count(*)').group(:uri).having('count(*) > 1').pluck(:uri).each do |uri| say("Duplicates found for #{uri}") begin ActivityPub::FetchRemoteAccountService.new.call(uri) unless dry_run? rescue => e say("Error processing #{uri}: #{e}", :red) end end end desc 'backup USERNAME', 'Request a backup for a user' long_desc <<-LONG_DESC Request a new backup for an account with a given USERNAME. The backup will be created in Sidekiq asynchronously, and the user will receive an e-mail with a link to it once it's done. LONG_DESC def backup(username) account = Account.find_local(username) fail_with_message 'No user with such username' if account.nil? backup = account.user.backups.create! BackupWorker.perform_async(backup.id) say('OK', :green) end option :concurrency, type: :numeric, default: 5, aliases: [:c] option :dry_run, type: :boolean desc 'cull [DOMAIN...]', 'Remove remote accounts that no longer exist' long_desc <<-LONG_DESC Query every single remote account in the database to determine if it still exists on the origin server, and if it doesn't, remove it from the database. Accounts that have had confirmed activity within the last week are excluded from the checks. LONG_DESC def cull(*domains) skip_threshold = 7.days.ago skip_domains = Concurrent::Set.new query = Account.remote.where(protocol: :activitypub) query = query.where(domain: domains) unless domains.empty? processed, culled = parallelize_with_progress(query.partitioned) do |account| next if account.updated_at >= skip_threshold || (account.last_webfingered_at.present? && account.last_webfingered_at >= skip_threshold) || skip_domains.include?(account.domain) code = 0 begin code = Request.new(:head, account.uri).perform(&:code) rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Mastodon::PrivateNetworkAddressError skip_domains << account.domain end if [404, 410].include?(code) DeleteAccountService.new.call(account, reserve_username: false) unless dry_run? 1 else # Touch account even during dry run to avoid getting the account into the window again account.touch end end say("Visited #{processed} accounts, removed #{culled}#{dry_run_mode_suffix}", :green) unless skip_domains.empty? say('The following domains were not available during the check:', :yellow) skip_domains.each { |domain| say(" #{domain}") } end end option :all, type: :boolean option :domain option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, aliases: [:v] option :dry_run, type: :boolean desc 'refresh [USERNAMES]', 'Fetch remote user data and files' long_desc <<-LONG_DESC Fetch remote user data and files for one or multiple accounts. With the --all option, all remote accounts will be processed. Through the --domain option, this can be narrowed down to a specific domain only. Otherwise, remote accounts must be specified with space-separated USERNAMES. LONG_DESC def refresh(*usernames) if options[:domain] || options[:all] scope = Account.remote scope = scope.where(domain: options[:domain]) if options[:domain] processed, = parallelize_with_progress(scope) do |account| next if dry_run? account.reset_avatar! account.reset_header! account.save end say("Refreshed #{processed} accounts#{dry_run_mode_suffix}", :green, true) elsif !usernames.empty? usernames.each do |user| user, domain = user.split('@') account = Account.find_remote(user, domain) fail_with_message 'No such account' if account.nil? next if dry_run? begin account.reset_avatar! account.reset_header! account.save rescue Mastodon::UnexpectedResponseError say("Account failed: #{user}@#{domain}", :red) end end say("OK#{dry_run_mode_suffix}", :green) else fail_with_message 'No account(s) given' end end option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, aliases: [:v] desc 'follow USERNAME', 'Make all local accounts follow account specified by USERNAME' def follow(username) target_account = Account.find_local(username) fail_with_message 'No such account' if target_account.nil? processed, = parallelize_with_progress(Account.local.without_suspended) do |account| FollowService.new.call(account, target_account, bypass_limit: true) end say("OK, followed target from #{processed} accounts", :green) end option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, aliases: [:v] desc 'unfollow ACCT', 'Make all local accounts unfollow account specified by ACCT' def unfollow(acct) username, domain = acct.split('@') target_account = Account.find_remote(username, domain) fail_with_message 'No such account' if target_account.nil? processed, = parallelize_with_progress(target_account.followers.local) do |account| UnfollowService.new.call(account, target_account) end say("OK, unfollowed target from #{processed} accounts", :green) end option :follows, type: :boolean, default: false option :followers, type: :boolean, default: false desc 'reset-relationships USERNAME', 'Reset all follows and/or followers for a user' long_desc <<-LONG_DESC Reset all follows and/or followers for a user specified by USERNAME. With the --follows option, the command unfollows everyone that the account follows, and then re-follows the users that would be followed by a brand new account. With the --followers option, the command removes all followers of the account. LONG_DESC def reset_relationships(username) fail_with_message 'Please specify either --follows or --followers, or both' unless options[:follows] || options[:followers] account = Account.find_local(username) fail_with_message 'No such account' if account.nil? total = 0 total += account.following.reorder(nil).count if options[:follows] total += account.followers.reorder(nil).count if options[:followers] progress = create_progress_bar(total) processed = 0 if options[:follows] account.following.reorder(nil).find_each do |target_account| UnfollowService.new.call(account, target_account) rescue => e progress.log pastel.red("Error processing #{target_account.id}: #{e}") ensure progress.increment processed += 1 end BootstrapTimelineWorker.perform_async(account.id) end if options[:followers] account.followers.reorder(nil).find_each do |target_account| UnfollowService.new.call(target_account, account) rescue => e progress.log pastel.red("Error processing #{target_account.id}: #{e}") ensure progress.increment processed += 1 end end progress.finish say("Processed #{processed} relationships", :green, true) end option :number, type: :numeric, aliases: [:n] option :all, type: :boolean desc 'approve [USERNAME]', 'Approve pending accounts' long_desc <<~LONG_DESC When registrations require review from staff, approve pending accounts, either all of them with the --all option, or a specific number of them specified with the --number (-n) option, or only a single specific account identified by its username. LONG_DESC def approve(username = nil) fail_with_message 'Number must be positive' if options[:number]&.negative? if options[:all] User.pending.find_each(&:approve!) say('OK', :green) elsif options[:number]&.positive? User.pending.order(created_at: :asc).limit(options[:number]).each(&:approve!) say('OK', :green) elsif username.present? account = Account.find_local(username) fail_with_message 'No such account' if account.nil? account.user&.approve! say('OK', :green) end end option :concurrency, type: :numeric, default: 5, aliases: [:c] option :dry_run, type: :boolean desc 'prune', 'Prune remote accounts that never interacted with local users' long_desc <<-LONG_DESC Prune remote account that - follows no local accounts - is not followed by any local accounts - has no statuses on local - has not been mentioned - has not been favourited local posts - not muted/blocked by us LONG_DESC def prune query = Account.remote.where.not(actor_type: %i(Application Service)) query = query.where('NOT EXISTS (SELECT 1 FROM mentions WHERE account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM favourites WHERE account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM statuses WHERE account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM follows WHERE account_id = accounts.id OR target_account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM blocks WHERE account_id = accounts.id OR target_account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM mutes WHERE target_account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM reports WHERE target_account_id = accounts.id)') query = query.where('NOT EXISTS (SELECT 1 FROM follow_requests WHERE account_id = accounts.id OR target_account_id = accounts.id)') _, deleted = parallelize_with_progress(query) do |account| next if account.bot? || account.group? next if account.suspended? next if account.silenced? account.destroy unless dry_run? 1 end say("OK, pruned #{deleted} accounts#{dry_run_mode_suffix}", :green) end option :force, type: :boolean option :replay, type: :boolean option :target desc 'migrate USERNAME', 'Migrate a local user to another account' long_desc <<~LONG_DESC With --replay, replay the last migration of the specified account, in case some remote server may not have properly processed the associated `Move` activity. With --target, specify another account to migrate to. With --force, perform the migration even if the selected account redirects to a different account that the one specified. LONG_DESC def migrate(username) fail_with_message 'Use --replay or --target, not both' if options[:replay].present? && options[:target].present? fail_with_message 'Use either --replay or --target' if options[:replay].blank? && options[:target].blank? account = Account.find_local(username) fail_with_message "No such account: #{username}" if account.nil? migration = nil if options[:replay] migration = account.migrations.last fail_with_message 'The specified account has not performed any migration' if migration.nil? fail_with_message 'The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway' unless options[:force] || migration.target_account_id == account.moved_to_account_id end if options[:target] target_account = ResolveAccountService.new.call(options[:target]) fail_with_message "The specified target account could not be found: #{options[:target]}" if target_account.nil? fail_with_message 'The specified account is redirecting to a different target account. Use --force if you want to change the migration target' unless options[:force] || account.moved_to_account_id.nil? || account.moved_to_account_id == target_account.id begin migration = account.migrations.create!(acct: target_account.acct) rescue ActiveRecord::RecordInvalid => e fail_with_message "Error: #{e.message}" end end MoveService.new.call(migration) say("OK, migrated #{account.acct} to #{migration.target_account.acct}", :green) end private def report_errors(errors) message = errors.map do |error| <<~STRING Failure/Error: #{error.attribute} #{error.type} STRING end.join fail_with_message message end def rotate_keys_for_account(account, delay = 0) fail_with_message 'No such account' if account.nil? old_key = account.private_key new_key = OpenSSL::PKey::RSA.new(2048) account.update(private_key: new_key.to_pem, public_key: new_key.public_key.to_pem) ActivityPub::UpdateDistributionWorker.perform_in(delay, account.id, { 'sign_with' => old_key }) end end end