about summary refs log tree commit diff
path: root/lib/mastodon/accounts_cli.rb
blob: a6532541e0c29121cd7dd010412a76df7909f1f1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
# frozen_string_literal: true

require 'set'
require_relative '../../config/boot'
require_relative '../../config/environment'
require_relative 'cli_helper'

module Mastodon
  class AccountsCLI < Thor
    include CLIHelper

    def self.exit_on_failure?
      true
    end

    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
        say('No account(s) given', :red)
        exit(1)
      end
    end

    option :email, required: true
    option :confirmed, type: :boolean
    option :role
    option :reattach, type: :boolean
    option :force, 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.
    LONG_DESC
    def create(username)
      role_id  = nil

      if options[:role]
        role = UserRole.find_by(name: options[:role])

        if role.nil?
          say('Cannot find user role with that name', :red)
          exit(1)
        end

        role_id = role.id
      end

      account  = Account.new(username: username)
      password = SecureRandom.hex
      user     = User.new(email: options[:email], password: password, agreement: true, approved: 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)
        end
      end

      account.suspended_at = nil
      user.account         = account

      if user.save
        if options[:confirmed]
          user.confirmed_at = nil
          user.confirm!
        end

        say('OK', :green)
        say("New password: #{password}")
      else
        user.errors.to_h.each do |key, error|
          say('Failure/Error: ', :red)
          say(key)
          say("    #{error}", :red)
        end

        exit(1)
      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

      if user.nil?
        say('No user with such username', :red)
        exit(1)
      end

      if options[:role]
        role = UserRole.find_by(name: options[:role])

        if role.nil?
          say('Cannot find user role with that name', :red)
          exit(1)
        end

        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]
      user.confirm if options[:confirm]

      if user.save
        say('OK', :green)
        say("New password: #{password}") if options[:reset_password]
      else
        user.errors.to_h.each do |key, error|
          say('Failure/Error: ', :red)
          say(key)
          say("    #{error}", :red)
        end

        exit(1)
      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?
        say('Use username or --email, not both', :red)
        exit(1)
      elsif username.blank? && options[:email].blank?
        say('No username provided', :red)
        exit(1)
      end

      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
      account = nil

      if username.present?
        account = Account.find_local(username)
        if account.nil?
          say('No user with such username', :red)
          exit(1)
        end
      else
        account = Account.left_joins(:user).find_by(user: { email: options[:email] })
        if account.nil?
          say('No user with such email', :red)
          exit(1)
        end
      end

      say("Deleting user with #{account.statuses_count} statuses, this might take a while...#{dry_run}")
      DeleteAccountService.new.call(account, reserve_email: false) unless options[:dry_run]
      say("OK#{dry_run}", :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)

      if from_account.nil? || from_account.local?
        say("No such account (#{from_acct})", :red)
        exit(1)
      end

      username, domain = to_acct.split('@')
      to_account = Account.find_remote(username, domain)

      if to_account.nil? || to_account.local?
        say("No such account (#{to_acct})", :red)
        exit(1)
      end

      if from_account.public_key != to_account.public_key && !options[:force]
        say("Accounts don't have the same public key, might not be duplicates!", :red)
        say('Override with --force', :red)
        exit(1)
      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 options[: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)

      if account.nil?
        say('No user with such username', :red)
        exit(1)
      end

      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
      dry_run        = options[:dry_run] ? ' (DRY RUN)' : ''
      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
          skip_domains << account.domain
        end

        if [404, 410].include?(code)
          DeleteAccountService.new.call(account, reserve_username: false) unless options[: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}", :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)
      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''

      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 options[:dry_run]

          account.reset_avatar!
          account.reset_header!
          account.save
        end

        say("Refreshed #{processed} accounts#{dry_run}", :green, true)
      elsif !usernames.empty?
        usernames.each do |user|
          user, domain = user.split('@')
          account = Account.find_remote(user, domain)

          if account.nil?
            say('No such account', :red)
            exit(1)
          end

          next if options[: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}", :green)
      else
        say('No account(s) given', :red)
        exit(1)
      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)

      if target_account.nil?
        say('No such account', :red)
        exit(1)
      end

      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)

      if target_account.nil?
        say('No such account', :red)
        exit(1)
      end

      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)
      unless options[:follows] || options[:followers]
        say('Please specify either --follows or --followers, or both', :red)
        exit(1)
      end

      account = Account.find_local(username)

      if account.nil?
        say('No such account', :red)
        exit(1)
      end

      total     = 0
      total    += Account.where(id: ::Follow.where(account: account).select(:target_account_id)).count if options[:follows]
      total    += Account.where(id: ::Follow.where(target_account: account).select(:account_id)).count if options[:followers]
      progress  = create_progress_bar(total)
      processed = 0

      if options[:follows]
        scope = Account.where(id: ::Follow.where(account: account).select(:target_account_id))

        scope.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]
        scope = Account.where(id: ::Follow.where(target_account: account).select(:account_id))

        scope.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)
      if options[:all]
        User.pending.find_each(&:approve!)
        say('OK', :green)
      elsif options[:number]
        User.pending.limit(options[:number]).each(&:approve!)
        say('OK', :green)
      elsif username.present?
        account = Account.find_local(username)

        if account.nil?
          say('No such account', :red)
          exit(1)
        end

        account.user&.approve!
        say('OK', :green)
      else
        exit(1)
      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
      dry_run = options[:dry_run] ? ' (dry run)' : ''

      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 options[:dry_run]
        1
      end

      say("OK, pruned #{deleted} accounts#{dry_run}", :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)
      if options[:replay].present? && options[:target].present?
        say('Use --replay or --target, not both', :red)
        exit(1)
      end

      if options[:replay].blank? && options[:target].blank?
        say('Use either --replay or --target', :red)
        exit(1)
      end

      account = Account.find_local(username)

      if account.nil?
        say("No such account: #{username}", :red)
        exit(1)
      end

      migration = nil

      if options[:replay]
        migration = account.migrations.last
        if migration.nil?
          say('The specified account has not performed any migration', :red)
          exit(1)
        end

        unless options[:force] || migration.target_account_id == account.moved_to_account_id
          say('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway', :red)
          exit(1)
        end
      end

      if options[:target]
        target_account = ResolveAccountService.new.call(options[:target])

        if target_account.nil?
          say("The specified target account could not be found: #{options[:target]}", :red)
          exit(1)
        end

        unless options[:force] || account.moved_to_account_id.nil? || account.moved_to_account_id == target_account.id
          say('The specified account is redirecting to a different target account. Use --force if you want to change the migration target', :red)
          exit(1)
        end

        begin
          migration = account.migrations.create!(acct: target_account.acct)
        rescue ActiveRecord::RecordInvalid => e
          say("Error: #{e.message}", :red)
          exit(1)
        end
      end

      MoveService.new.call(migration)

      say("OK, migrated #{account.acct} to #{migration.target_account.acct}", :green)
    end

    private

    def rotate_keys_for_account(account, delay = 0)
      if account.nil?
        say('No such account', :red)
        exit(1)
      end

      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