From a3b5675aa83f7fec4036888e0d94b626ad973f75 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Dec 2020 05:09:14 +0100 Subject: [PATCH 01/13] Change number format on about page from full to shortened (#15327) --- app/views/about/more.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index c2168e1f5..109b5fa86 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -17,11 +17,11 @@ .row__information-board .information-board__section %span= t 'about.user_count_before' - %strong= number_with_delimiter @instance_presenter.user_count + %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true %span= t 'about.user_count_after', count: @instance_presenter.user_count .information-board__section %span= t 'about.status_count_before' - %strong= number_with_delimiter @instance_presenter.status_count + %strong= number_to_human @instance_presenter.status_count, strip_insignificant_zeros: true %span= t 'about.status_count_after', count: @instance_presenter.status_count .row__mascot .landing-page__mascot From 216b85b053d091306e3311a21f5b050f70a75130 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Dec 2020 09:06:34 +0100 Subject: [PATCH 02/13] Fix performance on instances list in admin UI (#15282) - Reduce duplicate queries - Remove n+1 queries - Add accounts count to detailed view - Add separate action log entry for updating existing domain blocks --- Gemfile | 1 + Gemfile.lock | 4 + .../admin/domain_blocks_controller.rb | 5 +- app/controllers/admin/instances_controller.rb | 44 +------- .../api/v1/instances/peers_controller.rb | 2 +- app/models/account.rb | 6 +- app/models/concerns/domain_materializable.rb | 13 +++ app/models/domain_allow.rb | 1 + app/models/domain_block.rb | 1 + app/models/instance.rb | 65 ++++++++--- app/models/instance_filter.rb | 31 ++++-- app/models/unavailable_domain.rb | 2 + app/policies/domain_block_policy.rb | 4 + app/presenters/instance_presenter.rb | 2 +- app/views/admin/instances/_instance.html.haml | 25 +++++ app/views/admin/instances/index.html.haml | 34 ++---- app/views/admin/instances/show.html.haml | 60 ++++++----- .../scheduler/instance_refresh_scheduler.rb | 11 ++ config/brakeman.ignore | 101 +++++++++++++++--- config/locales/en.yml | 3 + config/sidekiq.yml | 3 + db/migrate/20201206004238_create_instances.rb | 9 ++ db/schema.rb | 27 ++++- db/views/instances_v01.sql | 17 +++ lib/mastodon/domains_cli.rb | 4 +- .../admin/instances_controller_spec.rb | 6 +- spec/models/account_spec.rb | 21 ---- 27 files changed, 331 insertions(+), 171 deletions(-) create mode 100644 app/models/concerns/domain_materializable.rb create mode 100644 app/views/admin/instances/_instance.html.haml create mode 100644 app/workers/scheduler/instance_refresh_scheduler.rb create mode 100644 db/migrate/20201206004238_create_instances.rb create mode 100644 db/views/instances_v01.sql diff --git a/Gemfile b/Gemfile index 63becb7cc..3aee5d7cc 100644 --- a/Gemfile +++ b/Gemfile @@ -82,6 +82,7 @@ gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 1.1' gem 'ruby-progressbar', '~> 1.10' gem 'sanitize', '~> 5.2' +gem 'scenic', '~> 1.5' gem 'sidekiq', '~> 6.1' gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-unique-jobs', '~> 6.0' diff --git a/Gemfile.lock b/Gemfile.lock index f7192d084..c4c8d9904 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -561,6 +561,9 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) + scenic (1.5.4) + activerecord (>= 4.0.0) + railties (>= 4.0.0) securecompare (1.0.0) semantic_range (2.3.0) sidekiq (6.1.2) @@ -782,6 +785,7 @@ DEPENDENCIES rubocop-rails (~> 2.8) ruby-progressbar (~> 1.10) sanitize (~> 5.2) + scenic (~> 1.5) sidekiq (~> 6.1) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 74a36b79c..6a5b41a74 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -29,6 +29,7 @@ module Admin @domain_block = existing_domain_block @domain_block.update(resource_params) end + if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id) log_action :create, @domain_block @@ -40,7 +41,7 @@ module Admin end def update - authorize :domain_block, :create? + authorize :domain_block, :update? @domain_block.update(update_params) @@ -48,7 +49,7 @@ module Admin if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id, severity_changed) - log_action :create, @domain_block + log_action :update, @domain_block redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') else render :edit diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 1790becbf..b5918d231 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -2,65 +2,31 @@ module Admin class InstancesController < BaseController - before_action :set_domain_block, only: :show - before_action :set_domain_allow, only: :show + before_action :set_instances, only: :index before_action :set_instance, only: :show def index authorize :instance, :index? - - @instances = ordered_instances end def show authorize :instance, :show? - - @following_count = Follow.where(account: Account.where(domain: params[:id])).count - @followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count - @reports_count = Report.where(target_account: Account.where(domain: params[:id])).count - @blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count - @available = DeliveryFailureTracker.available?(params[:id]) - @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) - @private_comment = @domain_block&.private_comment - @public_comment = @domain_block&.public_comment end private - def set_domain_block - @domain_block = DomainBlock.rule_for(params[:id]) - end - - def set_domain_allow - @domain_allow = DomainAllow.rule_for(params[:id]) - end - def set_instance - resource = Account.by_domain_accounts.find_by(domain: params[:id]) - resource ||= @domain_block - resource ||= @domain_allow + @instance = Instance.find(params[:id]) + end - if resource - @instance = Instance.new(resource) - else - not_found - end + def set_instances + @instances = filtered_instances.page(params[:page]) end def filtered_instances InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results end - def paginated_instances - filtered_instances.page(params[:page]) - end - - helper_method :paginated_instances - - def ordered_instances - paginated_instances.map { |resource| Instance.new(resource) } - end - def filter_params params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS) end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 9fa440935..2877fec52 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Instances::PeersController < Api::BaseController def index expires_in 1.day, public: true - render_with_cache(expires_in: 1.day) { Account.remote.domains } + render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } end private diff --git a/app/models/account.rb b/app/models/account.rb index ed11a514d..e21b353e9 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -67,6 +67,7 @@ class Account < ApplicationRecord include Paginable include AccountCounters include DomainNormalizable + include DomainMaterializable include AccountMerging TRUST_LEVELS = { @@ -103,7 +104,6 @@ class Account < ApplicationRecord scope :bots, -> { where(actor_type: %w(Application Service)) } scope :groups, -> { where(actor_type: 'Group') } scope :alphabetic, -> { order(domain: :asc, username: :asc) } - scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') } scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } @@ -438,10 +438,6 @@ class Account < ApplicationRecord super - %w(statuses_count following_count followers_count) end - def domains - reorder(nil).pluck(Arel.sql('distinct accounts.domain')) - end - def inboxes urls = reorder(nil).where(protocol: :activitypub).group(:preferred_inbox_url).pluck(Arel.sql("coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url) AS preferred_inbox_url")) DeliveryFailureTracker.without_unavailable(urls) diff --git a/app/models/concerns/domain_materializable.rb b/app/models/concerns/domain_materializable.rb new file mode 100644 index 000000000..88337f8c0 --- /dev/null +++ b/app/models/concerns/domain_materializable.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DomainMaterializable + extend ActiveSupport::Concern + + included do + after_create_commit :refresh_instances_view + end + + def refresh_instances_view + Instance.refresh unless domain.nil? || Instance.where(domain: domain).exists? + end +end diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 5fe0e3a29..4b0a89c18 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -12,6 +12,7 @@ class DomainAllow < ApplicationRecord include DomainNormalizable + include DomainMaterializable validates :domain, presence: true, uniqueness: true, domain: true diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 2b18e01fa..829d7583b 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -16,6 +16,7 @@ class DomainBlock < ApplicationRecord include DomainNormalizable + include DomainMaterializable enum severity: [:silence, :suspend, :noop] diff --git a/app/models/instance.rb b/app/models/instance.rb index 3c740f8a2..29be03662 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -1,26 +1,63 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: instances +# +# domain :string primary key +# accounts_count :bigint(8) +# -class Instance - include ActiveModel::Model +class Instance < ApplicationRecord + self.primary_key = :domain - attr_accessor :domain, :accounts_count, :domain_block + has_many :accounts, foreign_key: :domain, primary_key: :domain - def initialize(resource) - @domain = resource.domain - @accounts_count = resource.respond_to?(:accounts_count) ? resource.accounts_count : nil - @domain_block = resource.is_a?(DomainBlock) ? resource : DomainBlock.rule_for(domain) - @domain_allow = resource.is_a?(DomainAllow) ? resource : DomainAllow.rule_for(domain) + belongs_to :domain_block, foreign_key: :domain, primary_key: :domain + belongs_to :domain_allow, foreign_key: :domain, primary_key: :domain + + scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + + def self.refresh + Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) end - def countable? - @accounts_count.present? + def readonly? + true + end + + def delivery_failure_tracker + @delivery_failure_tracker ||= DeliveryFailureTracker.new(domain) + end + + def following_count + @following_count ||= Follow.where(account: accounts).count + end + + def followers_count + @followers_count ||= Follow.where(target_account: accounts).count + end + + def reports_count + @reports_count ||= Report.where(target_account: accounts).count + end + + def blocks_count + @blocks_count ||= Block.where(target_account: accounts).count + end + + def public_comment + domain_block&.public_comment + end + + def private_comment + domain_block&.private_comment + end + + def media_storage + @media_storage ||= MediaAttachment.where(account: accounts).sum(:file_file_size) end def to_param domain end - - def cache_key - domain - end end diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb index 9c467bc27..0598d8fea 100644 --- a/app/models/instance_filter.rb +++ b/app/models/instance_filter.rb @@ -13,18 +13,27 @@ class InstanceFilter end def results - if params[:limited].present? - scope = DomainBlock - scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present? - scope.order(id: :desc) - elsif params[:allowed].present? - scope = DomainAllow - scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present? - scope.order(id: :desc) + scope = Instance.includes(:domain_block, :domain_allow).order(accounts_count: :desc) + + params.each do |key, value| + scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when 'limited' + Instance.joins(:domain_block).reorder(Arel.sql('domain_blocks.id desc')) + when 'allowed' + Instance.joins(:domain_allow).reorder(Arel.sql('domain_allows.id desc')) + when 'by_domain' + Instance.matches_domain(value) else - scope = Account.remote - scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present? - scope.by_domain_accounts + raise "Unknown filter: #{key}" end end end diff --git a/app/models/unavailable_domain.rb b/app/models/unavailable_domain.rb index e2918b586..5e8870bde 100644 --- a/app/models/unavailable_domain.rb +++ b/app/models/unavailable_domain.rb @@ -12,6 +12,8 @@ class UnavailableDomain < ApplicationRecord include DomainNormalizable + validates :domain, presence: true, uniqueness: true + after_commit :reset_cache! private diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb index 47c0a81af..543259cce 100644 --- a/app/policies/domain_block_policy.rb +++ b/app/policies/domain_block_policy.rb @@ -13,6 +13,10 @@ class DomainBlockPolicy < ApplicationPolicy admin? end + def update? + admin? + end + def destroy? admin? end diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index c150bf742..1bfdd40ac 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -29,7 +29,7 @@ class InstancePresenter end def domain_count - Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) } + Rails.cache.fetch('distinct_domain_count') { Instance.count } end def sample_accounts diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml new file mode 100644 index 000000000..188d0d984 --- /dev/null +++ b/app/views/admin/instances/_instance.html.haml @@ -0,0 +1,25 @@ +.directory__tag + = link_to admin_instance_path(instance) do + %h4 + = instance.domain + %small + - if instance.domain_block + - first_item = true + - if !instance.domain_block.noop? + = t("admin.domain_blocks.severity.#{instance.domain_block.severity}") + - first_item = false + - unless instance.domain_block.suspend? + - if instance.domain_block.reject_media? + - unless first_item + • + = t('admin.domain_blocks.rejecting_media') + - first_item = false + - if instance.domain_block.reject_reports? + - unless first_item + • + = t('admin.domain_blocks.rejecting_reports') + - elsif whitelist_mode? + = t('admin.accounts.whitelisted') + - else + = t('admin.accounts.no_limits_imposed') + .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= number_to_human instance.accounts_count, strip_insignificant_zeros: true diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml index 696ba3c7f..5f20e7ec0 100644 --- a/app/views/admin/instances/index.html.haml +++ b/app/views/admin/instances/index.html.haml @@ -32,32 +32,10 @@ %hr.spacer/ -- @instances.each do |instance| - .directory__tag - = link_to admin_instance_path(instance) do - %h4 - = instance.domain - %small - - if instance.domain_block - - first_item = true - - if !instance.domain_block.noop? - = t("admin.domain_blocks.severity.#{instance.domain_block.severity}") - - first_item = false - - unless instance.domain_block.suspend? - - if instance.domain_block.reject_media? - - unless first_item - • - = t('admin.domain_blocks.rejecting_media') - - first_item = false - - if instance.domain_block.reject_reports? - - unless first_item - • - = t('admin.domain_blocks.rejecting_reports') - - elsif whitelist_mode? - = t('admin.accounts.whitelisted') - - else - = t('admin.accounts.no_limits_imposed') - - if instance.countable? - .trends__item__current{ title: t('admin.instances.known_accounts', count: instance.accounts_count) }= number_to_human instance.accounts_count, strip_insignificant_zeros: true +- if @instances.empty? + %div.muted-hint.center-text + = t 'admin.instances.empty' +- else + = render @instances -= paginate paginated_instances += paginate @instances diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index 92e14c0df..0b9382771 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -3,57 +3,59 @@ .dashboard__counters %div - %div - .dashboard__counters__num= number_with_delimiter @following_count - .dashboard__counters__label= t 'admin.instances.total_followed_by_them' - %div - %div - .dashboard__counters__num= number_with_delimiter @followers_count - .dashboard__counters__label= t 'admin.instances.total_followed_by_us' - %div - %div - .dashboard__counters__num= number_to_human_size @media_storage - .dashboard__counters__label= t 'admin.instances.total_storage' - %div - %div - .dashboard__counters__num= number_with_delimiter @blocks_count - .dashboard__counters__label= t 'admin.instances.total_blocked_by_us' + = link_to admin_accounts_path(remote: '1', by_domain: @instance.domain) do + .dashboard__counters__num= number_with_delimiter @instance.accounts_count + .dashboard__counters__label= t 'admin.accounts.title' %div = link_to admin_reports_path(by_target_domain: @instance.domain) do - .dashboard__counters__num= number_with_delimiter @reports_count + .dashboard__counters__num= number_with_delimiter @instance.reports_count .dashboard__counters__label= t 'admin.instances.total_reported' + %div + %div + .dashboard__counters__num= number_to_human_size @instance.media_storage + .dashboard__counters__label= t 'admin.instances.total_storage' + %div + %div + .dashboard__counters__num= number_with_delimiter @instance.following_count + .dashboard__counters__label= t 'admin.instances.total_followed_by_them' + %div + %div + .dashboard__counters__num= number_with_delimiter @instance.followers_count + .dashboard__counters__label= t 'admin.instances.total_followed_by_us' + %div + %div + .dashboard__counters__num= number_with_delimiter @instance.blocks_count + .dashboard__counters__label= t 'admin.instances.total_blocked_by_us' + %div %div .dashboard__counters__num - - if @available + - if @instance.delivery_failure_tracker.available? = fa_icon 'check' - else = fa_icon 'times' .dashboard__counters__label= t 'admin.instances.delivery_available' -- if @private_comment.present? +- if @instance.private_comment.present? .speech-bubble .speech-bubble__bubble - = simple_format(h(@private_comment)) + = simple_format(h(@instance.private_comment)) .speech-bubble__owner= t 'admin.instances.private_comment' -- if @public_comment.present? +- if @instance.public_comment.present? .speech-bubble .speech-bubble__bubble - = simple_format(h(@public_comment)) + = simple_format(h(@instance.public_comment)) .speech-bubble__owner= t 'admin.instances.public_comment' %hr.spacer/ %div.action-buttons %div - = link_to t('admin.accounts.title'), admin_accounts_path(remote: '1', by_domain: @instance.domain), class: 'button' - - %div - - if @domain_allow - = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } - - elsif @domain_block - = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@domain_block), class: 'button' - = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@domain_block), class: 'button' + - if @instance.domain_allow + = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@instance.domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } + - elsif @instance.domain_block + = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button' + = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button' - else = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button' diff --git a/app/workers/scheduler/instance_refresh_scheduler.rb b/app/workers/scheduler/instance_refresh_scheduler.rb new file mode 100644 index 000000000..917404bec --- /dev/null +++ b/app/workers/scheduler/instance_refresh_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Scheduler::InstanceRefreshScheduler + include Sidekiq::Worker + + sidekiq_options lock: :until_executed, retry: 0 + + def perform + Instance.refresh + end +end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index baa993c78..dcbfd02b4 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -102,6 +102,37 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "4704e8093e3e0561bf705f892e8fc6780419f8255f4440b1c0afd09339bd6446", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/admin/instances/index.html.haml", + "line": 39, + "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => filtered_instances.page(params[:page]), {})", + "render_path": [ + { + "type": "controller", + "class": "Admin::InstancesController", + "method": "index", + "line": 10, + "file": "app/controllers/admin/instances_controller.rb", + "rendered": { + "name": "admin/instances/index", + "file": "app/views/admin/instances/index.html.haml" + } + } + ], + "location": { + "type": "template", + "template": "admin/instances/index" + }, + "user_input": "params[:page]", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Redirect", "warning_code": 18, @@ -122,6 +153,26 @@ "confidence": "High", "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "6e4051854bb62e2ddbc671f82d6c2328892e1134b8b28105ecba9b0122540714", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/account.rb", + "line": 491, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "find_by_sql([\" WITH first_degree AS (\\n SELECT target_account_id\\n FROM follows\\n WHERE account_id = ?\\n UNION ALL\\n SELECT ?\\n )\\n SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n WHERE accounts.id IN (SELECT * FROM first_degree)\\n AND #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])", + "render_path": null, + "location": { + "type": "method", + "class": "Account", + "method": "advanced_search_for" + }, + "user_input": "textsearch", + "confidence": "Medium", + "note": "" + }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -163,23 +214,23 @@ "note": "" }, { - "warning_type": "Mass Assignment", - "warning_code": 105, - "fingerprint": "8f63dec68951d9bcf7eddb15af9392b2e1333003089c41fb76688dfd3579f394", - "check_name": "PermitAttributes", - "message": "Potentially dangerous key allowed for mass assignment", - "file": "app/controllers/api/v1/crypto/deliveries_controller.rb", - "line": 23, - "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", - "code": "params.require(:device).permit(:account_id, :device_id, :type, :body, :hmac)", + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/account.rb", + "line": 460, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "find_by_sql([\" SELECT\\n accounts.*,\\n ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, limit, offset])", "render_path": null, "location": { "type": "method", - "class": "Api::V1::Crypto::DeliveriesController", - "method": "resource_params" + "class": "Account", + "method": "search_for" }, - "user_input": ":account_id", - "confidence": "High", + "user_input": "textsearch", + "confidence": "Medium", "note": "" }, { @@ -273,6 +324,26 @@ "confidence": "High", "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "e21d8fee7a5805761679877ca35ed1029c64c45ef3b4012a30262623e1ba8bb9", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/account.rb", + "line": 507, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "find_by_sql([\" SELECT\\n accounts.*,\\n (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n FROM accounts\\n LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n WHERE #{query} @@ #{textsearch}\\n AND accounts.suspended_at IS NULL\\n AND accounts.moved_to_account_id IS NULL\\n GROUP BY accounts.id\\n ORDER BY rank DESC\\n LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])", + "render_path": null, + "location": { + "type": "method", + "class": "Account", + "method": "advanced_search_for" + }, + "user_input": "textsearch", + "confidence": "Medium", + "note": "" + }, { "warning_type": "Mass Assignment", "warning_code": 105, @@ -294,6 +365,6 @@ "note": "" } ], - "updated": "2020-06-01 18:18:02 +0200", - "brakeman_version": "4.8.0" + "updated": "2020-12-07 01:17:13 +0100", + "brakeman_version": "4.10.0" } diff --git a/config/locales/en.yml b/config/locales/en.yml index 59f561aa3..f89f50e4d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -255,6 +255,7 @@ en: unsuspend_account: Unsuspend Account update_announcement: Update Announcement update_custom_emoji: Update Custom Emoji + update_domain_block: Update Domain Block update_status: Update Status actions: assigned_to_self_report: "%{name} assigned report %{target} to themselves" @@ -295,6 +296,7 @@ en: unsuspend_account: "%{name} unsuspended %{target}'s account" update_announcement: "%{name} updated announcement %{target}" update_custom_emoji: "%{name} updated emoji %{target}" + update_domain_block: "%{name} updated domain block for %{target}" update_status: "%{name} updated status by %{target}" deleted_status: "(deleted status)" empty: No logs found. @@ -437,6 +439,7 @@ en: instances: by_domain: Domain delivery_available: Delivery is available + empty: No domains found. known_accounts: one: "%{count} known account" other: "%{count} known accounts" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 5de25de23..a71c1098e 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -36,3 +36,6 @@ pghero_scheduler: cron: '0 0 * * *' class: Scheduler::PgheroScheduler + instance_refresh_scheduler: + cron: '0 * * * *' + class: Scheduler::InstanceRefreshScheduler diff --git a/db/migrate/20201206004238_create_instances.rb b/db/migrate/20201206004238_create_instances.rb new file mode 100644 index 000000000..a4b866894 --- /dev/null +++ b/db/migrate/20201206004238_create_instances.rb @@ -0,0 +1,9 @@ +class CreateInstances < ActiveRecord::Migration[5.2] + def change + create_view :instances, materialized: true + + # To be able to refresh the view concurrently, + # at least one unique index is required + safety_assured { add_index :instances, :domain, unique: true } + end +end diff --git a/db/schema.rb b/db/schema.rb index 873c37f67..2f9d369be 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_10_17_234926) do +ActiveRecord::Schema.define(version: 2020_12_06_004238) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1045,4 +1045,29 @@ ActiveRecord::Schema.define(version: 2020_10_17_234926) do add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade add_foreign_key "webauthn_credentials", "users" + + create_view "instances", materialized: true, sql_definition: <<-SQL + WITH domain_counts(domain, accounts_count) AS ( + SELECT accounts.domain, + count(*) AS accounts_count + FROM accounts + WHERE (accounts.domain IS NOT NULL) + GROUP BY accounts.domain + ) + SELECT domain_counts.domain, + domain_counts.accounts_count + FROM domain_counts + UNION + SELECT domain_blocks.domain, + COALESCE(domain_counts.accounts_count, (0)::bigint) AS accounts_count + FROM (domain_blocks + LEFT JOIN domain_counts ON (((domain_counts.domain)::text = (domain_blocks.domain)::text))) + UNION + SELECT domain_allows.domain, + COALESCE(domain_counts.accounts_count, (0)::bigint) AS accounts_count + FROM (domain_allows + LEFT JOIN domain_counts ON (((domain_counts.domain)::text = (domain_allows.domain)::text))); + SQL + add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true + end diff --git a/db/views/instances_v01.sql b/db/views/instances_v01.sql new file mode 100644 index 000000000..94acd61a1 --- /dev/null +++ b/db/views/instances_v01.sql @@ -0,0 +1,17 @@ +WITH domain_counts(domain, accounts_count) +AS ( + SELECT domain, COUNT(*) as accounts_count + FROM accounts + WHERE domain IS NOT NULL + GROUP BY domain +) +SELECT domain, accounts_count +FROM domain_counts +UNION +SELECT domain_blocks.domain, COALESCE(domain_counts.accounts_count, 0) +FROM domain_blocks +LEFT OUTER JOIN domain_counts ON domain_counts.domain = domain_blocks.domain +UNION +SELECT domain_allows.domain, COALESCE(domain_counts.accounts_count, 0) +FROM domain_allows +LEFT OUTER JOIN domain_counts ON domain_counts.domain = domain_allows.domain diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb index 5433ddd9d..3c2dfd4ec 100644 --- a/lib/mastodon/domains_cli.rb +++ b/lib/mastodon/domains_cli.rb @@ -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/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb index 412b81443..8c0b309f2 100644 --- a/spec/controllers/admin/instances_controller_spec.rb +++ b/spec/controllers/admin/instances_controller_spec.rb @@ -9,10 +9,10 @@ RSpec.describe Admin::InstancesController, type: :controller do describe 'GET #index' do around do |example| - default_per_page = Account.default_per_page - Account.paginates_per 1 + default_per_page = Instance.default_per_page + Instance.paginates_per 1 example.run - Account.paginates_per default_per_page + Instance.paginates_per default_per_page end it 'renders instances' do diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 75f628076..1d000ed4d 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -440,13 +440,6 @@ RSpec.describe Account, type: :model do end end - describe '.domains' do - it 'returns domains' do - Fabricate(:account, domain: 'domain') - expect(Account.remote.domains).to match_array(['domain']) - end - end - describe '#statuses_count' do subject { Fabricate(:account) } @@ -737,20 +730,6 @@ RSpec.describe Account, type: :model do end end - describe 'by_domain_accounts' do - it 'returns accounts grouped by domain sorted by accounts' do - 2.times { Fabricate(:account, domain: 'example.com') } - Fabricate(:account, domain: 'example2.com') - - results = Account.where('id > 0').by_domain_accounts - expect(results.length).to eq 2 - expect(results.first.domain).to eq 'example.com' - expect(results.first.accounts_count).to eq 2 - expect(results.last.domain).to eq 'example2.com' - expect(results.last.accounts_count).to eq 1 - end - end - describe 'local' do it 'returns an array of accounts who do not have a domain' do account_1 = Fabricate(:account, domain: nil) From 4fd306200396a17c030c53483ccd7faa6c2f7291 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Dec 2020 09:08:09 +0100 Subject: [PATCH 03/13] Change "Profile unavailable" string to "Account suspended" in web UI (#15345) --- .../mastodon/features/account_gallery/index.js | 10 +++++++++- .../mastodon/features/account_timeline/index.js | 4 +++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 597ca8af6..015a6a6d7 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -152,6 +152,14 @@ class AccountGallery extends ImmutablePureComponent { loadOlder = ; } + let emptyMessage; + + if (suspended) { + emptyMessage = ; + } else if (blockedBy) { + emptyMessage = ; + } + return ( @@ -162,7 +170,7 @@ class AccountGallery extends ImmutablePureComponent { {(suspended || blockedBy) ? (
- + {emptyMessage}
) : (
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index cbc859805..fa4239d6f 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -136,7 +136,9 @@ class AccountTimeline extends ImmutablePureComponent { let emptyMessage; - if (suspended || blockedBy) { + if (suspended) { + emptyMessage = ; + } else if (blockedBy) { emptyMessage = ; } else if (remote && statusIds.isEmpty()) { emptyMessage = ; From a7e819b8a8de25414324e354baa62e9abbd3e8c4 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 14 Dec 2020 09:37:58 +0100 Subject: [PATCH 04/13] =?UTF-8?q?Fix=20dynamic=20updating=20of=20=E2=80=9C?= =?UTF-8?q?Bootstrap=20timeline=20accounts=E2=80=9D=20admin=20setting=20(#?= =?UTF-8?q?15325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claire --- app/javascript/packs/admin.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index 65b8dc040..1c44fb45b 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -57,8 +57,10 @@ const onEnableBootstrapTimelineAccountsChange = (target) => { bootstrapTimelineAccountsField.disabled = !target.checked; if (target.checked) { bootstrapTimelineAccountsField.parentElement.classList.remove('disabled'); + bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled'); } else { bootstrapTimelineAccountsField.parentElement.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled'); } } }; From 47e507fa61be6dc39dd9821e1d07c33e993cc246 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 14 Dec 2020 10:03:09 +0100 Subject: [PATCH 05/13] Add ability to require invite request text (#15326) Fixes #15273 Co-authored-by: Claire --- app/javascript/packs/admin.js | 26 ++++++++++++++++++++++ app/javascript/styles/mastodon/forms.scss | 15 ++++++++----- app/models/form/admin_settings.rb | 2 ++ app/models/user.rb | 3 ++- app/views/about/_registration.html.haml | 2 +- app/views/admin/settings/edit.html.haml | 6 +++++ app/views/auth/registrations/new.html.haml | 2 +- config/locales/en.yml | 3 +++ config/settings.yml | 1 + 9 files changed, 52 insertions(+), 8 deletions(-) diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index 1c44fb45b..8fd1b8a8e 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -67,10 +67,36 @@ const onEnableBootstrapTimelineAccountsChange = (target) => { delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target)); +const onChangeRegistrationMode = (target) => { + const enabled = target.value === 'approved'; + + [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => { + input.disabled = !enabled; + if (enabled) { + let element = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); + ready(() => { const domainBlockSeverityInput = document.getElementById('domain_block_severity'); if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput); const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts'); if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); + if (registrationMode) onChangeRegistrationMode(registrationMode); }); diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 92d89e6f2..e0604303b 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -377,11 +377,6 @@ code { box-shadow: none; } - &:focus:invalid:not(:placeholder-shown), - &:required:invalid:not(:placeholder-shown) { - border-color: lighten($error-red, 12%); - } - &:required:valid { border-color: $valid-value-color; } @@ -397,6 +392,16 @@ code { } } + input[type=text], + input[type=number], + input[type=email], + input[type=password] { + &:focus:invalid:not(:placeholder-shown), + &:required:invalid:not(:placeholder-shown) { + border-color: lighten($error-red, 12%); + } + } + .input.field_with_errors { label { color: lighten($error-red, 12%); diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 390836f28..e9f78da21 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -35,6 +35,7 @@ class Form::AdminSettings show_domain_blocks show_domain_blocks_rationale noindex + require_invite_text ).freeze BOOLEAN_KEYS = %i( @@ -51,6 +52,7 @@ class Form::AdminSettings trends trendable_by_default noindex + require_invite_text ).freeze UPLOAD_KEYS = %i( diff --git a/app/models/user.rb b/app/models/user.rb index 981dc6d47..6088f1994 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -82,7 +82,8 @@ class User < ApplicationRecord has_many :webauthn_credentials, dependent: :destroy has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy - accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? } + accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text } + validates :invite_request, presence: true, on: :create, if: -> { Setting.require_invite_text } validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates_with BlacklistedEmailValidator, on: :create diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index 6160ca4d4..e4d614d71 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -16,7 +16,7 @@ - if approved_registrations? .fields-group = f.simple_fields_for :invite_request do |invite_request_fields| - = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false + = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text .fields-group = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), required: true, disabled: closed_registrations? diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 9e28766b1..a162490b5 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -43,6 +43,12 @@ %hr.spacer/ + .fields-group + = f.input :require_invite_text, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.require_invite_text.title'), hint: t('admin.settings.registrations.require_invite_text.desc_html'), disabled: !approved_registrations? + .fields-group + + %hr.spacer/ + .fields-group = f.input :enable_bootstrap_timeline_accounts, as: :boolean, wrapper: :with_label, label: t('admin.settings.enable_bootstrap_timeline_accounts.title') .fields-group diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index de541847f..6981195ed 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -31,7 +31,7 @@ - if approved_registrations? && !@invite.present? .fields-group = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields| - = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: false + = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text = f.input :invite_code, as: :hidden diff --git a/config/locales/en.yml b/config/locales/en.yml index f89f50e4d..114f86fd1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -586,6 +586,9 @@ en: min_invite_role: disabled: No one title: Allow invitations by + require_invite_text: + desc_html: When registrations require manual approval, make the “Why do you want to join?” invite request text mandatory rather than optional + title: Require new users to fill an invite request text registrations_mode: modes: approved: Approval required for sign up diff --git a/config/settings.yml b/config/settings.yml index 217745f28..9cf68a096 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -70,6 +70,7 @@ defaults: &defaults spam_check_enabled: true show_domain_blocks: 'disabled' show_domain_blocks_rationale: 'disabled' + require_invite_text: false development: <<: *defaults From 9342705e6bfc4f0e4f999bd3c1bb70d425fcb598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Miko=C5=82ajczak?= Date: Tue, 15 Dec 2020 02:04:56 +0100 Subject: [PATCH 06/13] Add app shortcuts (#15234) * Add app shortcuts Signed-off-by: mkljczk * Fix codeclimate issues Signed-off-by: mkljczk * Change shortcuts Signed-off-by: mkljczk * More consistent new-status icon Signed-off-by: mkljczk --- app/serializers/manifest_serializer.rb | 40 ++++++++++++++++++++++++- public/shortcuts/direct.png | Bin 0 -> 3045 bytes public/shortcuts/new-status.png | Bin 0 -> 3031 bytes public/shortcuts/notifications.png | Bin 0 -> 3067 bytes public/shortcuts/profile.png | Bin 0 -> 4190 bytes 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 public/shortcuts/direct.png create mode 100644 public/shortcuts/new-status.png create mode 100644 public/shortcuts/notifications.png create mode 100644 public/shortcuts/profile.png diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb index 21ec0d4be..dafe8f55b 100644 --- a/app/serializers/manifest_serializer.rb +++ b/app/serializers/manifest_serializer.rb @@ -7,7 +7,7 @@ class ManifestSerializer < ActiveModel::Serializer attributes :name, :short_name, :description, :icons, :theme_color, :background_color, :display, :start_url, :scope, - :share_target + :share_target, :shortcuts def name object.site_title @@ -64,4 +64,42 @@ class ManifestSerializer < ActiveModel::Serializer }, } end + + def shortcuts + [ + { + name: 'New toot', + url: '/web/statuses/new', + icons: [ + { + src: '/shortcuts/new-status.png', + type: 'image/png', + sizes: '192x192', + }, + ], + }, + { + name: 'Notifications', + url: '/web/notifications', + icons: [ + { + src: '/shortcuts/notifications.png', + type: 'image/png', + sizes: '192x192', + }, + ], + }, + { + name: 'Direct messages', + url: '/web/timelines/direct', + icons: [ + { + src: '/shortcuts/direct.png', + type: 'image/png', + sizes: '192x192', + }, + ], + }, + ] + end end diff --git a/public/shortcuts/direct.png b/public/shortcuts/direct.png new file mode 100644 index 0000000000000000000000000000000000000000..e8772c00e664042779abc16be73407e3e2cb0e89 GIT binary patch literal 3045 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 Z`ai>UH@>A_0vG@Q002ovPDHLkV1n|Rqn-c& literal 0 HcmV?d00001 diff --git a/public/shortcuts/notifications.png b/public/shortcuts/notifications.png new file mode 100644 index 0000000000000000000000000000000000000000..6b9d45718c82784e5c3380dcc9e4cd3fadb9e9b2 GIT binary patch literal 3067 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 literal 0 HcmV?d00001 From a2ef002214572f8166413d989bf2f38768a6910d Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Tue, 15 Dec 2020 11:04:03 +0900 Subject: [PATCH 07/13] Fix to isolate the sidekiq process that runs the scheduler job (#15314) --- boxfile.yml | 1 + config/sidekiq.yml | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/boxfile.yml b/boxfile.yml index c4fd19ce6..c1d89bb15 100644 --- a/boxfile.yml +++ b/boxfile.yml @@ -110,6 +110,7 @@ worker.sidekiq: mailers: bundle exec sidekiq -c 5 -q mailers -L /app/log/sidekiq.log pull: bundle exec sidekiq -c 5 -q pull -L /app/log/sidekiq.log push: bundle exec sidekiq -c 5 -q push -L /app/log/sidekiq.log + scheduler: bundle exec sidekiq -c 5 -q scheduler -L /app/log/sidekiq.log writable_dirs: - tmp diff --git a/config/sidekiq.yml b/config/sidekiq.yml index a71c1098e..010923717 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -5,37 +5,51 @@ - [push, 4] - [mailers, 2] - [pull] + - [scheduler] +:scheduler: + :listened_queues_only: true :schedule: scheduled_statuses_scheduler: every: '5m' class: Scheduler::ScheduledStatusesScheduler + queue: scheduler trending_tags_scheduler: every: '5m' class: Scheduler::TrendingTagsScheduler + queue: scheduler media_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' class: Scheduler::MediaCleanupScheduler + queue: scheduler feed_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * *' class: Scheduler::FeedCleanupScheduler + queue: scheduler doorkeeper_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * 0' class: Scheduler::DoorkeeperCleanupScheduler + queue: scheduler user_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(4..6) %> * * *' class: Scheduler::UserCleanupScheduler + queue: scheduler ip_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' class: Scheduler::IpCleanupScheduler + queue: scheduler email_scheduler: cron: '0 10 * * 2' class: Scheduler::EmailScheduler + queue: scheduler backup_cleanup_scheduler: cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *' class: Scheduler::BackupCleanupScheduler + queue: scheduler pghero_scheduler: cron: '0 0 * * *' class: Scheduler::PgheroScheduler + queue: scheduler instance_refresh_scheduler: cron: '0 * * * *' class: Scheduler::InstanceRefreshScheduler + queue: scheduler From 7c800fa80188872805091f733c919d6b07b3936e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Dec 2020 03:17:06 +0100 Subject: [PATCH 08/13] Bump ini from 1.3.5 to 1.3.7 (#15317) Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7. - [Release notes](https://github.com/isaacs/ini/releases) - [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1a0ef66e2..653d67133 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5523,9 +5523,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.7" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" + integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== inquirer@^0.12.0: version "0.12.0" From 1390cc194bd07b85fe36e31a0cab22981315974d Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 15 Dec 2020 04:30:15 +0100 Subject: [PATCH 09/13] Add indication to admin UI of whether a report has been forwarded (#13237) * Add indication to admin UI of whether a report has been forwarded * Rework how forwarded status is displayed Co-authored-by: Claire --- app/models/report.rb | 1 + app/services/report_service.rb | 3 ++- app/views/admin/reports/index.html.haml | 4 ++++ app/views/admin/reports/show.html.haml | 10 ++++++++++ config/locales/en.yml | 2 ++ db/migrate/20200309150742_add_forwarded_to_reports.rb | 5 +++++ db/schema.rb | 1 + 7 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20200309150742_add_forwarded_to_reports.rb diff --git a/app/models/report.rb b/app/models/report.rb index f31bcfd2e..cd08120e4 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -14,6 +14,7 @@ # target_account_id :bigint(8) not null # assigned_account_id :bigint(8) # uri :string +# forwarded :boolean # class Report < ApplicationRecord diff --git a/app/services/report_service.rb b/app/services/report_service.rb index 1e955c1e7..9d9c7d6c9 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -24,7 +24,8 @@ class ReportService < BaseService target_account: @target_account, status_ids: @status_ids, comment: @comment, - uri: @options[:uri] + uri: @options[:uri], + forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward]) ) end diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml index bb441380e..721c55f71 100644 --- a/app/views/admin/reports/index.html.haml +++ b/app/views/admin/reports/index.html.haml @@ -59,6 +59,10 @@ = fa_icon('camera') = report.media_attachments.count + - if report.forwarded? + · + = t('admin.reports.forwarded_to', domain: target_account.domain) + .report-card__summary__item__assigned - if report.assigned_account.present? = admin_account_link_to report.assigned_account diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml index 2681419ca..b060c553f 100644 --- a/app/views/admin/reports/show.html.haml +++ b/app/views/admin/reports/show.html.haml @@ -46,6 +46,16 @@ %td{ colspan: 2 } - if @report.action_taken? = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put + - unless @report.target_account.local? + %tr + %th= t('admin.reports.forwarded') + %td{ colspan: 3 } + - if @report.forwarded.nil? + \- + - elsif @report.forwarded? + = t('simple_form.yes') + - else + = t('simple_form.no') - if !@report.action_taken_by_account.nil? %tr %th= t('admin.reports.action_taken_by') diff --git a/config/locales/en.yml b/config/locales/en.yml index 114f86fd1..3ff01ac86 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -517,6 +517,8 @@ en: comment: none: None created_at: Reported + forwarded: Forwarded + forwarded_to: Forwarded to %{domain} mark_as_resolved: Mark as resolved mark_as_unresolved: Mark as unresolved notes: diff --git a/db/migrate/20200309150742_add_forwarded_to_reports.rb b/db/migrate/20200309150742_add_forwarded_to_reports.rb new file mode 100644 index 000000000..df278240b --- /dev/null +++ b/db/migrate/20200309150742_add_forwarded_to_reports.rb @@ -0,0 +1,5 @@ +class AddForwardedToReports < ActiveRecord::Migration[5.2] + def change + add_column :reports, :forwarded, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 2f9d369be..55822a4b3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -717,6 +717,7 @@ ActiveRecord::Schema.define(version: 2020_12_06_004238) do t.bigint "target_account_id", null: false t.bigint "assigned_account_id" t.string "uri" + t.boolean "forwarded" t.index ["account_id"], name: "index_reports_on_account_id" t.index ["target_account_id"], name: "index_reports_on_target_account_id" end From 75d2762fdf600de6785f612caf182b32064a9bf6 Mon Sep 17 00:00:00 2001 From: Mashiro Date: Tue, 15 Dec 2020 13:28:14 +0800 Subject: [PATCH 10/13] Add "invite request content" display in user account admin page (#15265) * feat: display `invite_request_text` in admin's user account page * fix: move invite_request to the bottom of accounts page * fix: remove time display, remove formate, change code terminology * fix: remove escape --- app/views/admin/accounts/show.html.haml | 10 ++++++++++ config/locales/en.yml | 1 + 2 files changed, 11 insertions(+) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index d5978eddd..ae527cc23 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -242,3 +242,13 @@ .actions = f.button :button, t('admin.account_moderation_notes.create'), type: :submit + + %hr.spacer/ + + - if @account.user&.invite_request&.text&.present? + %div.speech-bubble + %div.speech-bubble__bubble + = @account.user&.invite_request&.text + %div.speech-bubble__owner + = admin_account_link_to @account + = t('admin.accounts.invite_request_text') diff --git a/config/locales/en.yml b/config/locales/en.yml index 3ff01ac86..1f5798fcc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -131,6 +131,7 @@ en: follows: Follows header: Header inbox_url: Inbox URL + invite_request_text: Reasons for joining invited_by: Invited by ip: IP joined: Joined From 1045549f85041b13002801808b30a332c3a68c61 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 15 Dec 2020 12:55:29 +0100 Subject: [PATCH 11/13] Add stoplight for object storage failures, return HTTP 503 (#13043) --- app/controllers/api/base_controller.rb | 2 +- app/controllers/application_controller.rb | 2 +- app/lib/activitypub/activity/create.rb | 4 ++++ config/initializers/paperclip.rb | 11 +++++++++++ lib/paperclip/attachment_extensions.rb | 17 +++++++++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index fe199e689..85f4cc768 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -40,7 +40,7 @@ class Api::BaseController < ApplicationController render json: { error: 'This action is not allowed' }, status: 403 end - rescue_from Mastodon::RaceConditionError do + rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2201e463e..44616d6e5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error - rescue_from Mastodon::RaceConditionError, with: :service_unavailable + rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, with: :service_unavailable rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index c77f237f9..612744676 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -228,6 +228,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri) emoji.image_remote_url = image_url emoji.save + rescue Seahorse::Client::NetworkingError + nil end def process_attachments @@ -250,6 +252,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachment.save rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id) + rescue Seahorse::Client::NetworkingError + nil end end diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 25adcd8d6..9ad7fd814 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -113,3 +113,14 @@ else end Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES } + +# In some places in the code, we rescue this exception, but we don't always +# load the S3 library, so it may be an undefined constant: + +unless defined?(Seahorse) + module Seahorse + module Client + class NetworkingError < StandardError; end + end + end +end diff --git a/lib/paperclip/attachment_extensions.rb b/lib/paperclip/attachment_extensions.rb index 752e79e65..94f7769b6 100644 --- a/lib/paperclip/attachment_extensions.rb +++ b/lib/paperclip/attachment_extensions.rb @@ -39,6 +39,23 @@ module Paperclip 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 From 1f564051b6b447e3663852c482982b3ff5a2f238 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 15 Dec 2020 12:56:43 +0100 Subject: [PATCH 12/13] Change RTL detection to rely on unicode-bidi paragraph by paragraph (#14573) --- app/helpers/statuses_helper.rb | 20 ------------ .../mastodon/components/autosuggest_input.js | 8 +---- .../components/autosuggest_textarea.js | 8 +---- .../mastodon/components/status_content.js | 18 ++++------- .../compose/components/reply_indicator.js | 6 +--- app/javascript/mastodon/rtl.js | 32 ------------------- app/javascript/styles/mailer.scss | 12 ++++++- .../styles/mastodon/components.scss | 2 ++ .../notification_mailer/_status.html.haml | 4 +-- app/views/statuses/_detailed_status.html.haml | 2 +- app/views/statuses/_simple_status.html.haml | 2 +- spec/helpers/statuses_helper_spec.rb | 18 ----------- 12 files changed, 26 insertions(+), 106 deletions(-) delete mode 100644 app/javascript/mastodon/rtl.js diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index daed9048f..1f654f34f 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -92,22 +92,6 @@ module StatusesHelper end end - def rtl_status?(status) - status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text)) - end - - def rtl?(text) - text = simplified_text(text) - rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m) - - if rtl_words.present? - total_size = text.size.to_f - rtl_size(rtl_words) / total_size > 0.3 - else - false - end - end - def fa_visibility_icon(status) case status.visibility when 'public' @@ -143,10 +127,6 @@ module StatusesHelper end end - def rtl_size(words) - words.reduce(0) { |acc, elem| acc + elem.size }.to_f - end - def embedded_view? params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION end diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js index 6d2035add..5187f95c8 100644 --- a/app/javascript/mastodon/components/autosuggest_input.js +++ b/app/javascript/mastodon/components/autosuggest_input.js @@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from '../rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import classNames from 'classnames'; import { List as ImmutableList } from 'immutable'; @@ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent { render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } return (
@@ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { onKeyUp={onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur} - style={style} + dir='auto' aria-autocomplete='list' id={id} className={className} diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 58ec4f6eb..08b9cd80b 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from '../rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; import classNames from 'classnames'; @@ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } return [
@@ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onFocus={this.onFocus} onBlur={this.onBlur} onPaste={this.onPaste} - style={style} + dir='auto' aria-autocomplete='list' /> diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 3200f2d82..185a2a663 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -1,7 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from '../rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; import classnames from 'classnames'; @@ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent { const content = { __html: status.get('contentHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') }; - const directionStyle = { direction: 'ltr' }; const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-spoiler': status.get('spoiler_text').length > 0, 'status__content--collapsed': renderReadMore, }); - if (isRtl(status.get('search_index'))) { - directionStyle.direction = 'rtl'; - } - const showThreadButton = (