Merge pull request #2242 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 00c222377d
th-downstream
Claire 1 year ago committed by GitHub
commit d907e79140
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -323,7 +323,7 @@ module.exports = {
'plugin:import/recommended', 'plugin:import/recommended',
'plugin:import/typescript', 'plugin:import/typescript',
'plugin:promise/recommended', 'plugin:promise/recommended',
'plugin:jsdoc/recommended', 'plugin:jsdoc/recommended-typescript',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
], ],

@ -53,6 +53,28 @@ Lint/UselessAccessModifier:
ContextCreatingMethods: ContextCreatingMethods:
- class_methods - class_methods
## Disable most Metrics/*Length cops
# Reason: those are often triggered and force significant refactors when this happend
# but the team feel they are not really improving the code quality.
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength
Metrics/BlockLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength
Metrics/ClassLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength
Metrics/MethodLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength
Metrics/ModuleLength:
Enabled: false
## End Disable Metrics/*Length cops
# Reason: Currently disabled in .rubocop_todo.yml # Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize
Metrics/AbcSize: Metrics/AbcSize:
@ -60,88 +82,12 @@ Metrics/AbcSize:
- 'lib/mastodon/cli/*.rb' - 'lib/mastodon/cli/*.rb'
- db/*migrate/**/* - db/*migrate/**/*
# Reason: Some functions cannot be broken up, but others may be refactor candidates
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength
Metrics/BlockLength:
CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
Exclude:
- 'config/routes.rb'
- 'lib/mastodon/cli/*.rb'
- 'lib/tasks/*.rake'
- 'app/models/concerns/account_associations.rb'
- 'app/models/concerns/account_interactions.rb'
- 'app/models/concerns/ldap_authenticable.rb'
- 'app/models/concerns/omniauthable.rb'
- 'app/models/concerns/pam_authenticable.rb'
- 'app/models/concerns/remotable.rb'
- 'app/services/suspend_account_service.rb'
- 'app/services/unsuspend_account_service.rb'
- 'app/views/accounts/show.rss.ruby'
- 'app/views/tags/show.rss.ruby'
- 'config/environments/development.rb'
- 'config/environments/production.rb'
- 'config/initializers/devise.rb'
- 'config/initializers/doorkeeper.rb'
- 'config/initializers/omniauth.rb'
- 'config/initializers/simple_form.rb'
- 'config/navigation.rb'
- 'config/routes.rb'
- 'config/routes/*.rb'
- 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb'
- 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb'
- 'lib/paperclip/gif_transcoder.rb'
# Reason: # Reason:
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting
Metrics/BlockNesting: Metrics/BlockNesting:
Exclude: Exclude:
- 'lib/mastodon/cli/*.rb' - 'lib/mastodon/cli/*.rb'
# Reason: Some Excluded files would be candidates for refactoring but not currently addressed
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength
Metrics/ClassLength:
CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
Exclude:
- 'lib/mastodon/cli/*.rb'
- 'app/controllers/admin/accounts_controller.rb'
- 'app/controllers/api/base_controller.rb'
- 'app/controllers/api/v1/admin/accounts_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/controllers/auth/registrations_controller.rb'
- 'app/controllers/auth/sessions_controller.rb'
- 'app/lib/activitypub/activity.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/activitypub/tag_manager.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/link_details_extractor.rb'
- 'app/lib/request.rb'
- 'app/lib/text_formatter.rb'
- 'app/lib/user_settings_decorator.rb'
- 'app/mailers/user_mailer.rb'
- 'app/models/account.rb'
- 'app/models/admin/account_action.rb'
- 'app/models/form/account_batch.rb'
- 'app/models/media_attachment.rb'
- 'app/models/status.rb'
- 'app/models/tag.rb'
- 'app/models/user.rb'
- 'app/serializers/activitypub/actor_serializer.rb'
- 'app/serializers/activitypub/note_serializer.rb'
- 'app/serializers/rest/status_serializer.rb'
- 'app/services/account_search_service.rb'
- 'app/services/activitypub/process_account_service.rb'
- 'app/services/activitypub/process_status_update_service.rb'
- 'app/services/backup_service.rb'
- 'app/services/bulk_import_service.rb'
- 'app/services/delete_account_service.rb'
- 'app/services/fan_out_on_write_service.rb'
- 'app/services/fetch_link_card_service.rb'
- 'app/services/import_service.rb'
- 'app/services/notify_service.rb'
- 'app/services/post_status_service.rb'
- 'app/services/update_status_service.rb'
- 'lib/paperclip/color_extractor.rb'
# Reason: Currently disabled in .rubocop_todo.yml # Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity # https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
@ -149,17 +95,10 @@ Metrics/CyclomaticComplexity:
- lib/mastodon/cli/*.rb - lib/mastodon/cli/*.rb
- db/*migrate/**/* - db/*migrate/**/*
# Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength
Metrics/MethodLength:
CountAsOne: [array, heredoc]
Exclude:
- 'lib/mastodon/cli/*.rb'
# Reason: # Reason:
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists
Metrics/ModuleLength: Metrics/ParameterLists:
CountAsOne: [array, heredoc] CountKeywordArgs: false
# Reason: Prevailing style is argument file paths # Reason: Prevailing style is argument file paths
# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath # https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath

@ -156,12 +156,6 @@ Metrics/AbcSize:
Exclude: Exclude:
- 'app/serializers/initial_state_serializer.rb' - 'app/serializers/initial_state_serializer.rb'
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
# AllowedMethods: refine
Metrics/BlockLength:
Exclude:
- 'app/models/concerns/status_safe_reblog_insert.rb'
# Configuration parameters: CountBlocks, Max. # Configuration parameters: CountBlocks, Max.
Metrics/BlockNesting: Metrics/BlockNesting:
Exclude: Exclude:
@ -171,28 +165,6 @@ Metrics/BlockNesting:
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 25 Max: 25
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Max: 58
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ModuleLength:
Exclude:
- 'app/controllers/concerns/signature_verification.rb'
- 'app/helpers/application_helper.rb'
- 'app/helpers/jsonld_helper.rb'
- 'app/models/concerns/account_interactions.rb'
- 'app/models/concerns/has_user_settings.rb'
- 'lib/sanitize_ext/sanitize_config.rb'
# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Exclude:
- 'app/models/concerns/account_interactions.rb'
- 'app/services/activitypub/fetch_remote_account_service.rb'
- 'app/services/activitypub/fetch_remote_actor_service.rb'
- 'app/services/activitypub/fetch_remote_status_service.rb'
# Configuration parameters: AllowedMethods, AllowedPatterns. # Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 28 Max: 28
@ -894,7 +866,6 @@ Rails/WhereExists:
- 'app/validators/vote_validator.rb' - 'app/validators/vote_validator.rb'
- 'app/workers/move_worker.rb' - 'app/workers/move_worker.rb'
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
- 'lib/mastodon/cli/email_domain_blocks.rb'
- 'lib/tasks/tests.rake' - 'lib/tasks/tests.rake'
- 'spec/controllers/api/v1/accounts/notes_controller_spec.rb' - 'spec/controllers/api/v1/accounts/notes_controller_spec.rb'
- 'spec/controllers/api/v1/tags_controller_spec.rb' - 'spec/controllers/api/v1/tags_controller_spec.rb'
@ -956,7 +927,6 @@ Style/FormatStringToken:
Exclude: Exclude:
- 'app/models/privacy_policy.rb' - 'app/models/privacy_policy.rb'
- 'config/initializers/devise.rb' - 'config/initializers/devise.rb'
- 'lib/mastodon/cli/maintenance.rb'
- 'lib/paperclip/color_extractor.rb' - 'lib/paperclip/color_extractor.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).

@ -59,7 +59,7 @@ gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2' gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar' gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.14' gem 'nokogiri', '~> 1.15'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.14' gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'

@ -439,8 +439,8 @@ GEM
net-protocol net-protocol
net-ssh (7.1.0) net-ssh (7.1.0)
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.14.3) nokogiri (1.15.2)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.2.8) nsa (0.2.8)
activesupport (>= 4.2, < 7) activesupport (>= 4.2, < 7)
@ -642,7 +642,7 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
semantic_range (3.0.0) semantic_range (3.0.0)
sidekiq (6.5.8) sidekiq (6.5.9)
connection_pool (>= 2.2.5, < 3) connection_pool (>= 2.2.5, < 3)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.5.0, < 5) redis (>= 4.5.0, < 5)
@ -829,7 +829,7 @@ DEPENDENCIES
mime-types (~> 3.4.1) mime-types (~> 3.4.1)
net-http (~> 0.3.2) net-http (~> 0.3.2)
net-ldap (~> 0.18) net-ldap (~> 0.18)
nokogiri (~> 1.14) nokogiri (~> 1.15)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.14) oj (~> 3.14)
omniauth (~> 1.9) omniauth (~> 1.9)

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Metrics/ModuleLength
module LanguagesHelper module LanguagesHelper
ISO_639_1 = { ISO_639_1 = {
aa: ['Afar', 'Afaraf'].freeze, aa: ['Afar', 'Afaraf'].freeze,

@ -13,7 +13,7 @@ import { registrationsOpen } from 'flavours/glitch/initial_state';
const mapStateToProps = (state, { accountId }) => ({ const mapStateToProps = (state, { accountId }) => ({
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], '/auth/sign_up'), signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({

@ -161,8 +161,9 @@ const makeMapStateToProps = () => {
}; };
const truncate = (str, num) => { const truncate = (str, num) => {
if (str.length > num) { const arr = Array.from(str);
return str.slice(0, num) + '…'; if (arr.length > num) {
return arr.slice(0, num).join('') + '…';
} else { } else {
return str; return str;
} }

@ -16,7 +16,7 @@ const SignInBanner = () => {
let signupButton; let signupButton;
const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], '/auth/sign_up')); const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up');
if (registrationsOpen) { if (registrationsOpen) {
signupButton = ( signupButton = (

@ -14,14 +14,15 @@ export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>;
const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits
/** /**
* @param {number} sourceNumber Number to convert to short number * @param sourceNumber Number to convert to short number
* @returns {ShortNumber} Calculated short number * @returns Calculated short number
* @example * @example
* shortNumber(5936); * shortNumber(5936);
* // => [5.936, 1000, 1] * // => [5.936, 1000, 1]
*/ */
export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits
export function toShortNumber(sourceNumber: number): ShortNumber { export function toShortNumber(sourceNumber: number): ShortNumber {
if (sourceNumber < DECIMAL_UNITS.THOUSAND) { if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
return [sourceNumber, DECIMAL_UNITS.ONE, 0]; return [sourceNumber, DECIMAL_UNITS.ONE, 0];
@ -45,9 +46,9 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
} }
/** /**
* @param {number} sourceNumber Original number that is shortened * @param sourceNumber Original number that is shortened
* @param {number} division The scale in which short number is displayed * @param division The scale in which short number is displayed
* @returns {number} Number that can be used for plurals when short form used * @returns Number that can be used for plurals when short form used
* @example * @example
* pluralReady(1793, DECIMAL_UNITS.THOUSAND) * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
* // => 1790 * // => 1790

@ -13,7 +13,7 @@ import { registrationsOpen } from 'mastodon/initial_state';
const mapStateToProps = (state, { accountId }) => ({ const mapStateToProps = (state, { accountId }) => ({
displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']),
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], '/auth/sign_up'), signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({

@ -166,8 +166,9 @@ const makeMapStateToProps = () => {
}; };
const truncate = (str, num) => { const truncate = (str, num) => {
if (str.length > num) { const arr = Array.from(str);
return str.slice(0, num) + '…'; if (arr.length > num) {
return arr.slice(0, num).join('') + '…';
} else { } else {
return str; return str;
} }

@ -17,7 +17,7 @@ const SignInBanner = () => {
let signupButton; let signupButton;
const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], '/auth/sign_up')); const signupUrl = useAppSelector((state) => state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up');
if (registrationsOpen) { if (registrationsOpen) {
signupButton = ( signupButton = (

@ -14,14 +14,15 @@ export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>;
const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10; const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10; const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits
/** /**
* @param {number} sourceNumber Number to convert to short number * @param sourceNumber Number to convert to short number
* @returns {ShortNumber} Calculated short number * @returns Calculated short number
* @example * @example
* shortNumber(5936); * shortNumber(5936);
* // => [5.936, 1000, 1] * // => [5.936, 1000, 1]
*/ */
export type ShortNumber = [number, DecimalUnits, 0 | 1]; // Array of: shorten number, unit of shorten number and maximum fraction digits
export function toShortNumber(sourceNumber: number): ShortNumber { export function toShortNumber(sourceNumber: number): ShortNumber {
if (sourceNumber < DECIMAL_UNITS.THOUSAND) { if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
return [sourceNumber, DECIMAL_UNITS.ONE, 0]; return [sourceNumber, DECIMAL_UNITS.ONE, 0];
@ -45,9 +46,9 @@ export function toShortNumber(sourceNumber: number): ShortNumber {
} }
/** /**
* @param {number} sourceNumber Original number that is shortened * @param sourceNumber Original number that is shortened
* @param {number} division The scale in which short number is displayed * @param division The scale in which short number is displayed
* @returns {number} Number that can be used for plurals when short form used * @returns Number that can be used for plurals when short form used
* @example * @example
* pluralReady(1793, DECIMAL_UNITS.THOUSAND) * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
* // => 1790 * // => 1790

@ -1,9 +0,0 @@
# frozen_string_literal: true
module Settings
module Extend
def settings
@settings ||= ScopedSettings.new(self)
end
end
end

@ -123,7 +123,7 @@ class Account < ApplicationRecord
scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) } scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) } scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
scope :popular, -> { order('account_stats.followers_count desc') } scope :popular, -> { order('account_stats.followers_count desc') }
scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomain(domain).select(:domain)) } scope :by_domain_and_subdomains, ->(domain) { where(domain: Instance.by_domain_and_subdomains(domain).select(:domain)) }
scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }

@ -22,7 +22,7 @@ class Instance < ApplicationRecord
end end
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
scope :by_domain_and_subdomain, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") } scope :by_domain_and_subdomains, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") }
def self.refresh def self.refresh
Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)

@ -19,7 +19,7 @@ class FetchResourceService < BaseService
private private
def process(url, terminal = false) def process(url, terminal: false)
@url = url @url = url
perform_request { |response| process_response(response, terminal) } perform_request { |response| process_response(response, terminal) }

@ -11,7 +11,7 @@ module.exports = (api) => {
modules: false, modules: false,
debug: false, debug: false,
include: [ include: [
'proposal-numeric-separator', 'transform-numeric-separator',
], ],
}; };
@ -24,8 +24,8 @@ module.exports = (api) => {
plugins: [ plugins: [
['react-intl', { messagesDir: './build/messages' }], ['react-intl', { messagesDir: './build/messages' }],
'preval', 'preval',
'@babel/plugin-proposal-optional-chaining', '@babel/plugin-transform-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator', '@babel/plugin-transform-nullish-coalescing-operator',
], ],
overrides: [ overrides: [
{ {

@ -113,12 +113,7 @@ module Mastodon::CLI
say('OK', :green) say('OK', :green)
say("New password: #{password}") say("New password: #{password}")
else else
user.errors.each do |error| report_errors(user.errors)
say('Failure/Error: ', :red)
say(error.attribute)
say(" #{error.type}", :red)
end
exit(1) exit(1)
end end
end end
@ -189,12 +184,7 @@ module Mastodon::CLI
say('OK', :green) say('OK', :green)
say("New password: #{password}") if options[:reset_password] say("New password: #{password}") if options[:reset_password]
else else
user.errors.each do |error| report_errors(user.errors)
say('Failure/Error: ', :red)
say(error.attribute)
say(" #{error.type}", :red)
end
exit(1) exit(1)
end end
end end
@ -217,7 +207,6 @@ module Mastodon::CLI
exit(1) exit(1)
end end
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
account = nil account = nil
if username.present? if username.present?
@ -234,9 +223,9 @@ module Mastodon::CLI
end end
end end
say("Deleting user with #{account.statuses_count} statuses, this might take a while...#{dry_run}") 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 options[:dry_run] DeleteAccountService.new.call(account, reserve_email: false) unless dry_run?
say("OK#{dry_run}", :green) say("OK#{dry_run_mode_suffix}", :green)
end end
option :force, type: :boolean, aliases: [:f], description: 'Override public key check' option :force, type: :boolean, aliases: [:f], description: 'Override public key check'
@ -291,7 +280,7 @@ module Mastodon::CLI
Account.remote.select(:uri, 'count(*)').group(:uri).having('count(*) > 1').pluck(:uri).each do |uri| Account.remote.select(:uri, 'count(*)').group(:uri).having('count(*) > 1').pluck(:uri).each do |uri|
say("Duplicates found for #{uri}") say("Duplicates found for #{uri}")
begin begin
ActivityPub::FetchRemoteAccountService.new.call(uri) unless options[:dry_run] ActivityPub::FetchRemoteAccountService.new.call(uri) unless dry_run?
rescue => e rescue => e
say("Error processing #{uri}: #{e}", :red) say("Error processing #{uri}: #{e}", :red)
end end
@ -332,7 +321,6 @@ module Mastodon::CLI
LONG_DESC LONG_DESC
def cull(*domains) def cull(*domains)
skip_threshold = 7.days.ago skip_threshold = 7.days.ago
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
skip_domains = Concurrent::Set.new skip_domains = Concurrent::Set.new
query = Account.remote.where(protocol: :activitypub) query = Account.remote.where(protocol: :activitypub)
@ -350,7 +338,7 @@ module Mastodon::CLI
end end
if [404, 410].include?(code) if [404, 410].include?(code)
DeleteAccountService.new.call(account, reserve_username: false) unless options[:dry_run] DeleteAccountService.new.call(account, reserve_username: false) unless dry_run?
1 1
else else
# Touch account even during dry run to avoid getting the account into the window again # Touch account even during dry run to avoid getting the account into the window again
@ -358,7 +346,7 @@ module Mastodon::CLI
end end
end end
say("Visited #{processed} accounts, removed #{culled}#{dry_run}", :green) say("Visited #{processed} accounts, removed #{culled}#{dry_run_mode_suffix}", :green)
unless skip_domains.empty? unless skip_domains.empty?
say('The following domains were not available during the check:', :yellow) say('The following domains were not available during the check:', :yellow)
@ -381,21 +369,19 @@ module Mastodon::CLI
specified with space-separated USERNAMES. specified with space-separated USERNAMES.
LONG_DESC LONG_DESC
def refresh(*usernames) def refresh(*usernames)
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
if options[:domain] || options[:all] if options[:domain] || options[:all]
scope = Account.remote scope = Account.remote
scope = scope.where(domain: options[:domain]) if options[:domain] scope = scope.where(domain: options[:domain]) if options[:domain]
processed, = parallelize_with_progress(scope) do |account| processed, = parallelize_with_progress(scope) do |account|
next if options[:dry_run] next if dry_run?
account.reset_avatar! account.reset_avatar!
account.reset_header! account.reset_header!
account.save account.save
end end
say("Refreshed #{processed} accounts#{dry_run}", :green, true) say("Refreshed #{processed} accounts#{dry_run_mode_suffix}", :green, true)
elsif !usernames.empty? elsif !usernames.empty?
usernames.each do |user| usernames.each do |user|
user, domain = user.split('@') user, domain = user.split('@')
@ -406,7 +392,7 @@ module Mastodon::CLI
exit(1) exit(1)
end end
next if options[:dry_run] next if dry_run?
begin begin
account.reset_avatar! account.reset_avatar!
@ -417,7 +403,7 @@ module Mastodon::CLI
end end
end end
say("OK#{dry_run}", :green) say("OK#{dry_run_mode_suffix}", :green)
else else
say('No account(s) given', :red) say('No account(s) given', :red)
exit(1) exit(1)
@ -568,8 +554,6 @@ module Mastodon::CLI
- not muted/blocked by us - not muted/blocked by us
LONG_DESC LONG_DESC
def prune def prune
dry_run = options[:dry_run] ? ' (dry run)' : ''
query = Account.remote.where.not(actor_type: %i(Application Service)) 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 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 favourites WHERE account_id = accounts.id)')
@ -585,11 +569,11 @@ module Mastodon::CLI
next if account.suspended? next if account.suspended?
next if account.silenced? next if account.silenced?
account.destroy unless options[:dry_run] account.destroy unless dry_run?
1 1
end end
say("OK, pruned #{deleted} accounts#{dry_run}", :green) say("OK, pruned #{deleted} accounts#{dry_run_mode_suffix}", :green)
end end
option :force, type: :boolean option :force, type: :boolean
@ -667,6 +651,14 @@ module Mastodon::CLI
private private
def report_errors(errors)
errors.each do |error|
say('Failure/Error: ', :red)
say(error.attribute)
say(" #{error.type}", :red)
end
end
def rotate_keys_for_account(account, delay = 0) def rotate_keys_for_account(account, delay = 0)
if account.nil? if account.nil?
say('No such account', :red) say('No such account', :red)

@ -34,7 +34,6 @@ module Mastodon::CLI
When the --purge-domain-blocks option is given, also purge matching domain blocks. When the --purge-domain-blocks option is given, also purge matching domain blocks.
LONG_DESC LONG_DESC
def purge(*domains) def purge(*domains)
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
domains = domains.map { |domain| TagManager.instance.normalize_domain(domain) } domains = domains.map { |domain| TagManager.instance.normalize_domain(domain) }
account_scope = Account.none account_scope = Account.none
domain_block_scope = DomainBlock.none domain_block_scope = DomainBlock.none
@ -79,23 +78,23 @@ module Mastodon::CLI
# Actually perform the deletions # Actually perform the deletions
processed, = parallelize_with_progress(account_scope) do |account| processed, = parallelize_with_progress(account_scope) do |account|
DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run] DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless dry_run?
end end
say("Removed #{processed} accounts#{dry_run}", :green) say("Removed #{processed} accounts#{dry_run_mode_suffix}", :green)
if options[:purge_domain_blocks] if options[:purge_domain_blocks]
domain_block_count = domain_block_scope.count domain_block_count = domain_block_scope.count
domain_block_scope.in_batches.destroy_all unless options[:dry_run] domain_block_scope.in_batches.destroy_all unless dry_run?
say("Removed #{domain_block_count} domain blocks#{dry_run}", :green) say("Removed #{domain_block_count} domain blocks#{dry_run_mode_suffix}", :green)
end end
custom_emojis_count = emoji_scope.count custom_emojis_count = emoji_scope.count
emoji_scope.in_batches.destroy_all unless options[:dry_run] emoji_scope.in_batches.destroy_all unless dry_run?
Instance.refresh unless options[:dry_run] Instance.refresh unless dry_run?
say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green) say("Removed #{custom_emojis_count} custom emojis#{dry_run_mode_suffix}", :green)
end end
option :concurrency, type: :numeric, default: 50, aliases: [:c] option :concurrency, type: :numeric, default: 50, aliases: [:c]

@ -39,7 +39,7 @@ module Mastodon::CLI
processed = 0 processed = 0
domains.each do |domain| domains.each do |domain|
if EmailDomainBlock.where(domain: domain).exists? if EmailDomainBlock.exists?(domain: domain)
say("#{domain} is already blocked.", :yellow) say("#{domain} is already blocked.", :yellow)
skipped += 1 skipped += 1
next next
@ -60,7 +60,7 @@ module Mastodon::CLI
(email_domain_block.other_domains || []).uniq.each do |hostname| (email_domain_block.other_domains || []).uniq.each do |hostname|
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block) another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block)
if EmailDomainBlock.where(domain: hostname).exists? if EmailDomainBlock.exists?(domain: hostname)
say("#{hostname} is already blocked.", :yellow) say("#{hostname} is already blocked.", :yellow)
skipped += 1 skipped += 1
next next

@ -18,14 +18,12 @@ module Mastodon::CLI
Otherwise, a single user specified by USERNAME. Otherwise, a single user specified by USERNAME.
LONG_DESC LONG_DESC
def build(username = nil) def build(username = nil)
dry_run = options[:dry_run] ? '(DRY RUN)' : ''
if options[:all] || username.nil? if options[:all] || username.nil?
processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account| processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account|
PrecomputeFeedService.new.call(account) unless options[:dry_run] PrecomputeFeedService.new.call(account) unless dry_run?
end end
say("Regenerated feeds for #{processed} accounts #{dry_run}", :green, true) say("Regenerated feeds for #{processed} accounts #{dry_run_mode_suffix}", :green, true)
elsif username.present? elsif username.present?
account = Account.find_local(username) account = Account.find_local(username)
@ -34,9 +32,9 @@ module Mastodon::CLI
exit(1) exit(1)
end end
PrecomputeFeedService.new.call(account) unless options[:dry_run] PrecomputeFeedService.new.call(account) unless dry_run?
say("OK #{dry_run}", :green, true) say("OK #{dry_run_mode_suffix}", :green, true)
else else
say('No account(s) given', :red) say('No account(s) given', :red)
exit(1) exit(1)

@ -15,6 +15,10 @@ module Mastodon::CLI
options[:dry_run] options[:dry_run]
end end
def dry_run_mode_suffix
dry_run? ? ' (DRY RUN)' : ''
end
def create_progress_bar(total = nil) def create_progress_bar(total = nil)
ProgressBar.create(total: total, format: '%c/%u |%b%i| %e') ProgressBar.create(total: total, format: '%c/%u |%b%i| %e')
end end

@ -94,7 +94,7 @@ module Mastodon::CLI
exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain
unless options[:dry_run] unless dry_run?
prompt.warn('This operation WILL NOT be reversible. It can also take a long time.') prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.') prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.') prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
@ -104,12 +104,11 @@ module Mastodon::CLI
inboxes = Account.inboxes inboxes = Account.inboxes
processed = 0 processed = 0
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
Setting.registrations_mode = 'none' unless options[:dry_run] Setting.registrations_mode = 'none' unless dry_run?
if inboxes.empty? if inboxes.empty?
Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless options[:dry_run] Account.local.without_suspended.in_batches.update_all(suspended_at: Time.now.utc, suspension_origin: :local) unless dry_run?
prompt.ok('It seems like your server has not federated with anything') prompt.ok('It seems like your server has not federated with anything')
prompt.ok('You can shut it down and delete it any time') prompt.ok('You can shut it down and delete it any time')
return return
@ -126,7 +125,7 @@ module Mastodon::CLI
json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(account)) json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(account))
unless options[:dry_run] unless dry_run?
ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url| ActivityPub::DeliveryWorker.push_bulk(inboxes, limit: 1_000) do |inbox_url|
[json, account.id, inbox_url] [json, account.id, inbox_url]
end end
@ -140,7 +139,7 @@ module Mastodon::CLI
Account.local.without_suspended.find_each { |account| delete_account.call(account) } Account.local.without_suspended.find_each { |account| delete_account.call(account) }
Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) } Account.local.suspended.joins(:deletion_request).find_each { |account| delete_account.call(account) }
prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}") prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run_mode_suffix}")
prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data') prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
rescue TTY::Reader::InputInterrupt rescue TTY::Reader::InputInterrupt
exit(1) exit(1)

@ -1,6 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'tty-prompt'
require_relative 'base' require_relative 'base'
module Mastodon::CLI module Mastodon::CLI
@ -134,25 +133,23 @@ module Mastodon::CLI
Mastodon has to be stopped to run this task, which will take a long time and may be destructive. Mastodon has to be stopped to run this task, which will take a long time and may be destructive.
LONG_DESC LONG_DESC
def fix_duplicates def fix_duplicates
@prompt = TTY::Prompt.new
if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION
@prompt.error 'Your version of the database schema is too old and is not supported by this script.' say 'Your version of the database schema is too old and is not supported by this script.', :red
@prompt.error 'Please update to at least Mastodon 3.0.0 before running this script.' say 'Please update to at least Mastodon 3.0.0 before running this script.', :red
exit(1) exit(1)
elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION
@prompt.warn 'Your version of the database schema is more recent than this script, this may cause unexpected errors.' say 'Your version of the database schema is more recent than this script, this may cause unexpected errors.', :yellow
exit(1) unless @prompt.yes?('Continue anyway? (Yes/No)') exit(1) unless yes?('Continue anyway? (Yes/No)')
end end
if Sidekiq::ProcessSet.new.any? if Sidekiq::ProcessSet.new.any?
@prompt.error 'It seems Sidekiq is running. All Mastodon processes need to be stopped when using this script.' say 'It seems Sidekiq is running. All Mastodon processes need to be stopped when using this script.', :red
exit(1) exit(1)
end end
@prompt.warn 'This task will take a long time to run and is potentially destructive.' say 'This task will take a long time to run and is potentially destructive.', :yellow
@prompt.warn 'Please make sure to stop Mastodon and have a backup.' say 'Please make sure to stop Mastodon and have a backup.', :yellow
exit(1) unless @prompt.yes?('Continue? (Yes/No)') exit(1) unless yes?('Continue? (Yes/No)')
deduplicate_users! deduplicate_users!
deduplicate_account_domain_blocks! deduplicate_account_domain_blocks!
@ -176,7 +173,7 @@ module Mastodon::CLI
Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) if ActiveRecord::Migrator.current_version >= 2020_12_06_004238 Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) if ActiveRecord::Migrator.current_version >= 2020_12_06_004238
Rails.cache.clear Rails.cache.clear
@prompt.say 'Finished!' say 'Finished!'
end end
private private
@ -184,7 +181,7 @@ module Mastodon::CLI
def deduplicate_accounts! def deduplicate_accounts!
remove_index_if_exists!(:accounts, 'index_accounts_on_username_and_domain_lower') remove_index_if_exists!(:accounts, 'index_accounts_on_username_and_domain_lower')
@prompt.say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.' say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.'
find_duplicate_accounts.each do |row| find_duplicate_accounts.each do |row|
accounts = Account.where(id: row['ids'].split(',')).to_a accounts = Account.where(id: row['ids'].split(',')).to_a
@ -196,14 +193,14 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring index_accounts_on_username_and_domain_lower…' say 'Restoring index_accounts_on_username_and_domain_lower…'
if ActiveRecord::Migrator.current_version < 2020_06_20_164023 if ActiveRecord::Migrator.current_version < 2020_06_20_164023
ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
else else
ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
end end
@prompt.say 'Reindexing textual indexes on accounts…' say 'Reindexing textual indexes on accounts…'
ActiveRecord::Base.connection.execute('REINDEX INDEX search_index;') ActiveRecord::Base.connection.execute('REINDEX INDEX search_index;')
ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_uri;') ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_uri;')
ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_url;') ActiveRecord::Base.connection.execute('REINDEX INDEX index_accounts_on_url;')
@ -215,19 +212,18 @@ module Mastodon::CLI
remove_index_if_exists!(:users, 'index_users_on_remember_token') remove_index_if_exists!(:users, 'index_users_on_remember_token')
remove_index_if_exists!(:users, 'index_users_on_reset_password_token') remove_index_if_exists!(:users, 'index_users_on_reset_password_token')
@prompt.say 'Deduplicating user records…' say 'Deduplicating user records…'
# Deduplicating email # Deduplicating email
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
ref_user = users.shift ref_user = users.shift
@prompt.warn "Multiple users registered with e-mail address #{ref_user.email}." say "Multiple users registered with e-mail address #{ref_user.email}.", :yellow
@prompt.warn "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}" say "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}", :yellow
@prompt.warn 'Please reach out to them and set another address with `tootctl account modify` or delete them.' say 'Please reach out to them and set another address with `tootctl account modify` or delete them.', :yellow
i = 0 users.each_with_index do |user, index|
users.each do |user| user.update!(email: "#{index} " + user.email)
user.update!(email: "#{i} " + user.email)
end end
end end
@ -235,7 +231,7 @@ module Mastodon::CLI
deduplicate_users_process_remember_token deduplicate_users_process_remember_token
deduplicate_users_process_password_token deduplicate_users_process_password_token
@prompt.say 'Restoring users indexes…' say 'Restoring users indexes…'
ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
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 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
@ -250,7 +246,7 @@ module Mastodon::CLI
def deduplicate_users_process_confirmation_token def deduplicate_users_process_confirmation_token
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| 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|
users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1) users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1)
@prompt.warn "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}" say "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}", :yellow
users.each do |user| users.each do |user|
user.update!(confirmation_token: nil) user.update!(confirmation_token: nil)
@ -262,7 +258,7 @@ module Mastodon::CLI
if ActiveRecord::Migrator.current_version < 2022_01_18_183010 if ActiveRecord::Migrator.current_version < 2022_01_18_183010
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| 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|
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1) users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
@prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}" say "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}", :yellow
users.each do |user| users.each do |user|
user.update!(remember_token: nil) user.update!(remember_token: nil)
@ -274,7 +270,7 @@ module Mastodon::CLI
def deduplicate_users_process_password_token def deduplicate_users_process_password_token
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| 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|
users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1) users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
@prompt.warn "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}" say "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}", :yellow
users.each do |user| users.each do |user|
user.update!(reset_password_token: nil) user.update!(reset_password_token: nil)
@ -285,12 +281,12 @@ module Mastodon::CLI
def deduplicate_account_domain_blocks! def deduplicate_account_domain_blocks!
remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain') remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain')
@prompt.say 'Removing duplicate account domain blocks…' say 'Removing duplicate account domain blocks…'
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| 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|
AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all
end end
@prompt.say 'Restoring account domain blocks indexes…' say 'Restoring account domain blocks indexes…'
ActiveRecord::Base.connection.add_index :account_domain_blocks, %w(account_id domain), name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true ActiveRecord::Base.connection.add_index :account_domain_blocks, %w(account_id domain), name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true
end end
@ -299,12 +295,12 @@ module Mastodon::CLI
remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username') remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username')
@prompt.say 'Removing duplicate account identity proofs…' say 'Removing duplicate account identity proofs…'
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| 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|
AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring account identity proofs indexes…' say 'Restoring account identity proofs indexes…'
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 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
end end
@ -313,19 +309,19 @@ module Mastodon::CLI
remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id') remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
@prompt.say 'Removing duplicate account identity proofs…' say 'Removing duplicate account identity proofs…'
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| 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|
AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring announcement_reactions indexes…' say 'Restoring announcement_reactions indexes…'
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 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
end end
def deduplicate_conversations! def deduplicate_conversations!
remove_index_if_exists!(:conversations, 'index_conversations_on_uri') remove_index_if_exists!(:conversations, 'index_conversations_on_uri')
@prompt.say 'Deduplicating conversations…' say 'Deduplicating conversations…'
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| 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|
conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse
@ -337,7 +333,7 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring conversations indexes…' say 'Restoring conversations indexes…'
if ActiveRecord::Migrator.current_version < 2022_03_07_083603 if ActiveRecord::Migrator.current_version < 2022_03_07_083603
ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
else else
@ -348,7 +344,7 @@ module Mastodon::CLI
def deduplicate_custom_emojis! def deduplicate_custom_emojis!
remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain') remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain')
@prompt.say 'Deduplicating custom_emojis…' say 'Deduplicating custom_emojis…'
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| 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|
emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse
@ -360,14 +356,14 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring custom_emojis indexes…' say 'Restoring custom_emojis indexes…'
ActiveRecord::Base.connection.add_index :custom_emojis, %w(shortcode domain), name: 'index_custom_emojis_on_shortcode_and_domain', unique: true ActiveRecord::Base.connection.add_index :custom_emojis, %w(shortcode domain), name: 'index_custom_emojis_on_shortcode_and_domain', unique: true
end end
def deduplicate_custom_emoji_categories! def deduplicate_custom_emoji_categories!
remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name') remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name')
@prompt.say 'Deduplicating custom_emoji_categories…' say 'Deduplicating custom_emoji_categories…'
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| 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|
categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse
@ -379,26 +375,26 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring custom_emoji_categories indexes…' say 'Restoring custom_emoji_categories indexes…'
ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true
end end
def deduplicate_domain_allows! def deduplicate_domain_allows!
remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain') remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain')
@prompt.say 'Deduplicating domain_allows…' say 'Deduplicating domain_allows…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring domain_allows indexes…' say 'Restoring domain_allows indexes…'
ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true
end end
def deduplicate_domain_blocks! def deduplicate_domain_blocks!
remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain') remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
@prompt.say 'Deduplicating domain_allows…' say 'Deduplicating domain_allows…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
@ -415,7 +411,7 @@ module Mastodon::CLI
domain_blocks.each(&:destroy) domain_blocks.each(&:destroy)
end end
@prompt.say 'Restoring domain_blocks indexes…' say 'Restoring domain_blocks indexes…'
ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true
end end
@ -424,37 +420,37 @@ module Mastodon::CLI
remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain') remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain')
@prompt.say 'Deduplicating unavailable_domains…' say 'Deduplicating unavailable_domains…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring domain_allows indexes…' say 'Restoring domain_allows indexes…'
ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
end end
def deduplicate_email_domain_blocks! def deduplicate_email_domain_blocks!
remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain') remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain')
@prompt.say 'Deduplicating email_domain_blocks…' say 'Deduplicating email_domain_blocks…'
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| 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|
domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a
domain_blocks.drop(1).each(&:destroy) domain_blocks.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring email_domain_blocks indexes…' say 'Restoring email_domain_blocks indexes…'
ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true
end end
def deduplicate_media_attachments! def deduplicate_media_attachments!
remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode') remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode')
@prompt.say 'Deduplicating media_attachments…' say 'Deduplicating media_attachments…'
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| 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|
MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil) MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil)
end end
@prompt.say 'Restoring media_attachments indexes…' say 'Restoring media_attachments indexes…'
if ActiveRecord::Migrator.current_version < 2022_03_10_060626 if ActiveRecord::Migrator.current_version < 2022_03_10_060626
ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
else else
@ -465,19 +461,19 @@ module Mastodon::CLI
def deduplicate_preview_cards! def deduplicate_preview_cards!
remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url') remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url')
@prompt.say 'Deduplicating preview_cards…' say 'Deduplicating preview_cards…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring preview_cards indexes…' say 'Restoring preview_cards indexes…'
ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true
end end
def deduplicate_statuses! def deduplicate_statuses!
remove_index_if_exists!(:statuses, 'index_statuses_on_uri') remove_index_if_exists!(:statuses, 'index_statuses_on_uri')
@prompt.say 'Deduplicating statuses…' say 'Deduplicating statuses…'
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| 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|
statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id) statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id)
ref_status = statuses.shift ref_status = statuses.shift
@ -487,7 +483,7 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring statuses indexes…' say 'Restoring statuses indexes…'
if ActiveRecord::Migrator.current_version < 2022_03_10_060706 if ActiveRecord::Migrator.current_version < 2022_03_10_060706
ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
else else
@ -499,7 +495,7 @@ module Mastodon::CLI
remove_index_if_exists!(:tags, 'index_tags_on_name_lower') remove_index_if_exists!(:tags, 'index_tags_on_name_lower')
remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree') remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree')
@prompt.say 'Deduplicating tags…' say 'Deduplicating tags…'
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| 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|
tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) } tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) }
ref_tag = tags.shift ref_tag = tags.shift
@ -509,7 +505,7 @@ module Mastodon::CLI
end end
end end
@prompt.say 'Restoring tags indexes…' say 'Restoring tags indexes…'
if ActiveRecord::Migrator.current_version < 2021_04_21_121431 if ActiveRecord::Migrator.current_version < 2021_04_21_121431
ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
else else
@ -522,12 +518,12 @@ module Mastodon::CLI
remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id') remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id')
@prompt.say 'Deduplicating webauthn_credentials…' say 'Deduplicating webauthn_credentials…'
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| 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|
WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring webauthn_credentials indexes…' say 'Restoring webauthn_credentials indexes…'
ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
end end
@ -536,28 +532,37 @@ module Mastodon::CLI
remove_index_if_exists!(:webhooks, 'index_webhooks_on_url') remove_index_if_exists!(:webhooks, 'index_webhooks_on_url')
@prompt.say 'Deduplicating webhooks…' say 'Deduplicating webhooks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row| ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row|
Webhooks.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy) Webhooks.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end end
@prompt.say 'Restoring webhooks indexes…' say 'Restoring webhooks indexes…'
ActiveRecord::Base.connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true ActiveRecord::Base.connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true
end end
def deduplicate_local_accounts!(accounts) def deduplicate_local_accounts!(accounts)
accounts = accounts.sort_by(&:id).reverse accounts = accounts.sort_by(&:id).reverse
@prompt.warn "Multiple local accounts were found for username '#{accounts.first.username}'." say "Multiple local accounts were found for username '#{accounts.first.username}'.", :yellow
@prompt.warn 'All those accounts are distinct accounts but only the most recently-created one is fully-functional.' say 'All those accounts are distinct accounts but only the most recently-created one is fully-functional.', :yellow
accounts.each_with_index do |account, idx| accounts.each_with_index do |account, idx|
@prompt.say format('%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') say format(
'%<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',
index: idx,
username: account.username,
created_at: account.created_at,
updated_at: account.updated_at,
last_log_in_at: account.user&.last_sign_in_at&.to_s || 'N/A',
status_count: account.account_stat&.statuses_count || 0,
last_status_at: account.account_stat&.last_status_at || 'N/A'
)
end end
@prompt.say 'Please chose the one to keep unchanged, other ones will be automatically renamed.' say 'Please chose the one to keep unchanged, other ones will be automatically renamed.'
ref_id = @prompt.ask('Account to keep unchanged:') do |q| ref_id = ask('Account to keep unchanged:') do |q|
q.required true q.required true
q.default 0 q.default 0
q.convert :int q.convert :int

@ -35,12 +35,12 @@ module Mastodon::CLI
say('--prune-profiles and --remove-headers should not be specified simultaneously', :red, true) say('--prune-profiles and --remove-headers should not be specified simultaneously', :red, true)
exit(1) exit(1)
end end
if options[:include_follows] && !(options[:prune_profiles] || options[:remove_headers]) if options[:include_follows] && !(options[:prune_profiles] || options[:remove_headers])
say('--include-follows can only be used with --prune-profiles or --remove-headers', :red, true) say('--include-follows can only be used with --prune-profiles or --remove-headers', :red, true)
exit(1) exit(1)
end end
time_ago = options[:days].days.ago time_ago = options[:days].days.ago
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
if options[:prune_profiles] || options[:remove_headers] if options[:prune_profiles] || options[:remove_headers]
processed, aggregate = parallelize_with_progress(Account.remote.where({ last_webfingered_at: ..time_ago, updated_at: ..time_ago })) do |account| processed, aggregate = parallelize_with_progress(Account.remote.where({ last_webfingered_at: ..time_ago, updated_at: ..time_ago })) do |account|
@ -51,7 +51,7 @@ module Mastodon::CLI
size = (account.header_file_size || 0) size = (account.header_file_size || 0)
size += (account.avatar_file_size || 0) if options[:prune_profiles] size += (account.avatar_file_size || 0) if options[:prune_profiles]
unless options[:dry_run] unless dry_run?
account.header.destroy account.header.destroy
account.avatar.destroy if options[:prune_profiles] account.avatar.destroy if options[:prune_profiles]
account.save! account.save!
@ -60,7 +60,7 @@ module Mastodon::CLI
size size
end end
say("Visited #{processed} accounts and removed profile media totaling #{number_to_human_size(aggregate)}#{dry_run}", :green, true) say("Visited #{processed} accounts and removed profile media totaling #{number_to_human_size(aggregate)}#{dry_run_mode_suffix}", :green, true)
end end
unless options[:prune_profiles] || options[:remove_headers] unless options[:prune_profiles] || options[:remove_headers]
@ -69,7 +69,7 @@ module Mastodon::CLI
size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0) size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
unless options[:dry_run] unless dry_run?
media_attachment.file.destroy media_attachment.file.destroy
media_attachment.thumbnail.destroy media_attachment.thumbnail.destroy
media_attachment.save media_attachment.save
@ -78,7 +78,7 @@ module Mastodon::CLI
size size
end end
say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true) say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run_mode_suffix}", :green, true)
end end
end end
@ -97,7 +97,6 @@ module Mastodon::CLI
progress = create_progress_bar(nil) progress = create_progress_bar(nil)
reclaimed_bytes = 0 reclaimed_bytes = 0
removed = 0 removed = 0
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
prefix = options[:prefix] prefix = options[:prefix]
case Paperclip::Attachment.default_options[:storage] case Paperclip::Attachment.default_options[:storage]
@ -123,7 +122,7 @@ module Mastodon::CLI
record_map = preload_records_from_mixed_objects(objects) record_map = preload_records_from_mixed_objects(objects)
objects.each do |object| objects.each do |object|
object.acl.put(acl: s3_permissions) if options[:fix_permissions] && !options[:dry_run] object.acl.put(acl: s3_permissions) if options[:fix_permissions] && !dry_run?
path_segments = object.key.split('/') path_segments = object.key.split('/')
path_segments.delete('cache') path_segments.delete('cache')
@ -145,7 +144,7 @@ module Mastodon::CLI
next unless attachment.blank? || !attachment.variant?(file_name) next unless attachment.blank? || !attachment.variant?(file_name)
begin begin
object.delete unless options[:dry_run] object.delete unless dry_run?
reclaimed_bytes += object.size reclaimed_bytes += object.size
removed += 1 removed += 1
@ -194,7 +193,7 @@ module Mastodon::CLI
begin begin
size = File.size(path) size = File.size(path)
unless options[:dry_run] unless dry_run?
File.delete(path) File.delete(path)
begin begin
FileUtils.rmdir(File.dirname(path), parents: true) FileUtils.rmdir(File.dirname(path), parents: true)
@ -216,7 +215,7 @@ module Mastodon::CLI
progress.total = progress.progress progress.total = progress.progress
progress.finish progress.finish
say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true) say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run_mode_suffix}", :green, true)
end end
option :account, type: :string option :account, type: :string
@ -246,8 +245,6 @@ module Mastodon::CLI
not be re-downloaded. To force re-download of every URL, use --force. not be re-downloaded. To force re-download of every URL, use --force.
DESC DESC
def refresh def refresh
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
if options[:status] if options[:status]
scope = MediaAttachment.where(status_id: options[:status]) scope = MediaAttachment.where(status_id: options[:status])
elsif options[:account] elsif options[:account]
@ -274,7 +271,7 @@ module Mastodon::CLI
next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?) next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
next if DomainBlock.reject_media?(media_attachment.account.domain) next if DomainBlock.reject_media?(media_attachment.account.domain)
unless options[:dry_run] unless dry_run?
media_attachment.reset_file! media_attachment.reset_file!
media_attachment.reset_thumbnail! media_attachment.reset_thumbnail!
media_attachment.save media_attachment.save
@ -283,7 +280,7 @@ module Mastodon::CLI
media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0) media_attachment.file_file_size + (media_attachment.thumbnail_file_size || 0)
end end
say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true) say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run_mode_suffix}", :green, true)
end end
desc 'usage', 'Calculate disk space consumed by Mastodon' desc 'usage', 'Calculate disk space consumed by Mastodon'

@ -27,7 +27,6 @@ module Mastodon::CLI
DESC DESC
def remove def remove
time_ago = options[:days].days.ago time_ago = options[:days].days.ago
dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
link = options[:link] ? 'link-type ' : '' link = options[:link] ? 'link-type ' : ''
scope = PreviewCard.cached scope = PreviewCard.cached
scope = scope.where(type: :link) if options[:link] scope = scope.where(type: :link) if options[:link]
@ -38,7 +37,7 @@ module Mastodon::CLI
size = preview_card.image_file_size size = preview_card.image_file_size
unless options[:dry_run] unless dry_run?
preview_card.image.destroy preview_card.image.destroy
preview_card.save preview_card.save
end end
@ -46,7 +45,7 @@ module Mastodon::CLI
size size
end end
say("Removed #{processed} #{link}preview cards (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true) say("Removed #{processed} #{link}preview cards (approx. #{number_to_human_size(aggregate)})#{dry_run_mode_suffix}", :green, true)
end end
end end
end end

@ -17,7 +17,6 @@ module Mastodon::CLI
LONG_DESC LONG_DESC
def storage_schema def storage_schema
progress = create_progress_bar(nil) progress = create_progress_bar(nil)
dry_run = dry_run? ? ' (DRY RUN)' : ''
records = 0 records = 0
klasses = [ klasses = [
@ -69,7 +68,7 @@ module Mastodon::CLI
progress.total = progress.progress progress.total = progress.progress
progress.finish progress.finish
say("Upgraded storage schema of #{records} records#{dry_run}", :green, true) say("Upgraded storage schema of #{records} records#{dry_run_mode_suffix}", :green, true)
end end
private private

@ -2,7 +2,7 @@
"name": "@mastodon/mastodon", "name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": { "engines": {
"node": ">=14" "node": ">=16"
}, },
"scripts": { "scripts": {
"postversion": "git push --tags", "postversion": "git push --tags",
@ -26,14 +26,14 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.21.8", "@babel/core": "^7.22.1",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.3",
"@babel/plugin-transform-react-inline-elements": "^7.21.0", "@babel/plugin-transform-react-inline-elements": "^7.21.0",
"@babel/plugin-transform-runtime": "^7.21.4", "@babel/plugin-transform-runtime": "^7.22.4",
"@babel/preset-env": "^7.21.5", "@babel/preset-env": "^7.22.4",
"@babel/preset-react": "^7.18.6", "@babel/preset-react": "^7.22.3",
"@babel/preset-typescript": "^7.21.5", "@babel/preset-typescript": "^7.21.5",
"@babel/runtime": "^7.21.5", "@babel/runtime": "^7.22.3",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^2.1.1", "@github/webauthn-json": "^2.1.1",
"@rails/ujs": "^6.1.7", "@rails/ujs": "^6.1.7",
@ -76,7 +76,7 @@
"intl-messageformat": "^2.2.0", "intl-messageformat": "^2.2.0",
"intl-relativeformat": "^6.4.3", "intl-relativeformat": "^6.4.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsdom": "^22.0.0", "jsdom": "^22.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mark-loader": "^0.1.6", "mark-loader": "^0.1.6",
"marky": "^1.2.5", "marky": "^1.2.5",
@ -86,7 +86,7 @@
"path-complete-extname": "^1.0.0", "path-complete-extname": "^1.0.0",
"pg": "^8.5.0", "pg": "^8.5.0",
"pg-connection-string": "^2.6.0", "pg-connection-string": "^2.6.0",
"postcss": "^8.4.23", "postcss": "^8.4.24",
"postcss-loader": "^4.3.0", "postcss-loader": "^4.3.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"punycode": "^2.3.0", "punycode": "^2.3.0",
@ -133,18 +133,18 @@
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",
"webpack-merge": "^5.9.0", "webpack-merge": "^5.9.0",
"wicg-inert": "^3.1.2", "wicg-inert": "^3.1.2",
"workbox-expiration": "^6.5.4", "workbox-expiration": "^6.6.0",
"workbox-precaching": "^6.5.4", "workbox-precaching": "^6.6.0",
"workbox-routing": "^6.5.4", "workbox-routing": "^6.6.0",
"workbox-strategies": "^6.5.4", "workbox-strategies": "^6.6.0",
"workbox-webpack-plugin": "^6.5.4", "workbox-webpack-plugin": "^6.6.0",
"workbox-window": "^6.5.4", "workbox-window": "^6.6.0",
"ws": "^8.12.1" "ws": "^8.12.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@types/babel__core": "^7.20.0", "@types/babel__core": "^7.20.1",
"@types/emoji-mart": "^3.0.9", "@types/emoji-mart": "^3.0.9",
"@types/escape-html": "^1.0.2", "@types/escape-html": "^1.0.2",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
@ -152,18 +152,18 @@
"@types/intl": "^1.2.0", "@types/intl": "^1.2.0",
"@types/jest": "^29.5.1", "@types/jest": "^29.5.1",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.14.194", "@types/lodash": "^4.14.195",
"@types/npmlog": "^4.1.4", "@types/npmlog": "^4.1.4",
"@types/object-assign": "^4.0.30", "@types/object-assign": "^4.0.30",
"@types/pg": "^8.6.6", "@types/pg": "^8.6.6",
"@types/prop-types": "^15.7.5", "@types/prop-types": "^15.7.5",
"@types/punycode": "^2.1.0", "@types/punycode": "^2.1.0",
"@types/react": "^18.0.26", "@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"@types/react-helmet": "^6.1.6", "@types/react-helmet": "^6.1.6",
"@types/react-immutable-proptypes": "^2.1.0", "@types/react-immutable-proptypes": "^2.1.0",
"@types/react-intl": "2.3.18", "@types/react-intl": "2.3.18",
"@types/react-motion": "^0.0.33", "@types/react-motion": "^0.0.34",
"@types/react-overlays": "^3.1.0", "@types/react-overlays": "^3.1.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/react-select": "^5.0.1", "@types/react-select": "^5.0.1",
@ -177,15 +177,15 @@
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"@types/webpack": "^4.41.33", "@types/webpack": "^4.41.33",
"@types/yargs": "^17.0.24", "@types/yargs": "^17.0.24",
"@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.59.7", "@typescript-eslint/parser": "^5.59.8",
"babel-jest": "^29.5.0", "babel-jest": "^29.5.0",
"eslint": "^8.41.0", "eslint": "^8.41.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.5.5", "eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-formatjs": "^4.10.1", "eslint-plugin-formatjs": "^4.10.1",
"eslint-plugin-import": "~2.27.5", "eslint-plugin-import": "~2.27.5",
"eslint-plugin-jsdoc": "^44.2.5", "eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsx-a11y": "~6.7.1", "eslint-plugin-jsx-a11y": "~6.7.1",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "~6.1.1", "eslint-plugin-promise": "~6.1.1",

@ -18,4 +18,37 @@ describe Admin::IpBlocksController do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET #new' do
it 'returns http success and renders view' do
get :new
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
describe 'POST #create' do
context 'with valid data' do
it 'creates a new ip block and redirects' do
expect do
post :create, params: { ip_block: { ip: '1.1.1.1', severity: 'no_access', expires_in: 1.day.to_i.to_s } }
end.to change(IpBlock, :count).by(1)
expect(response).to redirect_to(admin_ip_blocks_path)
expect(flash.notice).to match(I18n.t('admin.ip_blocks.created_msg'))
end
end
context 'with invalid data' do
it 'does not create new a ip block and renders new' do
expect do
post :create, params: { ip_block: { ip: '1.1.1.1' } }
end.to_not change(IpBlock, :count)
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
end
end end

@ -18,4 +18,42 @@ describe Admin::RelaysController do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET #new' do
it 'returns http success and renders view' do
get :new
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
describe 'POST #create' do
context 'with valid data' do
let(:inbox_url) { 'https://example.com/inbox' }
before do
stub_request(:post, inbox_url).to_return(status: 200)
end
it 'creates a new relay and redirects' do
expect do
post :create, params: { relay: { inbox_url: inbox_url } }
end.to change(Relay, :count).by(1)
expect(response).to redirect_to(admin_relays_path)
end
end
context 'with invalid data' do
it 'does not create new a relay and renders new' do
expect do
post :create, params: { relay: { inbox_url: 'invalid' } }
end.to_not change(Relay, :count)
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
end
end end

@ -18,4 +18,68 @@ describe Admin::RulesController do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET #edit' do
let(:rule) { Fabricate(:rule) }
it 'returns http success and renders edit' do
get :edit, params: { id: rule.id }
expect(response).to have_http_status(:success)
expect(response).to render_template(:edit)
end
end
describe 'POST #create' do
context 'with valid data' do
it 'creates a new rule and redirects' do
expect do
post :create, params: { rule: { text: 'The rule text.' } }
end.to change(Rule, :count).by(1)
expect(response).to redirect_to(admin_rules_path)
end
end
context 'with invalid data' do
it 'does creates a new rule and renders index' do
expect do
post :create, params: { rule: { text: '' } }
end.to_not change(Rule, :count)
expect(response).to render_template(:index)
end
end
end
describe 'PUT #update' do
let(:rule) { Fabricate(:rule, text: 'Original text') }
context 'with valid data' do
it 'updates the rule and redirects' do
put :update, params: { id: rule.id, rule: { text: 'Updated text.' } }
expect(response).to redirect_to(admin_rules_path)
end
end
context 'with invalid data' do
it 'does not update the rule and renders index' do
put :update, params: { id: rule.id, rule: { text: '' } }
expect(response).to render_template(:edit)
end
end
end
describe 'DELETE #destroy' do
let!(:rule) { Fabricate(:rule) }
it 'destroys the rule and redirects' do
delete :destroy, params: { id: rule.id }
expect(rule.reload).to be_discarded
expect(response).to redirect_to(admin_rules_path)
end
end
end end

@ -18,4 +18,82 @@ describe Admin::WebhooksController do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'GET #new' do
it 'returns http success and renders view' do
get :new
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
describe 'POST #create' do
it 'creates a new webhook record with valid data' do
expect do
post :create, params: { webhook: { url: 'https://example.com/hook', events: ['account.approved'] } }
end.to change(Webhook, :count).by(1)
expect(response).to be_redirect
end
it 'does not create a new webhook record with invalid data' do
expect do
post :create, params: { webhook: { url: 'https://example.com/hook', events: [] } }
end.to_not change(Webhook, :count)
expect(response).to have_http_status(:success)
expect(response).to render_template(:new)
end
end
context 'with an existing record' do
let!(:webhook) { Fabricate :webhook }
describe 'GET #show' do
it 'returns http success and renders view' do
get :show, params: { id: webhook.id }
expect(response).to have_http_status(:success)
expect(response).to render_template(:show)
end
end
describe 'GET #edit' do
it 'returns http success and renders view' do
get :edit, params: { id: webhook.id }
expect(response).to have_http_status(:success)
expect(response).to render_template(:edit)
end
end
describe 'PUT #update' do
it 'updates the record with valid data' do
put :update, params: { id: webhook.id, webhook: { url: 'https://example.com/new/location' } }
expect(webhook.reload.url).to match(%r{new/location})
expect(response).to redirect_to(admin_webhook_path(webhook))
end
it 'does not update the record with invalid data' do
expect do
put :update, params: { id: webhook.id, webhook: { url: '' } }
end.to_not change(webhook, :url)
expect(response).to have_http_status(:success)
expect(response).to render_template(:show)
end
end
describe 'DELETE #destroy' do
it 'destroys the record' do
expect do
delete :destroy, params: { id: webhook.id }
end.to change(Webhook, :count).by(-1)
expect(response).to redirect_to(admin_webhooks_path)
end
end
end
end end

@ -4,9 +4,662 @@ require 'rails_helper'
require 'mastodon/cli/accounts' require 'mastodon/cli/accounts'
describe Mastodon::CLI::Accounts do describe Mastodon::CLI::Accounts do
let(:cli) { described_class.new }
describe '.exit_on_failure?' do describe '.exit_on_failure?' do
it 'returns true' do it 'returns true' do
expect(described_class.exit_on_failure?).to be true expect(described_class.exit_on_failure?).to be true
end end
end end
describe '#create' do
shared_examples 'a new user with given email address and username' do
it 'creates a new user with the specified email address' do
cli.invoke(:create, arguments, options)
expect(User.find_by(email: options[:email])).to be_present
end
it 'creates a new local account with the specified username' do
cli.invoke(:create, arguments, options)
expect(Account.find_local('tootctl_username')).to be_present
end
it 'returns "OK" and newly generated password' do
allow(SecureRandom).to receive(:hex).and_return('test_password')
expect { cli.invoke(:create, arguments, options) }.to output(
a_string_including("OK\nNew password: test_password")
).to_stdout
end
end
context 'when required USERNAME and --email are provided' do
let(:arguments) { ['tootctl_username'] }
context 'with USERNAME and --email only' do
let(:options) { { email: 'tootctl@example.com' } }
it_behaves_like 'a new user with given email address and username'
context 'with invalid --email value' do
let(:options) { { email: 'invalid' } }
it 'exits with an error message' do
expect { cli.invoke(:create, arguments, options) }.to output(
a_string_including('Failure/Error: email')
).to_stdout
.and raise_error(SystemExit)
end
end
end
context 'with --confirmed option' do
let(:options) { { email: 'tootctl@example.com', confirmed: true } }
it_behaves_like 'a new user with given email address and username'
it 'creates a new user with confirmed status' do
cli.invoke(:create, arguments, options)
user = User.find_by(email: options[:email])
expect(user.confirmed?).to be(true)
end
end
context 'with --approve option' do
let(:options) { { email: 'tootctl@example.com', approve: true } }
before do
Form::AdminSettings.new(registrations_mode: 'approved').save
end
it_behaves_like 'a new user with given email address and username'
it 'creates a new user with approved status' do
cli.invoke(:create, arguments, options)
user = User.find_by(email: options[:email])
expect(user.approved?).to be(true)
end
end
context 'with --role option' do
context 'when role exists' do
let(:default_role) { Fabricate(:user_role) }
let(:options) { { email: 'tootctl@example.com', role: default_role.name } }
it_behaves_like 'a new user with given email address and username'
it 'creates a new user and assigns the specified role' do
cli.invoke(:create, arguments, options)
role = User.find_by(email: options[:email])&.role
expect(role.name).to eq(default_role.name)
end
end
context 'when role does not exist' do
let(:options) { { email: 'tootctl@example.com', role: '404' } }
it 'exits with an error message indicating the role name was not found' do
expect { cli.invoke(:create, arguments, options) }.to output(
a_string_including('Cannot find user role with that name')
).to_stdout
.and raise_error(SystemExit)
end
end
end
context 'with --reattach option' do
context "when account's user is present" do
let(:options) { { email: 'tootctl_new@example.com', reattach: true } }
let(:user) { Fabricate.build(:user, email: 'tootctl@example.com') }
before do
Fabricate(:account, username: 'tootctl_username', user: user)
end
it 'returns an error message indicating the username is already taken' do
expect { cli.invoke(:create, arguments, options) }.to output(
a_string_including("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user")
).to_stdout
end
context 'with --force option' do
let(:options) { { email: 'tootctl_new@example.com', reattach: true, force: true } }
it 'reattaches the account to the new user and deletes the previous user' do
cli.invoke(:create, arguments, options)
user = Account.find_local('tootctl_username')&.user
expect(user.email).to eq(options[:email])
end
end
end
context "when account's user is not present" do
let(:options) { { email: 'tootctl@example.com', reattach: true } }
before do
Fabricate(:account, username: 'tootctl_username', user: nil)
end
it_behaves_like 'a new user with given email address and username'
end
end
end
context 'when required --email option is not provided' do
let(:arguments) { ['tootctl_username'] }
it 'raises a required argument missing error (Thor::RequiredArgumentMissingError)' do
expect { cli.invoke(:create, arguments) }
.to raise_error(Thor::RequiredArgumentMissingError)
end
end
end
describe '#modify' do
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating the user was not found' do
expect { cli.invoke(:modify, arguments) }.to output(
a_string_including('No user with such username')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given username is found' do
let(:user) { Fabricate(:user) }
let(:arguments) { [user.account.username] }
context 'when no option is provided' do
it 'returns a successful message' do
expect { cli.invoke(:modify, arguments) }.to output(
a_string_including('OK')
).to_stdout
end
it 'does not modify the user' do
cli.invoke(:modify, arguments)
expect(user).to eq(user.reload)
end
end
context 'with --role option' do
context 'when the given role is not found' do
let(:options) { { role: '404' } }
it 'exits with an error message indicating the role was not found' do
expect { cli.invoke(:modify, arguments, options) }.to output(
a_string_including('Cannot find user role with that name')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given role is found' do
let(:default_role) { Fabricate(:user_role) }
let(:options) { { role: default_role.name } }
it "updates the user's role to the specified role" do
cli.invoke(:modify, arguments, options)
role = user.reload.role
expect(role.name).to eq(default_role.name)
end
end
end
context 'with --remove-role option' do
let(:options) { { remove_role: true } }
let(:role) { Fabricate(:user_role) }
let(:user) { Fabricate(:user, role: role) }
it "removes the user's role successfully" do
cli.invoke(:modify, arguments, options)
role = user.reload.role
expect(role.name).to be_empty
end
end
context 'with --email option' do
let(:user) { Fabricate(:user, email: 'old_email@email.com') }
let(:options) { { email: 'new_email@email.com' } }
it "sets the user's unconfirmed email to the provided email address" do
cli.invoke(:modify, arguments, options)
expect(user.reload.unconfirmed_email).to eq(options[:email])
end
it "does not update the user's original email address" do
cli.invoke(:modify, arguments, options)
expect(user.reload.email).to eq('old_email@email.com')
end
context 'with --confirm option' do
let(:user) { Fabricate(:user, email: 'old_email@email.com', confirmed_at: nil) }
let(:options) { { email: 'new_email@email.com', confirm: true } }
it "updates the user's email address to the provided email" do
cli.invoke(:modify, arguments, options)
expect(user.reload.email).to eq(options[:email])
end
it "sets the user's email address as confirmed" do
cli.invoke(:modify, arguments, options)
expect(user.reload.confirmed?).to be(true)
end
end
end
context 'with --confirm option' do
let(:user) { Fabricate(:user, confirmed_at: nil) }
let(:options) { { confirm: true } }
it "confirms the user's email address" do
cli.invoke(:modify, arguments, options)
expect(user.reload.confirmed?).to be(true)
end
end
context 'with --approve option' do
let(:user) { Fabricate(:user, approved: false) }
let(:options) { { approve: true } }
before do
Form::AdminSettings.new(registrations_mode: 'approved').save
end
it 'approves the user' do
expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.approved }.from(false).to(true)
end
end
context 'with --disable option' do
let(:user) { Fabricate(:user, disabled: false) }
let(:options) { { disable: true } }
it 'disables the user' do
expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(false).to(true)
end
end
context 'with --enable option' do
let(:user) { Fabricate(:user, disabled: true) }
let(:options) { { enable: true } }
it 'enables the user' do
expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(true).to(false)
end
end
context 'with --reset-password option' do
let(:options) { { reset_password: true } }
it 'returns a new password for the user' do
allow(SecureRandom).to receive(:hex).and_return('new_password')
expect { cli.invoke(:modify, arguments, options) }.to output(
a_string_including('new_password')
).to_stdout
end
end
context 'with --disable-2fa option' do
let(:user) { Fabricate(:user, otp_required_for_login: true) }
let(:options) { { disable_2fa: true } }
it 'disables the two-factor authentication for the user' do
expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.otp_required_for_login }.from(true).to(false)
end
end
context 'when provided data is invalid' do
let(:user) { Fabricate(:user) }
let(:options) { { email: 'invalid' } }
it 'exits with an error message' do
expect { cli.invoke(:modify, arguments, options) }.to output(
a_string_including('Failure/Error: email')
).to_stdout
.and raise_error(SystemExit)
end
end
end
end
describe '#delete' do
let(:account) { Fabricate(:account) }
let(:arguments) { [account.username] }
let(:options) { { email: account.user.email } }
let(:delete_account_service) { instance_double(DeleteAccountService) }
before do
allow(DeleteAccountService).to receive(:new).and_return(delete_account_service)
allow(delete_account_service).to receive(:call)
end
context 'when both username and --email are provided' do
it 'exits with an error message indicating that only one should be used' do
expect { cli.invoke(:delete, arguments, options) }.to output(
a_string_including('Use username or --email, not both')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when neither username nor --email are provided' do
it 'exits with an error message indicating that no username was provided' do
expect { cli.invoke(:delete) }.to output(
a_string_including('No username provided')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when username is provided' do
it 'deletes the specified user successfully' do
cli.invoke(:delete, arguments)
expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
end
context 'with --dry-run option' do
let(:options) { { dry_run: true } }
it 'does not delete the specified user' do
cli.invoke(:delete, arguments, options)
expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
end
it 'outputs a successful message in dry run mode' do
expect { cli.invoke(:delete, arguments, options) }.to output(
a_string_including('OK (DRY RUN)')
).to_stdout
end
end
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating that no user was found' do
expect { cli.invoke(:delete, arguments) }.to output(
a_string_including('No user with such username')
).to_stdout
.and raise_error(SystemExit)
end
end
end
context 'when --email is provided' do
it 'deletes the specified user successfully' do
cli.invoke(:delete, nil, options)
expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
end
context 'with --dry-run option' do
let(:options) { { email: account.user.email, dry_run: true } }
it 'does not delete the user' do
cli.invoke(:delete, nil, options)
expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
end
it 'outputs a successful message in dry run mode' do
expect { cli.invoke(:delete, nil, options) }.to output(
a_string_including('OK (DRY RUN)')
).to_stdout
end
end
context 'when the given email address is not found' do
let(:options) { { email: '404@example.com' } }
it 'exits with an error message indicating that no user was found' do
expect { cli.invoke(:delete, nil, options) }.to output(
a_string_including('No user with such email')
).to_stdout
.and raise_error(SystemExit)
end
end
end
end
describe '#approve' do
let(:total_users) { 10 }
before do
Form::AdminSettings.new(registrations_mode: 'approved').save
Fabricate.times(total_users, :user)
end
context 'with --all option' do
it 'approves all pending registrations' do
cli.invoke(:approve, nil, all: true)
expect(User.pluck(:approved).all?(true)).to be(true)
end
end
context 'with --number option' do
context 'when the number is positive' do
let(:options) { { number: 3 } }
it 'approves the earliest n pending registrations' do
cli.invoke(:approve, nil, options)
n_earliest_pending_registrations = User.order(created_at: :asc).first(options[:number])
expect(n_earliest_pending_registrations.all?(&:approved?)).to be(true)
end
it 'does not approve the remaining pending registrations' do
cli.invoke(:approve, nil, options)
pending_registrations = User.order(created_at: :asc).last(total_users - options[:number])
expect(pending_registrations.all?(&:approved?)).to be(false)
end
end
context 'when the number is negative' do
it 'exits with an error message indicating that the number must be positive' do
expect { cli.invoke(:approve, nil, number: -1) }.to output(
a_string_including('Number must be positive')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given number is greater than the number of users' do
let(:options) { { number: total_users * 2 } }
it 'approves all users' do
cli.invoke(:approve, nil, options)
expect(User.pluck(:approved).all?(true)).to be(true)
end
it 'does not raise any error' do
expect { cli.invoke(:approve, nil, options) }
.to_not raise_error
end
end
end
context 'with username argument' do
context 'when the given username is found' do
let(:user) { User.last }
let(:arguments) { [user.account.username] }
it 'approves the specified user successfully' do
cli.invoke(:approve, arguments)
expect(user.reload.approved?).to be(true)
end
end
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating that no such account was found' do
expect { cli.invoke(:approve, arguments) }.to output(
a_string_including('No such account')
).to_stdout
.and raise_error(SystemExit)
end
end
end
end
describe '#follow' do
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating that no account with the given username was found' do
expect { cli.invoke(:follow, arguments) }.to output(
a_string_including('No such account')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given username is found' do
let!(:target_account) { Fabricate(:account) }
let!(:follower_bob) { Fabricate(:account, username: 'bob') }
let!(:follower_rony) { Fabricate(:account, username: 'rony') }
let!(:follower_charles) { Fabricate(:account, username: 'charles') }
let(:follow_service) { instance_double(FollowService, call: nil) }
let(:scope) { Account.local.without_suspended }
before do
allow(cli).to receive(:parallelize_with_progress).and_yield(follower_bob)
.and_yield(follower_rony)
.and_yield(follower_charles)
.and_return([3, nil])
allow(FollowService).to receive(:new).and_return(follow_service)
end
it 'makes all local accounts follow the target account' do
cli.follow(target_account.username)
expect(cli).to have_received(:parallelize_with_progress).with(scope).once
expect(follow_service).to have_received(:call).with(follower_bob, target_account, any_args).once
expect(follow_service).to have_received(:call).with(follower_rony, target_account, any_args).once
expect(follow_service).to have_received(:call).with(follower_charles, target_account, any_args).once
end
it 'displays a successful message' do
expect { cli.follow(target_account.username) }.to output(
a_string_including('OK, followed target from 3 accounts')
).to_stdout
end
end
end
describe '#unfollow' do
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating that no account with the given username was found' do
expect { cli.invoke(:unfollow, arguments) }.to output(
a_string_including('No such account')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given username is found' do
let!(:target_account) { Fabricate(:account) }
let!(:follower_chris) { Fabricate(:account, username: 'chris') }
let!(:follower_rambo) { Fabricate(:account, username: 'rambo') }
let!(:follower_ana) { Fabricate(:account, username: 'ana') }
let(:unfollow_service) { instance_double(UnfollowService, call: nil) }
let(:scope) { target_account.followers.local }
before do
accounts = [follower_chris, follower_rambo, follower_ana]
accounts.each { |account| target_account.follow!(account) }
allow(cli).to receive(:parallelize_with_progress).and_yield(follower_chris)
.and_yield(follower_rambo)
.and_yield(follower_ana)
.and_return([3, nil])
allow(UnfollowService).to receive(:new).and_return(unfollow_service)
end
it 'makes all local accounts unfollow the target account' do
cli.unfollow(target_account.username)
expect(cli).to have_received(:parallelize_with_progress).with(scope).once
expect(unfollow_service).to have_received(:call).with(follower_chris, target_account).once
expect(unfollow_service).to have_received(:call).with(follower_rambo, target_account).once
expect(unfollow_service).to have_received(:call).with(follower_ana, target_account).once
end
it 'displays a successful message' do
expect { cli.unfollow(target_account.username) }.to output(
a_string_including('OK, unfollowed target from 3 accounts')
).to_stdout
end
end
end
describe '#backup' do
context 'when the given username is not found' do
let(:arguments) { ['non_existent_username'] }
it 'exits with an error message indicating that there is no such account' do
expect { cli.invoke(:backup, arguments) }.to output(
a_string_including('No user with such username')
).to_stdout
.and raise_error(SystemExit)
end
end
context 'when the given username is found' do
let(:account) { Fabricate(:account) }
let(:user) { account.user }
let(:arguments) { [account.username] }
it 'creates a new backup for the specified user' do
expect { cli.invoke(:backup, arguments) }.to change { user.backups.count }.by(1)
end
it 'creates a backup job' do
allow(BackupWorker).to receive(:perform_async)
cli.invoke(:backup, arguments)
latest_backup = user.backups.last
expect(BackupWorker).to have_received(:perform_async).with(latest_backup.id).once
end
it 'displays a successful message' do
expect { cli.invoke(:backup, arguments) }.to output(
a_string_including('OK')
).to_stdout
end
end
end
end end

@ -62,6 +62,10 @@ RSpec.configure do |config|
config.infer_spec_type_from_file_location! config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace! config.filter_rails_from_backtrace!
config.define_derived_metadata(file_path: Regexp.new('spec/lib/mastodon/cli')) do |metadata|
metadata[:type] = :cli
end
config.include Devise::Test::ControllerHelpers, type: :controller config.include Devise::Test::ControllerHelpers, type: :controller
config.include Devise::Test::ControllerHelpers, type: :helper config.include Devise::Test::ControllerHelpers, type: :helper
config.include Devise::Test::ControllerHelpers, type: :view config.include Devise::Test::ControllerHelpers, type: :view
@ -73,6 +77,10 @@ RSpec.configure do |config|
config.include Redisable config.include Redisable
config.include SignedRequestHelpers, type: :request config.include SignedRequestHelpers, type: :request
config.before :each, type: :cli do
stub_stdout
end
config.before :each, type: :feature do config.before :each, type: :feature do
https = ENV['LOCAL_HTTPS'] == 'true' https = ENV['LOCAL_HTTPS'] == 'true'
Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}" Capybara.app_host = "http#{https ? 's' : ''}://#{ENV.fetch('LOCAL_DOMAIN')}"
@ -106,6 +114,10 @@ def attachment_fixture(name)
Rails.root.join('spec', 'fixtures', 'files', name).open Rails.root.join('spec', 'fixtures', 'files', name).open
end end
def stub_stdout
allow($stdout).to receive(:write)
end
def stub_jsonld_contexts! def stub_jsonld_contexts!
stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt')) stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt'))
stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt')) stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt'))

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save