aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorTakeshi Umeda <noel.yoshiba@gmail.com>2021-01-10 11:17:55 +0900
committerGitHub <noreply@github.com>2021-01-10 11:17:55 +0900
commit98a2603dc163210d3a0aab0a0c2b8ef74c7e5eb0 (patch)
tree761694d2d697c58faf02a3ff9ef26bf045fc0274 /lib
parent7cd4ed7d4298626d2b141cd6d8378e95bc248824 (diff)
parent087ed84367537ac168ed3e00bb7eb4bd582dc3d0 (diff)
downloadmastodon-feature-limited-visibility-bearcaps.tar
mastodon-feature-limited-visibility-bearcaps.tar.gz
mastodon-feature-limited-visibility-bearcaps.tar.bz2
mastodon-feature-limited-visibility-bearcaps.zip
Merge branch 'master' into feature-limited-visibility-bearcapsfeature-limited-visibility-bearcaps
Diffstat (limited to 'lib')
-rw-r--r--lib/chewy/strategy/custom_sidekiq.rb25
-rw-r--r--lib/cli.rb8
-rw-r--r--lib/enumerable.rb26
-rw-r--r--lib/mastodon/accounts_cli.rb72
-rw-r--r--lib/mastodon/domains_cli.rb6
-rw-r--r--lib/mastodon/emoji_cli.rb7
-rw-r--r--lib/mastodon/ip_blocks_cli.rb132
-rw-r--r--lib/mastodon/maintenance_cli.rb624
-rw-r--r--lib/mastodon/media_cli.rb4
-rw-r--r--lib/mastodon/redis_config.rb2
-rw-r--r--lib/mastodon/version.rb8
-rw-r--r--lib/paperclip/attachment_extensions.rb23
-rw-r--r--lib/paperclip/color_extractor.rb6
-rw-r--r--lib/paperclip/response_with_limit_adapter.rb2
-rw-r--r--lib/paperclip/url_generator_extensions.rb4
-rw-r--r--lib/tasks/db.rake14
-rw-r--r--lib/tasks/emojis.rake2
-rw-r--r--lib/tasks/mastodon.rake2
-rw-r--r--lib/webpacker/helper_extensions.rb20
-rw-r--r--lib/webpacker/manifest_extensions.rb17
20 files changed, 961 insertions, 43 deletions
diff --git a/lib/chewy/strategy/custom_sidekiq.rb b/lib/chewy/strategy/custom_sidekiq.rb
index 3e54326ba..794ae4ed4 100644
--- a/lib/chewy/strategy/custom_sidekiq.rb
+++ b/lib/chewy/strategy/custom_sidekiq.rb
@@ -2,29 +2,10 @@
module Chewy
class Strategy
- class CustomSidekiq < Base
- class Worker
- include ::Sidekiq::Worker
-
- sidekiq_options queue: 'pull'
-
- def perform(type, ids, options = {})
- options[:refresh] = !Chewy.disable_refresh_async if Chewy.disable_refresh_async
- type.constantize.import!(ids, options)
- end
- end
-
- def update(type, objects, _options = {})
- return unless Chewy.enabled?
-
- ids = type.root.id ? Array.wrap(objects) : type.adapter.identify(objects)
-
- return if ids.empty?
-
- Worker.perform_async(type.name, ids)
+ class CustomSidekiq < Sidekiq
+ def update(_type, _objects, _options = {})
+ super if Chewy.enabled?
end
-
- def leave; end
end
end
end
diff --git a/lib/cli.rb b/lib/cli.rb
index 9162144cc..3f1658566 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -13,6 +13,8 @@ require_relative 'mastodon/preview_cards_cli'
require_relative 'mastodon/cache_cli'
require_relative 'mastodon/upgrade_cli'
require_relative 'mastodon/email_domain_blocks_cli'
+require_relative 'mastodon/ip_blocks_cli'
+require_relative 'mastodon/maintenance_cli'
require_relative 'mastodon/version'
module Mastodon
@@ -57,6 +59,12 @@ module Mastodon
desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
+ desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
+ subcommand 'ip_blocks', Mastodon::IpBlocksCLI
+
+ desc 'maintenance SUBCOMMAND ...ARGS', 'Various maintenance utilities'
+ subcommand 'maintenance', Mastodon::MaintenanceCLI
+
option :dry_run, type: :boolean
desc 'self-destruct', 'Erase the server from the federation'
long_desc <<~LONG_DESC
diff --git a/lib/enumerable.rb b/lib/enumerable.rb
new file mode 100644
index 000000000..66918f65e
--- /dev/null
+++ b/lib/enumerable.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Enumerable
+ # TODO: Remove this once stop to support Ruby 2.6
+ if RUBY_VERSION < '2.7.0'
+ def filter_map
+ if block_given?
+ result = []
+ each do |element|
+ res = yield element
+ result << res if res
+ end
+ result
+ else
+ Enumerator.new do |yielder|
+ result = []
+ each do |element|
+ res = yielder.yield element
+ result << res if res
+ end
+ result
+ end
+ end
+ end
+ end
+end
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 8c91c3013..653bfca30 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -77,7 +77,7 @@ module Mastodon
def create(username)
account = Account.new(username: username)
password = SecureRandom.hex
- user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
+ user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true)
if options[:reattach]
account = Account.find_local(username) || Account.new(username: username)
@@ -87,7 +87,7 @@ module Mastodon
say('Use --force to reattach it anyway and delete the other user')
return
elsif account.user.present?
- account.user.destroy!
+ DeleteAccountService.new.call(account, reserve_email: false)
end
end
@@ -192,10 +192,69 @@ module Mastodon
end
say("Deleting user with #{account.statuses_count} statuses, this might take a while...")
- SuspendAccountService.new.call(account, reserve_email: false)
+ DeleteAccountService.new.call(account, reserve_email: false)
say('OK', :green)
end
+ option :force, type: :boolean, aliases: [:f], description: 'Override public key check'
+ desc 'merge FROM TO', 'Merge two remote accounts into one'
+ long_desc <<-LONG_DESC
+ Merge two remote accounts specified by their username@domain
+ into one, whereby the TO account is the one being merged into
+ and kept, while the FROM one is removed. It is primarily meant
+ to fix duplicates caused by other servers changing their domain.
+
+ The command by default only works if both accounts have the same
+ public key to prevent mistakes. To override this, use the --force.
+ LONG_DESC
+ def merge(from_acct, to_acct)
+ username, domain = from_acct.split('@')
+ from_account = Account.find_remote(username, domain)
+
+ if from_account.nil? || from_account.local?
+ say("No such account (#{from_acct})", :red)
+ exit(1)
+ end
+
+ username, domain = to_acct.split('@')
+ to_account = Account.find_remote(username, domain)
+
+ if to_account.nil? || to_account.local?
+ say("No such account (#{to_acct})", :red)
+ exit(1)
+ end
+
+ if from_account.public_key != to_account.public_key && !options[:force]
+ say("Accounts don't have the same public key, might not be duplicates!", :red)
+ say('Override with --force', :red)
+ exit(1)
+ end
+
+ to_account.merge_with!(from_account)
+ from_account.destroy
+
+ say('OK', :green)
+ end
+
+ desc 'fix-duplicates', 'Find duplicate remote accounts and merge them'
+ option :dry_run, type: :boolean
+ long_desc <<-LONG_DESC
+ Merge known remote accounts sharing an ActivityPub actor identifier.
+
+ Such duplicates can occur when a remote server admin misconfigures their
+ domain configuration.
+ LONG_DESC
+ def fix_duplicates
+ Account.remote.select(:uri, 'count(*)').group(:uri).having('count(*) > 1').pluck(:uri).each do |uri|
+ say("Duplicates found for #{uri}")
+ begin
+ ActivityPub::FetchRemoteAccountService.new.call(uri) unless options[:dry_run]
+ rescue => e
+ say("Error processing #{uri}: #{e}", :red)
+ end
+ end
+ end
+
desc 'backup USERNAME', 'Request a backup for a user'
long_desc <<-LONG_DESC
Request a new backup for an account with a given USERNAME.
@@ -245,7 +304,7 @@ module Mastodon
end
if [404, 410].include?(code)
- SuspendAccountService.new.call(account, reserve_username: false) unless options[:dry_run]
+ DeleteAccountService.new.call(account, reserve_username: false) unless options[:dry_run]
1
else
# Touch account even during dry run to avoid getting the account into the window again
@@ -325,7 +384,7 @@ module Mastodon
end
processed, = parallelize_with_progress(Account.local.without_suspended) do |account|
- FollowService.new.call(account, target_account)
+ FollowService.new.call(account, target_account, bypass_limit: true)
end
say("OK, followed target from #{processed} accounts", :green)
@@ -335,7 +394,8 @@ module Mastodon
option :verbose, type: :boolean, aliases: [:v]
desc 'unfollow ACCT', 'Make all local accounts unfollow account specified by ACCT'
def unfollow(acct)
- target_account = Account.find_remote(*acct.split('@'))
+ username, domain = acct.split('@')
+ target_account = Account.find_remote(username, domain)
if target_account.nil?
say('No such account', :red)
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 558737c27..3c2dfd4ec 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -42,7 +42,7 @@ module Mastodon
end
processed, = parallelize_with_progress(scope) do |account|
- SuspendAccountService.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 options[:dry_run]
end
DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
@@ -53,6 +53,8 @@ module Mastodon
custom_emojis_count = custom_emojis.count
custom_emojis.destroy_all unless options[:dry_run]
+ Instance.refresh unless options[:dry_run]
+
say("Removed #{custom_emojis_count} custom emojis", :green)
end
@@ -83,7 +85,7 @@ module Mastodon
processed = Concurrent::AtomicFixnum.new(0)
failed = Concurrent::AtomicFixnum.new(0)
start_at = Time.now.to_f
- seed = start ? [start] : Account.remote.domains
+ seed = start ? [start] : Instance.pluck(:domain)
blocked_domains = Regexp.new('\\.?' + DomainBlock.where(severity: 1).pluck(:domain).join('|') + '$')
progress = create_progress_bar
diff --git a/lib/mastodon/emoji_cli.rb b/lib/mastodon/emoji_cli.rb
index da8fd6a0d..0a1f538e6 100644
--- a/lib/mastodon/emoji_cli.rb
+++ b/lib/mastodon/emoji_cli.rb
@@ -43,7 +43,12 @@ module Mastodon
tar.each do |entry|
next unless entry.file? && entry.full_name.end_with?('.png')
- shortcode = [options[:prefix], File.basename(entry.full_name, '.*'), options[:suffix]].compact.join
+ filename = File.basename(entry.full_name, '.*')
+
+ # Skip macOS shadow files
+ next if filename.start_with?('._')
+
+ shortcode = [options[:prefix], filename, options[:suffix]].compact.join
custom_emoji = CustomEmoji.local.find_by(shortcode: shortcode)
if custom_emoji && !options[:overwrite]
diff --git a/lib/mastodon/ip_blocks_cli.rb b/lib/mastodon/ip_blocks_cli.rb
new file mode 100644
index 000000000..5c38c1aca
--- /dev/null
+++ b/lib/mastodon/ip_blocks_cli.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+ class IpBlocksCLI < Thor
+ def self.exit_on_failure?
+ true
+ end
+
+ option :severity, required: true, enum: %w(no_access sign_up_requires_approval), desc: 'Severity of the block'
+ option :comment, aliases: [:c], desc: 'Optional comment'
+ option :duration, aliases: [:d], type: :numeric, desc: 'Duration of the block in seconds'
+ option :force, type: :boolean, aliases: [:f], desc: 'Overwrite existing blocks'
+ desc 'add IP...', 'Add one or more IP blocks'
+ long_desc <<-LONG_DESC
+ Add one or more IP blocks. You can use CIDR syntax to
+ block IP ranges. You must specify --severity of the block. All
+ options will be copied for each IP block you create in one command.
+
+ You can add a --comment. If an IP block already exists for one of
+ the provided IPs, it will be skipped unless you use the --force
+ option to overwrite it.
+ LONG_DESC
+ def add(*addresses)
+ if addresses.empty?
+ say('No IP(s) given', :red)
+ exit(1)
+ end
+
+ skipped = 0
+ processed = 0
+ failed = 0
+
+ addresses.each do |address|
+ ip_block = IpBlock.find_by(ip: address)
+
+ if ip_block.present? && !options[:force]
+ say("#{address} is already blocked", :yellow)
+ skipped += 1
+ next
+ end
+
+ ip_block ||= IpBlock.new(ip: address)
+
+ ip_block.severity = options[:severity]
+ ip_block.comment = options[:comment] if options[:comment].present?
+ ip_block.expires_in = options[:duration]
+
+ if ip_block.save
+ processed += 1
+ else
+ say("#{address} could not be saved", :red)
+ failed += 1
+ end
+ end
+
+ say("Added #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed))
+ end
+
+ option :force, type: :boolean, aliases: [:f], desc: 'Remove blocks for ranges that cover given IP(s)'
+ desc 'remove IP...', 'Remove one or more IP blocks'
+ long_desc <<-LONG_DESC
+ Remove one or more IP blocks. Normally, only exact matches are removed. If
+ you want to ensure that all of the given IP addresses are unblocked, you
+ can use --force which will also remove any blocks for IP ranges that would
+ cover the given IP(s).
+ LONG_DESC
+ def remove(*addresses)
+ if addresses.empty?
+ say('No IP(s) given', :red)
+ exit(1)
+ end
+
+ processed = 0
+ skipped = 0
+
+ addresses.each do |address|
+ ip_blocks = begin
+ if options[:force]
+ IpBlock.where('ip >>= ?', address)
+ else
+ IpBlock.where('ip <<= ?', address)
+ end
+ end
+
+ if ip_blocks.empty?
+ say("#{address} is not yet blocked", :yellow)
+ skipped += 1
+ next
+ end
+
+ ip_blocks.in_batches.destroy_all
+ processed += 1
+ end
+
+ say("Removed #{processed}, skipped #{skipped}", color(processed, 0))
+ end
+
+ option :format, aliases: [:f], enum: %w(plain nginx), desc: 'Format of the output'
+ desc 'export', 'Export blocked IPs'
+ long_desc <<-LONG_DESC
+ Export blocked IPs. Different formats are supported for usage with other
+ tools. Only blocks with no_access severity are returned.
+ LONG_DESC
+ def export
+ IpBlock.where(severity: :no_access).find_each do |ip_block|
+ case options[:format]
+ when 'nginx'
+ puts "deny #{ip_block.ip}/#{ip_block.ip.prefix};"
+ else
+ puts "#{ip_block.ip}/#{ip_block.ip.prefix}"
+ end
+ end
+ end
+
+ private
+
+ def color(processed, failed)
+ if !processed.zero? && failed.zero?
+ :green
+ elsif failed.zero?
+ :yellow
+ else
+ :red
+ end
+ end
+ end
+end
diff --git a/lib/mastodon/maintenance_cli.rb b/lib/mastodon/maintenance_cli.rb
new file mode 100644
index 000000000..029d42a05
--- /dev/null
+++ b/lib/mastodon/maintenance_cli.rb
@@ -0,0 +1,624 @@
+# frozen_string_literal: true
+
+require 'tty-prompt'
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+ class MaintenanceCLI < Thor
+ include CLIHelper
+
+ def self.exit_on_failure?
+ true
+ end
+
+ MIN_SUPPORTED_VERSION = 2019_10_01_213028
+ MAX_SUPPORTED_VERSION = 2020_12_18_054746
+
+ # Stubs to enjoy ActiveRecord queries while not depending on a particular
+ # version of the code/database
+
+ class Status < ApplicationRecord; end
+ class StatusPin < ApplicationRecord; end
+ class Poll < ApplicationRecord; end
+ class Report < ApplicationRecord; end
+ class Tombstone < ApplicationRecord; end
+ class Favourite < ApplicationRecord; end
+ class Follow < ApplicationRecord; end
+ class FollowRequest < ApplicationRecord; end
+ class Block < ApplicationRecord; end
+ class Mute < ApplicationRecord; end
+ class AccountIdentityProof < ApplicationRecord; end
+ class AccountModerationNote < ApplicationRecord; end
+ class AccountPin < ApplicationRecord; end
+ class ListAccount < ApplicationRecord; end
+ class PollVote < ApplicationRecord; end
+ class Mention < ApplicationRecord; end
+ class AccountDomainBlock < ApplicationRecord; end
+ class AnnouncementReaction < ApplicationRecord; end
+ class FeaturedTag < ApplicationRecord; end
+ class CustomEmoji < ApplicationRecord; end
+ class CustomEmojiCategory < ApplicationRecord; end
+ class Bookmark < ApplicationRecord; end
+ class WebauthnCredential < ApplicationRecord; end
+
+ class PreviewCard < ApplicationRecord
+ self.inheritance_column = false
+ end
+
+ class MediaAttachment < ApplicationRecord
+ self.inheritance_column = nil
+ end
+
+ class AccountStat < ApplicationRecord
+ belongs_to :account, inverse_of: :account_stat
+ end
+
+ # Dummy class, to make migration possible across version changes
+ class Account < ApplicationRecord
+ has_one :user, inverse_of: :account
+ has_one :account_stat, inverse_of: :account
+
+ scope :local, -> { where(domain: nil) }
+
+ def local?
+ domain.nil?
+ end
+
+ def acct
+ local? ? username : "#{username}@#{domain}"
+ end
+
+ # This is a duplicate of the AccountMerging concern because we need it to
+ # be independent from code version.
+ def merge_with!(other_account)
+ # Since it's the same remote resource, the remote resource likely
+ # already believes we are following/blocking, so it's safe to
+ # re-attribute the relationships too. However, during the presence
+ # of the index bug users could have *also* followed the reference
+ # account already, therefore mass update will not work and we need
+ # to check for (and skip past) uniqueness errors
+
+ owned_classes = [
+ Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
+ Follow, FollowRequest, Block, Mute, AccountIdentityProof,
+ AccountModerationNote, AccountPin, AccountStat, ListAccount,
+ PollVote, Mention
+ ]
+ owned_classes << AccountDeletionRequest if ActiveRecord::Base.connection.table_exists?(:account_deletion_requests)
+ owned_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes)
+
+ owned_classes.each do |klass|
+ klass.where(account_id: other_account.id).find_each do |record|
+ begin
+ record.update_attribute(:account_id, id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+
+ target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin]
+ target_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes)
+
+ target_classes.each do |klass|
+ klass.where(target_account_id: other_account.id).find_each do |record|
+ begin
+ record.update_attribute(:target_account_id, id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+ end
+ end
+
+ class User < ApplicationRecord
+ belongs_to :account, inverse_of: :user
+ end
+
+ desc 'fix-duplicates', 'Fix duplicates in database and rebuild indexes'
+ long_desc <<~LONG_DESC
+ Delete or merge duplicate accounts, statuses, emojis, etc. and rebuild indexes.
+
+ This is useful if your database indexes are corrupted because of issues such as https://wiki.postgresql.org/wiki/Locale_data_changes
+
+ Mastodon has to be stopped to run this task, which will take a long time and may be destructive.
+ LONG_DESC
+ def fix_duplicates
+ @prompt = TTY::Prompt.new
+
+ if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION
+ @prompt.warn 'Your version of the database schema is too old and is not supported by this script.'
+ @prompt.warn 'Please update to at least Mastodon 3.0.0 before running this script.'
+ exit(1)
+ 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.'
+ exit(1) unless @prompt.yes?('Continue anyway?')
+ end
+
+ @prompt.warn 'This task will take a long time to run and is potentially destructive.'
+ @prompt.warn 'Please make sure to stop Mastodon and have a backup.'
+ exit(1) unless @prompt.yes?('Continue?')
+
+ deduplicate_accounts!
+ deduplicate_users!
+ deduplicate_account_domain_blocks!
+ deduplicate_account_identity_proofs!
+ deduplicate_announcement_reactions!
+ deduplicate_conversations!
+ deduplicate_custom_emojis!
+ deduplicate_custom_emoji_categories!
+ deduplicate_domain_allows!
+ deduplicate_domain_blocks!
+ deduplicate_unavailable_domains!
+ deduplicate_email_domain_blocks!
+ deduplicate_media_attachments!
+ deduplicate_preview_cards!
+ deduplicate_statuses!
+ deduplicate_tags!
+ deduplicate_webauthn_credentials!
+
+ Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) if ActiveRecord::Migrator.current_version >= 2020_12_06_004238
+ Rails.cache.clear
+
+ @prompt.say 'Finished!'
+ end
+
+ private
+
+ def deduplicate_accounts!
+ 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.'
+
+ find_duplicate_accounts.each do |row|
+ accounts = Account.where(id: row['ids'].split(',')).to_a
+
+ if accounts.first.local?
+ deduplicate_local_accounts!(accounts)
+ else
+ deduplicate_remote_accounts!(accounts)
+ end
+ end
+
+ @prompt.say 'Restoring index_accounts_on_username_and_domain_lower…'
+ if ActiveRecord::Migrator.current_version < 20200620164023
+ ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
+ else
+ ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
+ end
+
+ @prompt.say 'Reindexing textual indexes on accounts…'
+ 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_url;')
+ end
+
+ def deduplicate_users!
+ remove_index_if_exists!(:users, 'index_users_on_confirmation_token')
+ remove_index_if_exists!(:users, 'index_users_on_email')
+ remove_index_if_exists!(:users, 'index_users_on_remember_token')
+ remove_index_if_exists!(:users, 'index_users_on_reset_password_token')
+
+ @prompt.say 'Deduplicating user records…'
+
+ # 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|
+ users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
+ ref_user = users.shift
+ @prompt.warn "Multiple users registered with e-mail address #{ref_user.email}."
+ @prompt.warn "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}"
+ @prompt.warn 'Please reach out to them and set another address with `tootctl account modify` or delete them.'
+
+ i = 0
+ users.each do |user|
+ user.update!(email: "#{i} " + user.email)
+ end
+ end
+
+ 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)
+ @prompt.warn "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
+
+ users.each do |user|
+ user.update!(confirmation_token: nil)
+ end
+ end
+
+ 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)
+ @prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
+
+ users.each do |user|
+ user.update!(remember_token: nil)
+ end
+ end
+
+ 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)
+ @prompt.warn "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
+
+ users.each do |user|
+ user.update!(reset_password_token: nil)
+ end
+ end
+
+ @prompt.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, ['email'], name: 'index_users_on_email', unique: true
+ ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true
+ ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
+ end
+
+ def deduplicate_account_domain_blocks!
+ remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain')
+
+ @prompt.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|
+ AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all
+ end
+
+ @prompt.say 'Restoring account domain blocks indexes…'
+ ActiveRecord::Base.connection.add_index :account_domain_blocks, ['account_id', 'domain'], name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true
+ end
+
+ def deduplicate_account_identity_proofs!
+ remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username')
+
+ @prompt.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|
+ AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring account identity proofs indexes…'
+ ActiveRecord::Base.connection.add_index :account_identity_proofs, ['account_id', 'provider', 'provider_username'], name: 'index_account_proofs_on_account_and_provider_and_username', unique: true
+ end
+
+ def deduplicate_announcement_reactions!
+ return unless ActiveRecord::Base.connection.table_exists?(:announcement_reactions)
+
+ remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
+
+ @prompt.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|
+ AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring announcement_reactions indexes…'
+ ActiveRecord::Base.connection.add_index :announcement_reactions, ['account_id', 'announcement_id', 'name'], name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true
+ end
+
+ def deduplicate_conversations!
+ remove_index_if_exists!(:conversations, 'index_conversations_on_uri')
+
+ @prompt.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|
+ conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse
+
+ ref_conversation = conversations.shift
+
+ conversations.each do |other|
+ merge_conversations!(ref_conversation, other)
+ other.destroy
+ end
+ end
+
+ @prompt.say 'Restoring conversations indexes…'
+ ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
+ end
+
+ def deduplicate_custom_emojis!
+ remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain')
+
+ @prompt.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|
+ emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse
+
+ ref_emoji = emojis.shift
+
+ emojis.each do |other|
+ merge_custom_emojis!(ref_emoji, other)
+ other.destroy
+ end
+ end
+
+ @prompt.say 'Restoring custom_emojis indexes…'
+ ActiveRecord::Base.connection.add_index :custom_emojis, ['shortcode', 'domain'], name: 'index_custom_emojis_on_shortcode_and_domain', unique: true
+ end
+
+ def deduplicate_custom_emoji_categories!
+ remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name')
+
+ @prompt.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|
+ categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse
+
+ ref_category = categories.shift
+
+ categories.each do |other|
+ merge_custom_emoji_categories!(ref_category, other)
+ other.destroy
+ end
+ end
+
+ @prompt.say 'Restoring custom_emoji_categories indexes…'
+ ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true
+ end
+
+ def deduplicate_domain_allows!
+ remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain')
+
+ @prompt.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|
+ DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring domain_allows indexes…'
+ ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true
+ end
+
+ def deduplicate_domain_blocks!
+ remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
+
+ @prompt.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|
+ domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
+
+ reject_media = domain_blocks.any?(&:reject_media?)
+ reject_reports = domain_blocks.any?(&:reject_reports?)
+
+ reference_block = domain_blocks.shift
+
+ private_comment = domain_blocks.reduce(reference_block.private_comment.presence) { |a, b| a || b.private_comment.presence }
+ public_comment = domain_blocks.reduce(reference_block.public_comment.presence) { |a, b| a || b.public_comment.presence }
+
+ reference_block.update!(reject_media: reject_media, reject_reports: reject_reports, private_comment: private_comment, public_comment: public_comment)
+
+ domain_blocks.each(&:destroy)
+ end
+
+ @prompt.say 'Restoring domain_blocks indexes…'
+ ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true
+ end
+
+ def deduplicate_unavailable_domains!
+ return unless ActiveRecord::Base.connection.table_exists?(:unavailable_domains)
+
+ remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain')
+
+ @prompt.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|
+ UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring domain_allows indexes…'
+ ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
+ end
+
+ def deduplicate_email_domain_blocks!
+ remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain')
+
+ @prompt.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|
+ domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a
+ domain_blocks.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring email_domain_blocks indexes…'
+ ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true
+ end
+
+ def deduplicate_media_attachments!
+ remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode')
+
+ @prompt.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|
+ MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil)
+ end
+
+ @prompt.say 'Restoring media_attachments indexes…'
+ ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
+ end
+
+ def deduplicate_preview_cards!
+ remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url')
+
+ @prompt.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|
+ PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring preview_cards indexes…'
+ ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true
+ end
+
+ def deduplicate_statuses!
+ remove_index_if_exists!(:statuses, 'index_statuses_on_uri')
+
+ @prompt.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|
+ statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id)
+ ref_status = statuses.shift
+ statuses.each do |status|
+ merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id
+ status.destroy
+ end
+ end
+
+ @prompt.say 'Restoring statuses indexes…'
+ ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
+ end
+
+ def deduplicate_tags!
+ remove_index_if_exists!(:tags, 'index_tags_on_name_lower')
+
+ @prompt.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|
+ tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) }
+ ref_tag = tags.shift
+ tags.each do |tag|
+ merge_tags!(ref_tag, tag)
+ tag.destroy
+ end
+ end
+
+ @prompt.say 'Restoring tags indexes…'
+ ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
+ end
+
+ def deduplicate_webauthn_credentials!
+ return unless ActiveRecord::Base.connection.table_exists?(:webauthn_credentials)
+
+ remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id')
+
+ @prompt.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|
+ WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
+ end
+
+ @prompt.say 'Restoring webauthn_credentials indexes…'
+ ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
+ end
+
+ def deduplicate_local_accounts!(accounts)
+ accounts = accounts.sort_by(&:id).reverse
+
+ @prompt.warn "Multiple local accounts were found for username '#{accounts.first.username}'."
+ @prompt.warn 'All those accounts are distinct accounts but only the most recently-created one is fully-functionnal.'
+
+ accounts.each_with_index do |account, idx|
+ @prompt.say '%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']
+ end
+
+ @prompt.say 'Please chose the one to keep unchanged, other ones will be automatically renamed.'
+
+ ref_id = @prompt.ask('Account to keep unchanged:') do |q|
+ q.required true
+ q.default 0
+ q.convert :int
+ end
+
+ accounts.delete_at(ref_id)
+
+ i = 0
+ accounts.each do |account|
+ i += 1
+ username = account.username + "_#{i}"
+
+ while Account.local.exists?(username: username)
+ i += 1
+ username = account.username + "_#{i}"
+ end
+
+ account.update!(username: username)
+ end
+ end
+
+ def deduplicate_remote_accounts!(accounts)
+ accounts = accounts.sort_by(&:updated_at).reverse
+
+ reference_account = accounts.shift
+
+ accounts.each do |other_account|
+ if other_account.public_key == reference_account.public_key
+ # The accounts definitely point to the same resource, so
+ # it's safe to re-attribute content and relationships
+ reference_account.merge_with!(other_account)
+ end
+
+ other_account.destroy
+ end
+ end
+
+ def merge_conversations!(main_conv, duplicate_conv)
+ owned_classes = [ConversationMute, AccountConversation]
+ owned_classes.each do |klass|
+ klass.where(conversation_id: duplicate_conv.id).find_each do |record|
+ begin
+ record.update_attribute(:account_id, main_conv.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+ end
+
+ def merge_custom_emojis!(main_emoji, duplicate_emoji)
+ owned_classes = [AnnouncementReaction]
+ owned_classes.each do |klass|
+ klass.where(custom_emoji_id: duplicate_emoji.id).update_all(custom_emoji_id: main_emoji.id)
+ end
+ end
+
+ def merge_custom_emoji_categories!(main_category, duplicate_category)
+ owned_classes = [CustomEmoji]
+ owned_classes.each do |klass|
+ klass.where(category_id: duplicate_category.id).update_all(category_id: main_category.id)
+ end
+ end
+
+ def merge_statuses!(main_status, duplicate_status)
+ owned_classes = [Favourite, Mention, Poll]
+ owned_classes << Bookmark if ActiveRecord::Base.connection.table_exists?(:bookmarks)
+ owned_classes.each do |klass|
+ klass.where(status_id: duplicate_status.id).find_each do |record|
+ begin
+ record.update_attribute(:status_id, main_status.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+
+ StatusPin.where(account_id: main_status.account_id, status_id: duplicate_status.id).find_each do |record|
+ begin
+ record.update_attribute(:status_id, main_status.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+
+ Status.where(in_reply_to_id: duplicate_status.id).find_each do |record|
+ begin
+ record.update_attribute(:in_reply_to_id, main_status.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+
+ Status.where(reblog_of_id: duplicate_status.id).find_each do |record|
+ begin
+ record.update_attribute(:reblog_of_id, main_status.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+
+ def merge_tags!(main_tag, duplicate_tag)
+ [FeaturedTag].each do |klass|
+ klass.where(tag_id: duplicate_tag.id).find_each do |record|
+ begin
+ record.update_attribute(:tag_id, main_tag.id)
+ rescue ActiveRecord::RecordNotUnique
+ next
+ end
+ end
+ end
+ end
+
+ def find_duplicate_accounts
+ ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1")
+ end
+
+ def remove_index_if_exists!(table, name)
+ ActiveRecord::Base.connection.remove_index(table, name: name)
+ rescue ArgumentError
+ nil
+ rescue ActiveRecord::StatementInvalid
+ nil
+ end
+ end
+end
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index 54da5b2cd..5f4a414b1 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -47,6 +47,7 @@ module Mastodon
option :start_after
option :prefix
+ option :fix_permissions, type: :boolean, default: false
option :dry_run, type: :boolean, default: false
desc 'remove-orphans', 'Scan storage and check for files that do not belong to existing media attachments'
long_desc <<~LONG_DESC
@@ -66,6 +67,7 @@ module Mastodon
when :s3
paperclip_instance = MediaAttachment.new.file
s3_interface = paperclip_instance.s3_interface
+ s3_permissions = Paperclip::Attachment.default_options[:s3_permissions]
bucket = s3_interface.bucket(Paperclip::Attachment.default_options[:s3_credentials][:bucket])
last_key = options[:start_after]
@@ -86,6 +88,8 @@ module Mastodon
record_map = preload_records_from_mixed_objects(objects)
objects.each do |object|
+ object.acl.put(acl: s3_permissions) if options[:fix_permissions] && !options[:dry_run]
+
path_segments = object.key.split('/')
path_segments.delete('cache')
diff --git a/lib/mastodon/redis_config.rb b/lib/mastodon/redis_config.rb
index e9db9122f..c3c8ff800 100644
--- a/lib/mastodon/redis_config.rb
+++ b/lib/mastodon/redis_config.rb
@@ -23,7 +23,7 @@ end
setup_redis_env_url
setup_redis_env_url(:cache, false)
-namespace = ENV.fetch('REDIS_NAMESPACE') { nil }
+namespace = ENV.fetch('REDIS_NAMESPACE', nil)
cache_namespace = namespace ? namespace + '_cache' : 'cache'
REDIS_CACHE_PARAMS = {
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 7aa6cb2c7..bd0915775 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -9,7 +9,7 @@ module Mastodon
end
def minor
- 2
+ 3
end
def patch
@@ -33,16 +33,16 @@ module Mastodon
end
def repository
- ENV.fetch('GITHUB_REPOSITORY') { 'tootsuite/mastodon' }
+ ENV.fetch('GITHUB_REPOSITORY', 'tootsuite/mastodon')
end
def source_base_url
- ENV.fetch('SOURCE_BASE_URL') { "https://github.com/#{repository}" }
+ ENV.fetch('SOURCE_BASE_URL', "https://github.com/#{repository}")
end
# specify git tag or commit hash here
def source_tag
- ENV.fetch('SOURCE_TAG') { nil }
+ ENV.fetch('SOURCE_TAG', nil)
end
def source_url
diff --git a/lib/paperclip/attachment_extensions.rb b/lib/paperclip/attachment_extensions.rb
index 93df0a326..e25a34213 100644
--- a/lib/paperclip/attachment_extensions.rb
+++ b/lib/paperclip/attachment_extensions.rb
@@ -27,7 +27,7 @@ module Paperclip
return true if original_filename == other_filename
return false if original_filename.nil?
- formats = styles.values.map(&:format).compact
+ formats = styles.values.filter_map(&:format)
return false if formats.empty?
@@ -35,6 +35,27 @@ module Paperclip
formats.include?(other_extension.delete('.')) && File.basename(other_filename, other_extension) == File.basename(original_filename, File.extname(original_filename))
end
+
+ def default_url(style_name = default_style)
+ @url_generator.for_as_default(style_name)
+ end
+
+ STOPLIGHT_THRESHOLD = 10
+ STOPLIGHT_COOLDOWN = 30
+
+ # We overwrite this method to put a circuit breaker around
+ # calls to object storage, to stop hitting APIs that are slow
+ # to respond or don't respond at all and as such minimize the
+ # impact of object storage outages on application throughput
+ def save
+ Stoplight('object-storage') { super }.with_threshold(STOPLIGHT_THRESHOLD).with_cool_off_time(STOPLIGHT_COOLDOWN).with_error_handler do |error, handle|
+ if error.is_a?(Seahorse::Client::NetworkingError)
+ handle.call(error)
+ else
+ raise error
+ end
+ end.run
+ end
end
end
diff --git a/lib/paperclip/color_extractor.rb b/lib/paperclip/color_extractor.rb
index c8bb771a0..a70a3d21f 100644
--- a/lib/paperclip/color_extractor.rb
+++ b/lib/paperclip/color_extractor.rb
@@ -89,7 +89,7 @@ module Paperclip
end
end
- # rubocop:disable Style/MethodParameterName
+ # rubocop:disable Naming/MethodParameterName
def rgb_to_hsl(r, g, b)
r /= 255.0
g /= 255.0
@@ -142,7 +142,7 @@ module Paperclip
g = 0.0
b = 0.0
- if s == 0.0
+ if s.zero?
r = l.to_f
g = l.to_f
b = l.to_f # achromatic
@@ -156,7 +156,7 @@ module Paperclip
[(r * 255).round, (g * 255).round, (b * 255).round]
end
- # rubocop:enable Style/MethodParameterName
+ # rubocop:enable Naming/MethodParameterName
def lighten_or_darken(color, by)
hue, saturation, light = rgb_to_hsl(color.r, color.g, color.b)
diff --git a/lib/paperclip/response_with_limit_adapter.rb b/lib/paperclip/response_with_limit_adapter.rb
index 8711b1349..17a2abd25 100644
--- a/lib/paperclip/response_with_limit_adapter.rb
+++ b/lib/paperclip/response_with_limit_adapter.rb
@@ -16,7 +16,7 @@ module Paperclip
private
def cache_current_values
- @original_filename = filename_from_content_disposition || filename_from_path || 'data'
+ @original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
@size = @target.response.content_length
@tempfile = copy_to_tempfile(@target)
@content_type = ContentTypeDetector.new(@tempfile.path).detect
diff --git a/lib/paperclip/url_generator_extensions.rb b/lib/paperclip/url_generator_extensions.rb
index 1079efdbc..e1d6df2c2 100644
--- a/lib/paperclip/url_generator_extensions.rb
+++ b/lib/paperclip/url_generator_extensions.rb
@@ -11,6 +11,10 @@ module Paperclip
Addressable::URI.parse(url).normalize.to_str.gsub(escape_regex) { |m| "%#{m.ord.to_s(16).upcase}" }
end
end
+
+ def for_as_default(style_name)
+ attachment_options[:interpolator].interpolate(default_url, @attachment, style_name)
+ end
end
end
diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake
index b76e90131..f6c9c7eec 100644
--- a/lib/tasks/db.rake
+++ b/lib/tasks/db.rake
@@ -48,6 +48,20 @@ namespace :db do
end
end
+ task :post_migration_hook do
+ at_exit do
+ unless %w(C POSIX).include?(ActiveRecord::Base.connection.execute('SELECT datcollate FROM pg_database WHERE datname = current_database();').first['datcollate'])
+ warn <<~WARNING
+ Your database collation is susceptible to index corruption.
+ (This warning does not indicate that index corruption has occured and can be ignored)
+ (To learn more, visit: https://docs.joinmastodon.org/admin/troubleshooting/index-corruption/)
+ WARNING
+ end
+ end
+ end
+
+ Rake::Task['db:migrate'].enhance(['db:post_migration_hook'])
+
# Before we load the schema, define the timestamp_id function.
# Idiomatically, we might do this in a migration, but then it
# wouldn't end up in schema.rb, so we'd need to figure out a way to
diff --git a/lib/tasks/emojis.rake b/lib/tasks/emojis.rake
index 0e7921ffc..d0b8fa890 100644
--- a/lib/tasks/emojis.rake
+++ b/lib/tasks/emojis.rake
@@ -91,7 +91,7 @@ namespace :emojis do
desc 'Generate emoji variants with white borders'
task :generate_borders do
src = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json')
- emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
+ emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴🐞🕺👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
map = Oj.load(File.read(src))
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 9e80989ef..2ad1e778b 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -412,7 +412,7 @@ namespace :mastodon do
password = SecureRandom.hex(16)
- user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username })
+ user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true)
user.save(validate: false)
prompt.ok "You can login with the password: #{password}"
diff --git a/lib/webpacker/helper_extensions.rb b/lib/webpacker/helper_extensions.rb
new file mode 100644
index 000000000..8f46d7631
--- /dev/null
+++ b/lib/webpacker/helper_extensions.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Webpacker::HelperExtensions
+ def javascript_pack_tag(name, **options)
+ src, integrity = current_webpacker_instance.manifest.lookup!(name, type: :javascript, with_integrity: true)
+ javascript_include_tag(src, options.merge(integrity: integrity))
+ end
+
+ def stylesheet_pack_tag(name, **options)
+ src, integrity = current_webpacker_instance.manifest.lookup!(name, type: :stylesheet, with_integrity: true)
+ stylesheet_link_tag(src, options.merge(integrity: integrity))
+ end
+
+ def preload_pack_asset(name, **options)
+ src, integrity = current_webpacker_instance.manifest.lookup!(name, with_integrity: true)
+ preload_link_tag(src, options.merge(integrity: integrity))
+ end
+end
+
+Webpacker::Helper.prepend(Webpacker::HelperExtensions)
diff --git a/lib/webpacker/manifest_extensions.rb b/lib/webpacker/manifest_extensions.rb
new file mode 100644
index 000000000..789eb81cc
--- /dev/null
+++ b/lib/webpacker/manifest_extensions.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Webpacker::ManifestExtensions
+ def lookup(name, pack_type = {})
+ asset = super
+
+ if pack_type[:with_integrity] && asset.respond_to?(:dig)
+ [asset.dig('src'), asset.dig('integrity')]
+ elsif asset.respond_to?(:dig)
+ asset.dig('src')
+ else
+ asset
+ end
+ end
+end
+
+Webpacker::Manifest.prepend(Webpacker::ManifestExtensions)