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
|
# frozen_string_literal: true
require 'set'
require_relative '../../config/boot'
require_relative '../../config/environment'
require_relative 'cli_helper'
module Mastodon
class AccountsCLI < Thor
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
Account.local.without_suspended.find_in_batches do |accounts|
accounts.each do |account|
rotate_keys_for_account(account, delay)
processed += 1
say('.', :green, false)
end
delay += 5.minutes
end
say
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, default: 'user'
option :reattach, type: :boolean
option :force, type: :boolean
desc 'create USERNAME', 'Create a new user'
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 one of "user", "admin" or "moderator"
can be supplied. Defaults to "user"
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)
account = Account.new(username: username)
password = SecureRandom.hex
user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
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?
account.user.destroy!
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 :email
option :confirm, type: :boolean
option :enable, type: :boolean
option :disable, type: :boolean
option :disable_2fa, type: :boolean
option :approve, type: :boolean
desc 'modify USERNAME', 'Modify a user'
long_desc <<-LONG_DESC
Modify a user account.
With the --role option, update the user's role to one of "user",
"moderator" or "admin".
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.
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]
user.admin = options[:role] == 'admin'
user.moderator = options[:role] == 'moderator'
end
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)
else
user.errors.to_h.each do |key, error|
say('Failure/Error: ', :red)
say(key)
say(' ' + error, :red)
end
exit(1)
end
end
desc 'delete USERNAME', 'Delete a user'
long_desc <<-LONG_DESC
Remove a user account with a given USERNAME.
LONG_DESC
def delete(username)
account = Account.find_local(username)
if account.nil?
say('No user with such username', :red)
exit(1)
end
say("Deleting user with #{account.statuses_count} statuses, this might take a while...")
SuspendAccountService.new.call(account, including_user: true)
say('OK', :green)
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 :dry_run, type: :boolean
desc 'cull', '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.
Domains that are unreachable are not checked.
With the --dry-run option, no deletes will actually be carried
out.
LONG_DESC
def cull
skip_threshold = 7.days.ago
culled = 0
dry_run_culled = []
skip_domains = Set.new
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
Account.remote.where(protocol: :activitypub).partitioned.find_each do |account|
next if account.updated_at >= skip_threshold || (account.last_webfingered_at.present? && account.last_webfingered_at >= skip_threshold)
code = 0
unless skip_domains.include?(account.domain)
begin
code = Request.new(:head, account.uri).perform(&:code)
rescue HTTP::ConnectionError
skip_domains << account.domain
rescue StandardError
next
end
end
if [404, 410].include?(code)
if options[:dry_run]
dry_run_culled << account.acct
else
SuspendAccountService.new.call(account, destroy: true)
end
culled += 1
say('+', :green, false)
else
account.touch # Touch account even during dry run to avoid getting the account into the window again
say('.', nil, false)
end
end
say
say("Removed #{culled} accounts. #{skip_domains.size} servers skipped#{dry_run}", skip_domains.empty? ? :green : :yellow)
unless skip_domains.empty?
say('The following servers were not available during the check:', :yellow)
skip_domains.each { |domain| say(' ' + domain) }
end
unless dry_run_culled.empty?
say('The following accounts would have been deleted:', :green)
dry_run_culled.each { |account| say(' ' + account) }
end
end
option :all, type: :boolean
option :domain
desc 'refresh [USERNAME]', '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, a single remote account must
be specified with USERNAME.
All processing is done in the background through Sidekiq.
LONG_DESC
def refresh(username = nil)
if options[:domain] || options[:all]
queued = 0
scope = Account.remote
scope = scope.where(domain: options[:domain]) if options[:domain]
scope.select(:id).reorder(nil).find_in_batches do |accounts|
Maintenance::RedownloadAccountMediaWorker.push_bulk(accounts.map(&:id))
queued += accounts.size
end
say("Scheduled refreshment of #{queued} accounts", :green, true)
elsif username.present?
username, domain = username.split('@')
account = Account.find_remote(username, domain)
if account.nil?
say('No such account', :red)
exit(1)
end
Maintenance::RedownloadAccountMediaWorker.perform_async(account.id)
say('OK', :green)
else
say('No account(s) given', :red)
exit(1)
end
end
desc 'follow ACCT', 'Make all local accounts follow account specified by ACCT'
long_desc <<-LONG_DESC
Make all local accounts follow an account specified by ACCT. ACCT can be
a simple username, in case of a local user. It can also be in the format
username@domain, in case of a remote user.
LONG_DESC
def follow(acct)
target_account = ResolveAccountService.new.call(acct)
processed = 0
failed = 0
if target_account.nil?
say("Target account (#{acct}) could not be resolved", :red)
exit(1)
end
Account.local.without_suspended.find_each do |account|
begin
FollowService.new.call(account, target_account)
processed += 1
say('.', :green, false)
rescue StandardError
failed += 1
say('.', :red, false)
end
end
say("OK, followed target from #{processed} accounts, skipped #{failed}", :green)
end
desc 'unfollow ACCT', 'Make all local accounts unfollow account specified by ACCT'
long_desc <<-LONG_DESC
Make all local accounts unfollow an account specified by ACCT. ACCT can be
a simple username, in case of a local user. It can also be in the format
username@domain, in case of a remote user.
LONG_DESC
def unfollow(acct)
target_account = Account.find_remote(*acct.split('@'))
processed = 0
failed = 0
if target_account.nil?
say("Target account (#{acct}) was not found", :red)
exit(1)
end
target_account.followers.local.find_each do |account|
begin
UnfollowService.new.call(account, target_account)
processed += 1
say('.', :green, false)
rescue StandardError
failed += 1
say('.', :red, false)
end
end
say("OK, unfollowed target from #{processed} accounts, skipped #{failed}", :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 user with such username', :red)
exit(1)
end
if options[:follows]
processed = 0
failed = 0
say("Unfollowing #{account.username}'s followees, this might take a while...")
Account.where(id: ::Follow.where(account: account).select(:target_account_id)).find_each do |target_account|
begin
UnfollowService.new.call(account, target_account)
processed += 1
say('.', :green, false)
rescue StandardError
failed += 1
say('.', :red, false)
end
end
BootstrapTimelineWorker.perform_async(account.id)
say("OK, unfollowed #{processed} followees, skipped #{failed}", :green)
end
if options[:followers]
processed = 0
failed = 0
say("Removing #{account.username}'s followers, this might take a while...")
Account.where(id: ::Follow.where(target_account: account).select(:account_id)).find_each do |target_account|
begin
UnfollowService.new.call(target_account, account)
processed += 1
say('.', :green, false)
rescue StandardError
failed += 1
say('.', :red, false)
end
end
say("OK, removed #{processed} followers, skipped #{failed}", :green)
end
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
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
|