about summary refs log tree commit diff
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2020-03-08 19:38:53 +0100
committerThibaut Girka <thib@sitedethib.com>2020-03-08 19:38:53 +0100
commitc790ecb14d8b06c6242886ff4d2cdf06e70c5cac (patch)
treedff0bfefe5a1922c7227ea1ec0236b92e11db699
parent13ef4d5fb0dbb66074f42df7989ae40509a4724f (diff)
parent764b89939fe2fcb8c4389738af8685949104c144 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- `app/controllers/api/v1/statuses_controller.rb`:
  Conflict due to upstream adding a new parameter (with_rate_limit),
  too close to glitch-soc's own additional parameter (content_type).
  Added upstream's parameter.
- `app/services/post_status_service.rb`:
  Conflict due to upstream adding a new parameter (rate_limit),
  too close to glitch-soc's own additional parameter (content_type).
  Added upstream's parameter.
- `app/views/settings/preferences/appearance/show.html.haml`:
  Conflict due to us not exposing theme settings here (as we have
  a different flavour/skin menu).
  Took upstream change, while still not exposing theme settings.
- `config/webpack/shared.js`:
  Coding style fixes for a part we have rewritten.
  Discarded upstream changes.
-rw-r--r--.circleci/config.yml1
-rw-r--r--Gemfile4
-rw-r--r--Gemfile.lock12
-rw-r--r--app/controllers/account_follow_controller.rb2
-rw-r--r--app/controllers/admin/site_uploads_controller.rb21
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/follower_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/following_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/identity_proofs_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/lists_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/pins_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/search_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/apps/credentials_controller.rb2
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/bookmarks_controller.rb2
-rw-r--r--app/controllers/api/v1/conversations_controller.rb2
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb2
-rw-r--r--app/controllers/api/v1/domain_blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/endorsements_controller.rb2
-rw-r--r--app/controllers/api/v1/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/featured_tags/suggestions_controller.rb3
-rw-r--r--app/controllers/api/v1/filters_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb2
-rw-r--r--app/controllers/api/v1/instances_controller.rb2
-rw-r--r--app/controllers/api/v1/media_controller.rb2
-rw-r--r--app/controllers/api/v1/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb2
-rw-r--r--app/controllers/api/v1/polls/votes_controller.rb2
-rw-r--r--app/controllers/api/v1/polls_controller.rb2
-rw-r--r--app/controllers/api/v1/preferences_controller.rb2
-rw-r--r--app/controllers/api/v1/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/bookmarks_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogs_controller.rb3
-rw-r--r--app/controllers/api/v1/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/streaming_controller.rb2
-rw-r--r--app/controllers/api/v1/suggestions_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/home_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb2
-rw-r--r--app/controllers/api/v1/trends_controller.rb2
-rw-r--r--app/controllers/api/v2/search_controller.rb2
-rw-r--r--app/controllers/api/web/embeds_controller.rb2
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb2
-rw-r--r--app/controllers/api/web/settings_controller.rb2
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/authorize_interactions_controller.rb2
-rw-r--r--app/controllers/concerns/rate_limit_headers.rb16
-rw-r--r--app/helpers/admin/settings_helper.rb11
-rw-r--r--app/javascript/mastodon/actions/accounts.js2
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js2
-rw-r--r--app/javascript/mastodon/components/media_gallery.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js4
-rw-r--r--app/javascript/mastodon/features/account/components/header.js4
-rw-r--r--app/javascript/mastodon/features/audio/index.js6
-rw-r--r--app/javascript/mastodon/features/blocks/index.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js8
-rw-r--r--app/javascript/mastodon/features/domain_blocks/index.js2
-rw-r--r--app/javascript/mastodon/features/favourites/index.js2
-rw-r--r--app/javascript/mastodon/features/follow_requests/index.js2
-rw-r--r--app/javascript/mastodon/features/followers/index.js2
-rw-r--r--app/javascript/mastodon/features/following/index.js2
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js11
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js8
-rw-r--r--app/javascript/mastodon/features/lists/index.js2
-rw-r--r--app/javascript/mastodon/features/mutes/index.js2
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js2
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js4
-rw-r--r--app/javascript/mastodon/features/status/components/card.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/__tests__/column-test.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js14
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/br.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json28
-rw-r--r--app/javascript/mastodon/locales/en.json14
-rw-r--r--app/javascript/mastodon/locales/ga.json2
-rw-r--r--app/javascript/mastodon/locales/hi.json2
-rw-r--r--app/javascript/mastodon/locales/kn.json2
-rw-r--r--app/javascript/mastodon/locales/lt.json2
-rw-r--r--app/javascript/mastodon/locales/lv.json2
-rw-r--r--app/javascript/mastodon/locales/mk.json2
-rw-r--r--app/javascript/mastodon/locales/ml.json2
-rw-r--r--app/javascript/mastodon/locales/mr.json2
-rw-r--r--app/javascript/mastodon/locales/ms.json2
-rw-r--r--app/javascript/mastodon/locales/ur.json2
-rw-r--r--app/javascript/mastodon/reducers/notifications.js4
-rw-r--r--app/javascript/mastodon/reducers/timelines.js4
-rw-r--r--app/javascript/mastodon/selectors/index.js2
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js2
-rw-r--r--app/javascript/mastodon/store/configureStore.js2
-rw-r--r--app/javascript/styles/mastodon/components.scss23
-rw-r--r--app/lib/exceptions.rb1
-rw-r--r--app/lib/rate_limiter.rb64
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/account_filter.rb27
-rw-r--r--app/models/announcement.rb4
-rw-r--r--app/models/concerns/account_interactions.rb16
-rw-r--r--app/models/concerns/rate_limitable.rb36
-rw-r--r--app/models/follow.rb3
-rw-r--r--app/models/follow_request.rb3
-rw-r--r--app/models/media_attachment.rb1
-rw-r--r--app/models/status.rb18
-rw-r--r--app/policies/settings_policy.rb4
-rw-r--r--app/serializers/rest/announcement_serializer.rb13
-rw-r--r--app/services/account_search_service.rb2
-rw-r--r--app/services/follow_service.rb78
-rw-r--r--app/services/post_status_service.rb13
-rw-r--r--app/services/reblog_service.rb16
-rw-r--r--app/views/admin/accounts/_account.html.haml2
-rw-r--r--app/views/admin/accounts/index.html.haml6
-rw-r--r--app/views/admin/settings/edit.html.haml11
-rw-r--r--app/views/errors/429.html.haml5
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml5
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml8
-rw-r--r--app/views/settings/preferences/other/show.html.haml5
-rw-r--r--app/views/settings/profiles/show.html.haml5
-rw-r--r--config/initializers/rack_attack.rb1
-rw-r--r--config/locales/en.yml5
-rw-r--r--config/locales/simple_form.en.yml4
-rw-r--r--config/routes.rb1
-rw-r--r--config/webpack/development.js2
-rw-r--r--config/webpack/shared.js2
-rw-r--r--dist/nginx.conf12
-rw-r--r--lib/mastodon/statuses_cli.rb8
-rw-r--r--package.json6
-rw-r--r--spec/controllers/account_follow_controller_spec.rb2
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb46
-rw-r--r--spec/features/profile_spec.rb2
-rw-r--r--yarn.lock86
138 files changed, 575 insertions, 319 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3ba027d95..a343fc654 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,6 +6,7 @@ aliases:
       - image: circleci/ruby:2.7-buster-node
         environment: &ruby_environment
           BUNDLE_APP_CONFIG: ./.bundle/
+          BUNDLE_PATH: ./vendor/bundle/
           DB_HOST: localhost
           DB_USER: root
           RAILS_ENV: test
diff --git a/Gemfile b/Gemfile
index 852def278..46757eadf 100644
--- a/Gemfile
+++ b/Gemfile
@@ -49,7 +49,7 @@ gem 'omniauth-saml', '~> 1.10'
 gem 'omniauth', '~> 1.9'
 
 gem 'discard', '~> 1.1'
-gem 'doorkeeper', '~> 5.2'
+gem 'doorkeeper', '~> 5.3'
 gem 'fast_blank', '~> 1.0'
 gem 'fastimage'
 gem 'goldfinger', '~> 2.1'
@@ -92,7 +92,7 @@ gem 'simple-navigation', '~> 4.1'
 gem 'simple_form', '~> 5.0'
 gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
 gem 'stoplight', '~> 2.2.0'
-gem 'strong_migrations', '~> 0.5'
+gem 'strong_migrations', '~> 0.6'
 gem 'tty-command', '~> 0.9', require: false
 gem 'tty-prompt', '~> 0.20', require: false
 gem 'twitter-text', '~> 1.14'
diff --git a/Gemfile.lock b/Gemfile.lock
index c05fac36b..49b2a8fe8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -195,7 +195,7 @@ GEM
     docile (1.3.2)
     domain_name (0.5.20190701)
       unf (>= 0.0.5, < 1.0.0)
-    doorkeeper (5.2.3)
+    doorkeeper (5.3.1)
       railties (>= 5)
     dotenv (2.7.5)
     dotenv-rails (2.7.5)
@@ -311,7 +311,7 @@ GEM
       multi_json (~> 1.14)
       rack (~> 2.0)
       rdf (~> 3.1)
-    json-ld-preloaded (3.1.0)
+    json-ld-preloaded (3.1.1)
       json-ld (~> 3.1)
       rdf (~> 3.1)
     jsonapi-renderer (0.2.2)
@@ -383,7 +383,7 @@ GEM
       concurrent-ruby (~> 1.0, >= 1.0.2)
       sidekiq (>= 3.5)
       statsd-ruby (~> 1.4, >= 1.4.0)
-    oj (3.10.1)
+    oj (3.10.3)
     omniauth (1.9.0)
       hashie (>= 3.4.6, < 3.7.0)
       rack (>= 1.6.2, < 3)
@@ -603,7 +603,7 @@ GEM
     stoplight (2.2.0)
     streamio-ffmpeg (3.0.2)
       multi_json (~> 1.8)
-    strong_migrations (0.5.1)
+    strong_migrations (0.6.2)
       activerecord (>= 5)
     temple (0.8.2)
     terminal-table (1.8.0)
@@ -690,7 +690,7 @@ DEPENDENCIES
   devise-two-factor (~> 3.1)
   devise_pam_authenticatable2 (~> 9.2)
   discard (~> 1.1)
-  doorkeeper (~> 5.2)
+  doorkeeper (~> 5.3)
   dotenv-rails (~> 2.7)
   e2mmap (~> 0.1.0)
   fabrication (~> 2.21)
@@ -779,7 +779,7 @@ DEPENDENCIES
   stackprof
   stoplight (~> 2.2.0)
   streamio-ffmpeg (~> 3.0)
-  strong_migrations (~> 0.5)
+  strong_migrations (~> 0.6)
   thor (~> 0.20)
   thwait (~> 0.1.0)
   tty-command (~> 0.9)
diff --git a/app/controllers/account_follow_controller.rb b/app/controllers/account_follow_controller.rb
index 185a355f8..33394074d 100644
--- a/app/controllers/account_follow_controller.rb
+++ b/app/controllers/account_follow_controller.rb
@@ -6,7 +6,7 @@ class AccountFollowController < ApplicationController
   before_action :authenticate_user!
 
   def create
-    FollowService.new.call(current_user.account, @account.acct)
+    FollowService.new.call(current_user.account, @account, with_rate_limit: true)
     redirect_to account_path(@account)
   end
 end
diff --git a/app/controllers/admin/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb
new file mode 100644
index 000000000..cacecedb0
--- /dev/null
+++ b/app/controllers/admin/site_uploads_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Admin
+  class SiteUploadsController < BaseController
+    before_action :set_site_upload
+
+    def destroy
+      authorize :settings, :destroy?
+
+      @site_upload.destroy!
+
+      redirect_to edit_admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg')
+    end
+
+    private
+
+    def set_site_upload
+      @site_upload = SiteUpload.find(params[:id])
+    end
+  end
+end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 68bf425f4..153ade253 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -44,6 +44,10 @@ class Api::BaseController < ApplicationController
     render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
   end
 
+  rescue_from Mastodon::RateLimitExceededError do
+    render json: { error: I18n.t('errors.429') }, status: 429
+  end
+
   rescue_from ActionController::ParameterMissing do |e|
     render json: { error: e.to_s }, status: 400
   end
diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
index e360b8a92..850702cca 100644
--- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
   before_action :set_account
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb
index a405b365f..830dcd8a1 100644
--- a/app/controllers/api/v1/accounts/following_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
   before_action :set_account
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index bea51ae11..8dad6fee9 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def index
     @proofs = @account.identity_proofs.active
     render json: @proofs, each_serializer: REST::IdentityProofSerializer
diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb
index 72392453c..ccb751f8f 100644
--- a/app/controllers/api/v1/accounts/lists_controller.rb
+++ b/app/controllers/api/v1/accounts/lists_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::ListsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def index
     @lists = @account.lists.where(account: current_account)
     render json: @lists, each_serializer: REST::ListSerializer
diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb
index 0a0239c42..3915b5669 100644
--- a/app/controllers/api/v1/accounts/pins_controller.rb
+++ b/app/controllers/api/v1/accounts/pins_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def create
     AccountPin.create!(account: current_account, target_account: @account)
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index ab8a0461f..1d3992a28 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:follows' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     accounts = Account.where(id: account_ids).select('id')
     # .where doesn't guarantee that our results are in the same order
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 4217b527a..3061fcb7e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::SearchController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
   before_action :require_user!
 
-  respond_to :json
-
   def show
     @accounts = account_search
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 333db9618..114ee0a82 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
 
   after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d68d2715f..0080faf33 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::AccountsController < Api::BaseController
 
   skip_before_action :require_authenticated_user!, only: :create
 
-  respond_to :json
+  override_rate_limit_headers :follow, family: :follows
 
   def show
     render json: @account, serializer: REST::AccountSerializer
@@ -31,7 +31,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def follow
-    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
+    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true)
 
     options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
 
diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb
index 8b63d0490..0475b2d4a 100644
--- a/app/controllers/api/v1/apps/credentials_controller.rb
+++ b/app/controllers/api/v1/apps/credentials_controller.rb
@@ -3,8 +3,6 @@
 class Api::V1::Apps::CredentialsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read }
 
-  respond_to :json
-
   def show
     render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key)
   end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 4cff04cad..a2baeef90 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::BlocksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb
index e1b244e76..c15212f0a 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::BookmarksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
index b19f27ebf..bc8013379 100644
--- a/app/controllers/api/v1/conversations_controller.rb
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -9,8 +9,6 @@ class Api::V1::ConversationsController < Api::BaseController
   before_action :set_conversation, except: :index
   after_action :insert_pagination_headers, only: :index
 
-  respond_to :json
-
   def index
     @conversations = paginated_conversations
     render json: @conversations, each_serializer: REST::ConversationSerializer
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
index 4e6d5d7c6..08b3474cc 100644
--- a/app/controllers/api/v1/custom_emojis_controller.rb
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::CustomEmojisController < Api::BaseController
-  respond_to :json
-
   skip_before_action :set_cache_headers
 
   def index
diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb
index af9e7a20f..5bb02d834 100644
--- a/app/controllers/api/v1/domain_blocks_controller.rb
+++ b/app/controllers/api/v1/domain_blocks_controller.rb
@@ -8,8 +8,6 @@ class Api::V1::DomainBlocksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers, only: :show
 
-  respond_to :json
-
   def show
     @blocks = load_domain_blocks
     render json: @blocks.map(&:domain)
diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb
index 2770c7aef..c87dbc4ce 100644
--- a/app/controllers/api/v1/endorsements_controller.rb
+++ b/app/controllers/api/v1/endorsements_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::EndorsementsController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index db827f9d4..3e242905d 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::FavouritesController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
index fb27ef88b..8c1b81a0f 100644
--- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb
+++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
@@ -2,12 +2,9 @@
 
 class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
-
   before_action :require_user!
   before_action :set_most_used_tags, only: :index
 
-  respond_to :json
-
   def index
     render json: @most_used_tags, each_serializer: REST::TagSerializer
   end
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
index e5ebaff4d..b0ace3af0 100644
--- a/app/controllers/api/v1/filters_controller.rb
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::FiltersController < Api::BaseController
   before_action :set_filters, only: :index
   before_action :set_filter, only: [:show, :update, :destroy]
 
-  respond_to :json
-
   def index
     render json: @filters, each_serializer: REST::FilterSerializer
   end
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
index b30e8464c..4f6b4bcbf 100644
--- a/app/controllers/api/v1/instances/activity_controller.rb
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
-  respond_to :json
-
   def show
     expires_in 1.day, public: true
     render_with_cache json: :activity, expires_in: 1.day
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
index cc00d8a6b..9fa440935 100644
--- a/app/controllers/api/v1/instances/peers_controller.rb
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Instances::PeersController < Api::BaseController
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
-  respond_to :json
-
   def index
     expires_in 1.day, public: true
     render_with_cache(expires_in: 1.day) { Account.remote.domains }
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index c323b60b4..5b5058a7b 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::InstancesController < Api::BaseController
-  respond_to :json
-
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 81825db15..d87d7b946 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::MediaController < Api::BaseController
   before_action -> { doorkeeper_authorize! :write, :'write:media' }
   before_action :require_user!
 
-  respond_to :json
-
   def create
     @media = current_account.media_attachments.create!(media_params)
     render json: @media, serializer: REST::MediaAttachmentSerializer
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 3b3a39943..5dc047b43 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::MutesController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @data = @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index c91753ae7..9dce9b807 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::NotificationsController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers, only: :index
 
-  respond_to :json
-
   DEFAULT_NOTIFICATIONS_LIMIT = 15
 
   def index
diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb
index 3fa0b6a76..e1d26106a 100644
--- a/app/controllers/api/v1/polls/votes_controller.rb
+++ b/app/controllers/api/v1/polls/votes_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Polls::VotesController < Api::BaseController
   before_action :require_user!
   before_action :set_poll
 
-  respond_to :json
-
   def create
     VoteService.new.call(current_account, @poll, vote_params[:choices])
     render json: @poll, serializer: REST::PollSerializer
diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb
index 031e6d42d..744baf7bb 100644
--- a/app/controllers/api/v1/polls_controller.rb
+++ b/app/controllers/api/v1/polls_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::PollsController < Api::BaseController
   before_action :set_poll
   before_action :refresh_poll
 
-  respond_to :json
-
   def show
     render json: @poll, serializer: REST::PollSerializer, include_results: true
   end
diff --git a/app/controllers/api/v1/preferences_controller.rb b/app/controllers/api/v1/preferences_controller.rb
index 077d39f5d..1640a8224 100644
--- a/app/controllers/api/v1/preferences_controller.rb
+++ b/app/controllers/api/v1/preferences_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::PreferencesController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     render json: current_account, serializer: REST::PreferencesSerializer
   end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 1b0b4b05b..66c40f6f4 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::ReportsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :write, :'write:reports' }, only: [:create]
   before_action :require_user!
 
-  respond_to :json
-
   def create
     @report = ReportService.new.call(
       current_account,
diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb
index a7f1eed00..3954af3c9 100644
--- a/app/controllers/api/v1/statuses/bookmarks_controller.rb
+++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     current_account.bookmarks.find_or_create_by!(account: current_account, status: @status)
     render json: @status, serializer: REST::StatusSerializer
diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
index 05f4acc33..8229786d6 100644
--- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
   before_action :set_status
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb
index f18ace996..7afa822ed 100644
--- a/app/controllers/api/v1/statuses/favourites_controller.rb
+++ b/app/controllers/api/v1/statuses/favourites_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     FavouriteService.new.call(current_account, @status)
     render json: @status, serializer: REST::StatusSerializer
diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb
index b02469b4f..43c7a525a 100644
--- a/app/controllers/api/v1/statuses/mutes_controller.rb
+++ b/app/controllers/api/v1/statuses/mutes_controller.rb
@@ -8,8 +8,6 @@ class Api::V1::Statuses::MutesController < Api::BaseController
   before_action :set_status
   before_action :set_conversation
 
-  respond_to :json
-
   def create
     current_account.mute_conversation!(@conversation)
     @mutes_map = { @conversation.id => true }
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
index 4118a8ce4..51b1621b6 100644
--- a/app/controllers/api/v1/statuses/pins_controller.rb
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     StatusPin.create!(account: current_account, status: @status)
     distribute_add_activity!
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
index fa60e7d84..6c9e49d90 100644
--- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
   before_action :set_status
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb
index 67106ccbe..7fa774a4d 100644
--- a/app/controllers/api/v1/statuses/reblogs_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogs_controller.rb
@@ -7,10 +7,11 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
   before_action :require_user!
   before_action :set_reblog
 
-  respond_to :json
+  override_rate_limit_headers :create, family: :statuses
 
   def create
     @status = ReblogService.new.call(current_account, @reblog, reblog_params)
+
     render json: @status, serializer: REST::StatusSerializer
   end
 
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 486004f9c..544e8e3c9 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -8,7 +8,7 @@ class Api::V1::StatusesController < Api::BaseController
   before_action :require_user!, except:  [:show, :context]
   before_action :set_status, only:       [:show, :context]
 
-  respond_to :json
+  override_rate_limit_headers :create, family: :statuses
 
   # This API was originally unlimited, pagination cannot be introduced without
   # breaking backwards-compatibility. Arbitrarily high number to cover most
@@ -45,7 +45,8 @@ class Api::V1::StatusesController < Api::BaseController
                                          application: doorkeeper_token.application,
                                          poll: status_params[:poll],
                                          content_type: status_params[:content_type],
-                                         idempotency: request.headers['Idempotency-Key'])
+                                         idempotency: request.headers['Idempotency-Key'],
+                                         with_rate_limit: true)
 
     render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
   end
diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb
index ebb17608c..7cd60615a 100644
--- a/app/controllers/api/v1/streaming_controller.rb
+++ b/app/controllers/api/v1/streaming_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::StreamingController < Api::BaseController
-  respond_to :json
-
   def index
     if Rails.configuration.x.streaming_api_base_url != request.host
       redirect_to streaming_api_url, status: 301
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 9da2b60ae..52054160d 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::SuggestionsController < Api::BaseController
   before_action :require_user!
   before_action :set_accounts
 
-  respond_to :json
-
   def index
     render json: @accounts, each_serializer: REST::AccountSerializer
   end
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index ff5ede138..ae6dbcb8b 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Timelines::HomeController < Api::BaseController
   before_action :require_user!, only: [:show]
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
 
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index ccc10f966..581befef1 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   before_action :require_user!, only: [:show], if: :require_auth?
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 9adc4ad29..2d6ad5a80 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Timelines::TagController < Api::BaseController
   before_action :load_tag
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
index bcea9857e..c875e9041 100644
--- a/app/controllers/api/v1/trends_controller.rb
+++ b/app/controllers/api/v1/trends_controller.rb
@@ -3,8 +3,6 @@
 class Api::V1::TrendsController < Api::BaseController
   before_action :set_tags
 
-  respond_to :json
-
   def index
     render json: @tags, each_serializer: REST::TagSerializer
   end
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 76decdb25..ddcf92200 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -8,8 +8,6 @@ class Api::V2::SearchController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:search' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     @search = Search.new(search_results)
     render json: @search, serializer: REST::SearchSerializer
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
index 4aa31695c..741ba910f 100644
--- a/app/controllers/api/web/embeds_controller.rb
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::EmbedsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def create
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index f388b17e5..7916b82fa 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::PushSubscriptionsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def create
diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb
index e3178bf48..3d65e46ed 100644
--- a/app/controllers/api/web/settings_controller.rb
+++ b/app/controllers/api/web/settings_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::SettingsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def update
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c882d40ab..63d9f91fb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -30,6 +30,7 @@ class ApplicationController < ActionController::Base
   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::RateLimitExceededError, with: :too_many_requests
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :require_functional!, if: :user_signed_in?
@@ -181,6 +182,10 @@ class ApplicationController < ActionController::Base
     respond_with_error(503)
   end
 
+  def too_many_requests
+    respond_with_error(429)
+  end
+
   def single_user_mode?
     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
   end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
index 20b3fa94b..f0bcac75b 100644
--- a/app/controllers/authorize_interactions_controller.rb
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
   end
 
   def create
-    if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource)
+    if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true)
       render :success
     else
       render :error
diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb
index b79c558d8..86fe58a71 100644
--- a/app/controllers/concerns/rate_limit_headers.rb
+++ b/app/controllers/concerns/rate_limit_headers.rb
@@ -3,6 +3,20 @@
 module RateLimitHeaders
   extend ActiveSupport::Concern
 
+  class_methods do
+    def override_rate_limit_headers(method_name, options = {})
+      around_action(only: method_name, if: :current_account) do |_controller, block|
+        begin
+          block.call
+        ensure
+          rate_limiter = RateLimiter.new(current_account, options)
+          rate_limit_headers = rate_limiter.to_headers
+          response.headers.merge!(rate_limit_headers) unless response.headers['X-RateLimit-Remaining'].present? && rate_limit_headers['X-RateLimit-Remaining'].to_i > response.headers['X-RateLimit-Remaining'].to_i
+        end
+      end
+    end
+  end
+
   included do
     before_action :set_rate_limit_headers, if: :rate_limited_request?
   end
@@ -44,7 +58,7 @@ module RateLimitHeaders
   end
 
   def api_throttle_data
-    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] }
+    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] }
     request.env['rack.attack.throttle_data'][most_limited_type]
   end
 
diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb
new file mode 100644
index 000000000..baf14ab25
--- /dev/null
+++ b/app/helpers/admin/settings_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Admin::SettingsHelper
+  def site_upload_delete_hint(hint, var)
+    upload = SiteUpload.find_by(var: var.to_s)
+    return hint unless upload
+
+    link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete }
+    safe_join([hint, link], '<br/>'.html_safe)
+  end
+end
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index d4a824e2c..4af36e998 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -106,7 +106,7 @@ export function fetchAccount(id) {
       dispatch,
       getState,
       db.transaction('accounts', 'read').objectStore('accounts').index('id'),
-      id
+      id,
     ).then(() => db.close(), error => {
       db.close();
       throw error;
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index e453730ba..124b34b02 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -44,7 +44,7 @@ export default class IntersectionObserverArticle extends React.Component {
     intersectionObserverWrapper.observe(
       id,
       this.node,
-      this.handleIntersection
+      this.handleIntersection,
     );
 
     this.componentMounted = true;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index cfe164a50..283d7e0a5 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -10,7 +10,7 @@ import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_s
 import { decode } from 'blurhash';
 
 const messages = defineMessages({
-  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' },
 });
 
 class Item extends React.PureComponent {
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e2c8d43c9..bebbbcb5a 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -36,8 +36,8 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8bd7f2db5..35cc3952f 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -29,8 +29,8 @@ const messages = defineMessages({
   report: { id: 'account.report', defaultMessage: 'Report @{name}' },
   share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
   media: { id: 'account.media', defaultMessage: 'Media' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index fda5a074f..95c9c7751 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -214,8 +214,8 @@ class Audio extends React.PureComponent {
         <div className='video-player__controls active'>
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
-              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
-              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
                 &nbsp;
@@ -236,7 +236,7 @@ class Audio extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              <button type='button' aria-label={intl.formatMessage(messages.download)}>
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
                 <a className='video-player__download__icon' href={this.props.src} download>
                   <Icon id={'download'} fixedWidth />
                 </a>
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
index 051431ed2..870c0de09 100644
--- a/app/javascript/mastodon/features/blocks/index.js
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -68,7 +68,7 @@ class Blocks extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />
+            <AccountContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index cac3776bb..cc740def2 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -155,6 +155,7 @@ class PollForm extends ImmutablePureComponent {
         <div className='poll__footer'>
           <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
 
+          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
           <select value={expiresIn} onChange={this.handleSelectDuration}>
             <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
             <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 7cbfe463a..de030b7a2 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -11,13 +11,13 @@ import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' },
   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' },
   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
-  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js
index 482245c86..06533d5ac 100644
--- a/app/javascript/mastodon/features/domain_blocks/index.js
+++ b/app/javascript/mastodon/features/domain_blocks/index.js
@@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {domains.map(domain =>
-            <DomainContainer key={domain} domain={domain} />
+            <DomainContainer key={domain} domain={domain} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index 249e6a044..75cb00c0e 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -79,7 +79,7 @@ class Favourites extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
index 57ef44145..bef56fab5 100644
--- a/app/javascript/mastodon/features/follow_requests/index.js
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -68,7 +68,7 @@ class FollowRequests extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountAuthorizeContainer key={id} id={id} />
+            <AccountAuthorizeContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 9e635d250..f8723e055 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -93,7 +93,7 @@ class Followers extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {blockedBy ? [] : accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index 284ae2c11..5112bfa9d 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -93,7 +93,7 @@ class Following extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {blockedBy ? [] : accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
index 91cf6215e..fb4a6bf0d 100644
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -95,6 +95,10 @@ class Content extends ImmutablePureComponent {
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
       } else {
+        let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
+        if (status) {
+          link.addEventListener('click', this.onStatusClick.bind(this, status), false);
+        }
         link.setAttribute('title', link.href);
         link.classList.add('unhandled-link');
       }
@@ -120,6 +124,13 @@ class Content extends ImmutablePureComponent {
     }
   }
 
+  onStatusClick = (status, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${status.get('id')}`);
+    }
+  }
+
   handleEmojiMouseEnter = ({ target }) => {
     target.src = target.getAttribute('data-original');
   }
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index adbc147d1..d9838e1c7 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -106,20 +106,20 @@ class GettingStarted extends ImmutablePureComponent {
 
       if (profile_directory) {
         navItems.push(
-          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
+          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
         );
 
         height += 48;
       }
 
       navItems.push(
-        <ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />
+        <ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />,
       );
 
       height += 34;
     } else if (profile_directory) {
       navItems.push(
-        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
+        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
       );
 
       height += 48;
@@ -129,7 +129,7 @@ class GettingStarted extends ImmutablePureComponent {
       <ColumnLink key={i++} icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
       <ColumnLink key={i++} icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
       <ColumnLink key={i++} icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
-      <ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />
+      <ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
     );
 
     height += 48*4;
diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js
index 7f7f5009c..ca1fa1f5e 100644
--- a/app/javascript/mastodon/features/lists/index.js
+++ b/app/javascript/mastodon/features/lists/index.js
@@ -74,7 +74,7 @@ class Lists extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
index 91dd268c1..3f58a62d2 100644
--- a/app/javascript/mastodon/features/mutes/index.js
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -68,7 +68,7 @@ class Mutes extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />
+            <AccountContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
index 9179e51db..4becb5fb7 100644
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -79,7 +79,7 @@ class Reblogs extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 959774da4..ba62d7b10 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -32,8 +32,8 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 2993fe29a..b8344a667 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -98,7 +98,7 @@ export default class Card extends React.PureComponent {
           },
         },
       ]),
-      0
+      0,
     );
   };
 
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
index 11cc1b6e8..89cb2458d 100644
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -19,7 +19,7 @@ describe('<Column />', () => {
       const wrapper = mount(
         <Column heading='notifications'>
           <div className='scrollable' />
-        </Column>
+        </Column>,
       );
       wrapper.find(ColumnHeader).find('button').simulate('click');
       expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 8ac9c8db7..42ded9d21 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -492,8 +492,8 @@ class Video extends React.PureComponent {
 
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
-              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
-              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
                 &nbsp;
@@ -517,11 +517,11 @@ class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
-              {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
-              {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
-              <button type='button' aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
-              <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
+              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
+              {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
+              <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
             </div>
           </div>
         </div>
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 5429f358f..c1a7bb533 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Зареждане...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 96092457f..b6bfa6648 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 18692bc44..93cfa2431 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -142,7 +142,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       }
     ],
@@ -217,7 +217,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Toggle visibility",
+        "defaultMessage": "Hide media",
         "id": "media_gallery.toggle_visible"
       },
       {
@@ -451,11 +451,11 @@
         "id": "status.copy"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -697,11 +697,11 @@
         "id": "account.media"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -1073,7 +1073,7 @@
         "id": "privacy.public.short"
       },
       {
-        "defaultMessage": "Post to public timelines",
+        "defaultMessage": "Visible for all, shown in public timelines",
         "id": "privacy.public.long"
       },
       {
@@ -1081,7 +1081,7 @@
         "id": "privacy.unlisted.short"
       },
       {
-        "defaultMessage": "Do not show in public timelines",
+        "defaultMessage": "Visible for all, but not in public timelines",
         "id": "privacy.unlisted.long"
       },
       {
@@ -1089,7 +1089,7 @@
         "id": "privacy.private.short"
       },
       {
-        "defaultMessage": "Post to followers only",
+        "defaultMessage": "Visible for followers only",
         "id": "privacy.private.long"
       },
       {
@@ -1097,7 +1097,7 @@
         "id": "privacy.direct.short"
       },
       {
-        "defaultMessage": "Post to mentioned users only",
+        "defaultMessage": "Visible for mentioned users only",
         "id": "privacy.direct.long"
       },
       {
@@ -1470,7 +1470,7 @@
         "id": "column.domain_blocks"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -2384,11 +2384,11 @@
         "id": "status.copy"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -2957,4 +2957,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index e25199905..3ec951903 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -3,7 +3,7 @@
   "account.badges.bot": "Bot",
   "account.badges.group": "Group",
   "account.block": "Block @{name}",
-  "account.block_domain": "Hide everything from {domain}",
+  "account.block_domain": "Block domain {domain}",
   "account.blocked": "Blocked",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
@@ -34,7 +34,7 @@
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Unblock @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "Unblock domain {domain}",
   "account.unendorse": "Don't feature on profile",
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
@@ -258,7 +258,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
@@ -324,13 +324,13 @@
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
-  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.long": "Visible for mentioned users only",
   "privacy.direct.short": "Direct",
-  "privacy.private.long": "Post to followers only",
+  "privacy.private.long": "Visible for followers only",
   "privacy.private.short": "Followers-only",
-  "privacy.public.long": "Post to public timelines",
+  "privacy.public.long": "Visible for all, shown in public timelines",
   "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Do not post to public timelines",
+  "privacy.unlisted.long": "Visible for all, but not in public timelines",
   "privacy.unlisted.short": "Unlisted",
   "refresh": "Refresh",
   "regeneration_indicator.label": "Loading…",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 0e9c9d6d1..b69ec2b95 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index d4659bc2e..ee9d701b0 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "लोड हो रहा है...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "नहीं मिला",
   "missing_indicator.sublabel": "यह संसाधन नहीं मिल सका।",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index 5385de456..eafb7ede7 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 5385de456..eafb7ede7 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 1b5b7cf5e..82ec3b8e8 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 4a1f736cf..ee8b13ace 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 58bb9b723..b3a06d6ed 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 8c0fed9b0..be5d9a396 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 0d23e0d2c..19f3ca257 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index 664eebfbf..d625a88bf 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 60e901e39..ed1ba0272 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -72,11 +72,11 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
 
       mutable.update(usePendingItems ? 'pendingItems' : 'items', list => {
         const lastIndex = 1 + list.findLastIndex(
-          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
+          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')),
         );
 
         const firstIndex = 1 + list.take(lastIndex).findLastIndex(
-          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0,
         );
 
         return list.take(firstIndex).concat(items, list.skip(lastIndex));
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 0d7222e10..63b76773d 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -54,7 +54,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
 
         return oldIds.take(firstIndex + 1).concat(
           isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
-          oldIds.skip(lastIndex)
+          oldIds.skip(lastIndex),
         );
       });
     }
@@ -166,7 +166,7 @@ export default function timelines(state = initialState, action) {
     return state.update(
       action.timeline,
       initialTimeline,
-      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items)
+      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
     );
   default:
     return state;
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6a48f3b3f..673268c5a 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -117,7 +117,7 @@ export const makeGetStatus = () => {
         map.set('account', accountBase);
         map.set('filtered', filtered);
       });
-    }
+    },
   );
 };
 
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index 1ab0dc0fa..958e5fc12 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -117,7 +117,7 @@ const handlePush = (event) => {
         badge: '/badge.png',
         data: { access_token, preferred_locale, url: '/web/notifications' },
       });
-    })
+    }),
   );
 };
 
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
index 7e7472841..e18af842f 100644
--- a/app/javascript/mastodon/store/configureStore.js
+++ b/app/javascript/mastodon/store/configureStore.js
@@ -10,6 +10,6 @@ export default function configureStore() {
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
-    soundsMiddleware()
+    soundsMiddleware(),
   ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
 };
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index aa885e241..143384d9e 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -388,8 +388,8 @@
 
   .emoji-picker-dropdown {
     position: absolute;
-    top: 5px;
-    right: 5px;
+    top: 0;
+    right: 0;
   }
 
   .compose-form__autosuggest-wrapper {
@@ -870,6 +870,7 @@
 
 .announcements__item__content {
   word-wrap: break-word;
+  overflow-y: auto;
 
   .emojione {
     width: 20px;
@@ -4060,10 +4061,7 @@ a.status-card.compact:hover {
 
 .emoji-button {
   display: block;
-  font-size: 24px;
-  line-height: 24px;
-  margin-left: 2px;
-  width: 24px;
+  padding: 5px 5px 2px 2px;
   outline: 0;
   cursor: pointer;
 
@@ -4079,7 +4077,6 @@ a.status-card.compact:hover {
     margin: 0;
     width: 22px;
     height: 22px;
-    margin-top: 2px;
   }
 
   &:hover,
@@ -5058,12 +5055,6 @@ a.status-card.compact:hover {
 }
 
 .media-gallery__gifv {
-  &.autoplay {
-    .media-gallery__gifv__label {
-      display: none;
-    }
-  }
-
   &:hover {
     .media-gallery__gifv__label {
       opacity: 1;
@@ -6682,17 +6673,21 @@ noscript {
     box-sizing: border-box;
     width: 100%;
     padding: 15px;
-    padding-right: 15px + 18px;
     position: relative;
     font-size: 15px;
     line-height: 20px;
     word-wrap: break-word;
     font-weight: 400;
+    max-height: 50vh;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
 
     &__range {
       display: block;
       font-weight: 500;
       margin-bottom: 10px;
+      padding-right: 18px;
     }
 
     &__unread {
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index 01346bfe5..3362576b0 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -8,6 +8,7 @@ module Mastodon
   class LengthValidationError < ValidationError; end
   class DimensionsValidationError < ValidationError; end
   class RaceConditionError < Error; end
+  class RateLimitExceededError < Error; end
 
   class UnexpectedResponseError < Error
     def initialize(response = nil)
diff --git a/app/lib/rate_limiter.rb b/app/lib/rate_limiter.rb
new file mode 100644
index 000000000..68dae9add
--- /dev/null
+++ b/app/lib/rate_limiter.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class RateLimiter
+  include Redisable
+
+  FAMILIES = {
+    follows: {
+      limit: 400,
+      period: 24.hours.freeze,
+    }.freeze,
+
+    statuses: {
+      limit: 300,
+      period: 3.hours.freeze,
+    }.freeze,
+
+    media: {
+      limit: 30,
+      period: 30.minutes.freeze,
+    }.freeze,
+  }.freeze
+
+  def initialize(by, options = {})
+    @by     = by
+    @family = options[:family]
+    @limit  = FAMILIES[@family][:limit]
+    @period = FAMILIES[@family][:period].to_i
+  end
+
+  def record!
+    count = redis.get(key)
+
+    if count.nil?
+      redis.set(key, 0)
+      redis.expire(key, (@period - (last_epoch_time % @period) + 1).to_i)
+    end
+
+    raise Mastodon::RateLimitExceededError if count.present? && count.to_i >= @limit
+
+    redis.incr(key)
+  end
+
+  def rollback!
+    redis.decr(key)
+  end
+
+  def to_headers(now = Time.now.utc)
+    {
+      'X-RateLimit-Limit' => @limit.to_s,
+      'X-RateLimit-Remaining' => (@limit - (redis.get(key) || 0).to_i).to_s,
+      'X-RateLimit-Reset' => (now + (@period - now.to_i % @period)).iso8601(6),
+    }
+  end
+
+  private
+
+  def key
+    @key ||= "rate_limit:#{@by.id}:#{@family}:#{(last_epoch_time / @period).to_i}"
+  end
+
+  def last_epoch_time
+    @last_epoch_time ||= Time.now.to_i
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 0fcf897c9..73421ee5a 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -106,6 +106,7 @@ class Account < ApplicationRecord
   scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
   scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
+  scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index c7bf07787..7b6012e0f 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -14,6 +14,7 @@ class AccountFilter
     email
     ip
     staff
+    order
   ).freeze
 
   attr_reader :params
@@ -24,7 +25,7 @@ class AccountFilter
   end
 
   def results
-    scope = Account.recent.includes(:user)
+    scope = Account.includes(:user).reorder(nil)
 
     params.each do |key, value|
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
@@ -38,6 +39,7 @@ class AccountFilter
   def set_defaults!
     params['local']  = '1' if params['remote'].blank?
     params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
+    params['order']  = 'recent' if params['order'].blank?
   end
 
   def scope_for(key, value)
@@ -51,9 +53,9 @@ class AccountFilter
     when 'active'
       Account.without_suspended
     when 'pending'
-      accounts_with_users.merge User.pending
+      accounts_with_users.merge(User.pending)
     when 'disabled'
-      accounts_with_users.merge User.disabled
+      accounts_with_users.merge(User.disabled)
     when 'silenced'
       Account.silenced
     when 'suspended'
@@ -63,16 +65,31 @@ class AccountFilter
     when 'display_name'
       Account.matches_display_name(value)
     when 'email'
-      accounts_with_users.merge User.matches_email(value)
+      accounts_with_users.merge(User.matches_email(value))
     when 'ip'
       valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
     when 'staff'
-      accounts_with_users.merge User.staff
+      accounts_with_users.merge(User.staff)
+    when 'order'
+      order_scope(value)
     else
       raise "Unknown filter: #{key}"
     end
   end
 
+  def order_scope(value)
+    case value
+    when 'active'
+      params['remote'] ? Account.joins(:account_stat).by_recent_status : Account.joins(:user).by_recent_sign_in
+    when 'recent'
+      Account.recent
+    when 'alphabetic'
+      Account.alphabetic
+    else
+      raise "Unknown order: #{value}"
+    end
+  end
+
   def accounts_with_users
     Account.joins(:user)
   end
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
index d99502f44..f8ac4e09d 100644
--- a/app/models/announcement.rb
+++ b/app/models/announcement.rb
@@ -48,6 +48,10 @@ class Announcement < ApplicationRecord
     @mentions ||= Account.from_text(text)
   end
 
+  def statuses
+    @statuses ||= Status.from_text(text)
+  end
+
   def tags
     @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
   end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 14bcf7bb1..32fcb5397 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -87,10 +87,10 @@ module AccountInteractions
     has_many :announcement_mutes, dependent: :destroy
   end
 
-  def follow!(other_account, reblogs: nil, uri: nil)
+  def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
     reblogs = true if reblogs.nil?
 
-    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri)
+    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
                               .find_or_create_by!(target_account: other_account)
 
     rel.update!(show_reblogs: reblogs)
@@ -99,6 +99,18 @@ module AccountInteractions
     rel
   end
 
+  def request_follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
+    reblogs = true if reblogs.nil?
+
+    rel = follow_requests.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+                         .find_or_create_by!(target_account: other_account)
+
+    rel.update!(show_reblogs: reblogs)
+    remove_potential_friendship(other_account)
+
+    rel
+  end
+
   def block!(other_account, uri: nil)
     remove_potential_friendship(other_account)
     block_relationships.create_with(uri: uri)
diff --git a/app/models/concerns/rate_limitable.rb b/app/models/concerns/rate_limitable.rb
new file mode 100644
index 000000000..ad1b5e44e
--- /dev/null
+++ b/app/models/concerns/rate_limitable.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module RateLimitable
+  extend ActiveSupport::Concern
+
+  def rate_limit=(value)
+    @rate_limit = value
+  end
+
+  def rate_limit?
+    @rate_limit
+  end
+
+  def rate_limiter(by, options = {})
+    return @rate_limiter if defined?(@rate_limiter)
+
+    @rate_limiter = RateLimiter.new(by, options)
+  end
+
+  class_methods do
+    def rate_limit(options = {})
+      after_create do
+        by = public_send(options[:by])
+
+        if rate_limit? && by&.local?
+          rate_limiter(by, options).record!
+          @rate_limit_recorded = true
+        end
+      end
+
+      after_rollback do
+        rate_limiter(public_send(options[:by]), options).rollback! if @rate_limit_recorded
+      end
+    end
+  end
+end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 87fa11425..f3e48a2ed 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -15,6 +15,9 @@
 class Follow < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include RateLimitable
+
+  rate_limit by: :account, family: :follows
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 96ac7eaa5..3325e264c 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -15,6 +15,9 @@
 class FollowRequest < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include RateLimitable
+
+  rate_limit by: :account, family: :follows
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6a0b892f6..2813d9200 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -170,6 +170,7 @@ class MediaAttachment < ApplicationRecord
 
   def variant?(other_file_name)
     return true if file_file_name == other_file_name
+    return false if file_file_name.nil?
 
     formats = file.styles.values.map(&:format).compact
 
diff --git a/app/models/status.rb b/app/models/status.rb
index f4284f771..b2d3c3f3b 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -35,6 +35,9 @@ class Status < ApplicationRecord
   include Paginable
   include Cacheable
   include StatusThreadingConcern
+  include RateLimitable
+
+  rate_limit by: :account, family: :statuses
 
   self.discard_column = :deleted_at
 
@@ -416,6 +419,21 @@ class Status < ApplicationRecord
       end
     end
 
+    def from_text(text)
+      return [] if text.blank?
+
+      text.scan(FetchLinkCardService::URL_PATTERN).map(&:first).uniq.map do |url|
+        status = begin
+          if TagManager.instance.local_url?(url)
+            ActivityPub::TagManager.instance.uri_to_resource(url, Status)
+          else
+            Status.find_by(uri: url) || Status.find_by(url: url)
+          end
+        end
+        status&.distributable? ? status : nil
+      end.compact
+    end
+
     private
 
     def timeline_scope(local_only = false)
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
index 2dcb79f51..874f97bab 100644
--- a/app/policies/settings_policy.rb
+++ b/app/policies/settings_policy.rb
@@ -8,4 +8,8 @@ class SettingsPolicy < ApplicationPolicy
   def show?
     admin?
   end
+
+  def destroy?
+    admin?
+  end
 end
diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb
index f27feb669..9343b97d2 100644
--- a/app/serializers/rest/announcement_serializer.rb
+++ b/app/serializers/rest/announcement_serializer.rb
@@ -7,6 +7,7 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
   attribute :read, if: :current_user?
 
   has_many :mentions
+  has_many :statuses
   has_many :tags, serializer: REST::StatusSerializer::TagSerializer
   has_many :emojis, serializer: REST::CustomEmojiSerializer
   has_many :reactions, serializer: REST::ReactionSerializer
@@ -46,4 +47,16 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
       object.pretty_acct
     end
   end
+
+  class StatusSerializer < ActiveModel::Serializer
+    attributes :id, :url
+
+    def id
+      object.id.to_s
+    end
+
+    def url
+      ActivityPub::TagManager.instance.url_for(object)
+    end
+  end
 end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index d217dabb3..493813aab 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -171,7 +171,7 @@ class AccountSearchService < BaseService
   end
 
   def username_complete?
-    query.include?('@') && "@#{query}" =~ Account::MENTION_RE
+    query.include?('@') && "@#{query}" =~ /\A#{Account::MENTION_RE}\Z/
   end
 
   def likely_acct?
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 4d19002c4..311ae7fa6 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -7,54 +7,68 @@ class FollowService < BaseService
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
   # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
-  # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
-  def call(source_account, target_account, reblogs: nil, bypass_locked: false)
-    reblogs = true if reblogs.nil?
-    target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
-
-    raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || (!target_account.local? && target_account.ostatus?) || source_account.domain_blocking?(target_account.domain)
-
-    if source_account.following?(target_account)
-      # We're already following this account, but we'll call follow! again to
-      # make sure the reblogs status is set correctly.
-      return source_account.follow!(target_account, reblogs: reblogs)
-    elsif source_account.requested?(target_account)
-      # This isn't managed by a method in AccountInteractions, so we modify it
-      # ourselves if necessary.
-      req = source_account.follow_requests.find_by(target_account: target_account)
-      req.update!(show_reblogs: reblogs)
-      return req
+  # @param [Hash] options
+  # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
+  # @option [Boolean] :bypass_locked
+  # @option [Boolean] :with_rate_limit
+  def call(source_account, target_account, options = {})
+    @source_account = source_account
+    @target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
+    @options        = { reblogs: true, bypass_locked: false, with_rate_limit: false }.merge(options)
+
+    raise ActiveRecord::RecordNotFound if following_not_possible?
+    raise Mastodon::NotPermittedError  if following_not_allowed?
+
+    if @source_account.following?(@target_account)
+      return change_follow_options!
+    elsif @source_account.requested?(@target_account)
+      return change_follow_request_options!
     end
 
     ActivityTracker.increment('activity:interactions')
 
-    if (target_account.locked? && !bypass_locked) || source_account.silenced? || target_account.activitypub?
-      request_follow(source_account, target_account, reblogs: reblogs)
-    elsif target_account.local?
-      direct_follow(source_account, target_account, reblogs: reblogs)
+    if (@target_account.locked? && !@options[:bypass_locked]) || @source_account.silenced? || @target_account.activitypub?
+      request_follow!
+    elsif @target_account.local?
+      direct_follow!
     end
   end
 
   private
 
-  def request_follow(source_account, target_account, reblogs: true)
-    follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
+  def following_not_possible?
+    @target_account.nil? || @target_account.id == @source_account.id || @target_account.suspended?
+  end
+
+  def following_not_allowed?
+    @target_account.blocking?(@source_account) || @source_account.blocking?(@target_account) || @target_account.moved? || (!@target_account.local? && @target_account.ostatus?) || @source_account.domain_blocking?(@target_account.domain)
+  end
+
+  def change_follow_options!
+    @source_account.follow!(@target_account, reblogs: @options[:reblogs])
+  end
+
+  def change_follow_request_options!
+    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs])
+  end
+
+  def request_follow!
+    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
 
-    if target_account.local?
-      LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
-    elsif target_account.activitypub?
-      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
+    if @target_account.local?
+      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name)
+    elsif @target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
     end
 
     follow_request
   end
 
-  def direct_follow(source_account, target_account, reblogs: true)
-    follow = source_account.follow!(target_account, reblogs: reblogs)
+  def direct_follow!
+    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
 
-    LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
-    MergeWorker.perform_async(target_account.id, source_account.id)
+    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name)
+    MergeWorker.perform_async(@target_account.id, @source_account.id)
 
     follow
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 936e6ac55..5d3b8d725 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -19,6 +19,7 @@ class PostStatusService < BaseService
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @option [Doorkeeper::Application] :application
   # @option [String] :idempotency Optional idempotency key
+  # @option [Boolean] :with_rate_limit
   # @return [Status]
   def call(account, options = {})
     @account     = account
@@ -58,7 +59,7 @@ class PostStatusService < BaseService
      end
     end
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
-    @visibility   = :unlisted if @visibility == :public && @account.silenced?
+    @visibility   = :unlisted if @visibility&.to_sym == :public && @account.silenced?
     @scheduled_at = @options[:scheduled_at]&.to_datetime
     @scheduled_at = nil if scheduled_in_the_past?
   rescue ArgumentError
@@ -170,6 +171,7 @@ class PostStatusService < BaseService
       language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
       application: @options[:application],
       content_type: @options[:content_type] || @account.user&.setting_default_content_type,
+      rate_limit: @options[:with_rate_limit],
     }.compact
   end
 
@@ -189,10 +191,11 @@ class PostStatusService < BaseService
 
   def scheduled_options
     @options.tap do |options_hash|
-      options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id
-      options_hash[:application_id] = options_hash.delete(:application)&.id
-      options_hash[:scheduled_at]   = nil
-      options_hash[:idempotency]    = nil
+      options_hash[:in_reply_to_id]  = options_hash.delete(:thread)&.id
+      options_hash[:application_id]  = options_hash.delete(:application)&.id
+      options_hash[:scheduled_at]    = nil
+      options_hash[:idempotency]     = nil
+      options_hash[:with_rate_limit] = false
     end
   end
 end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 0b12f143c..0a46509f8 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -8,6 +8,8 @@ class ReblogService < BaseService
   # @param [Account] account Account to reblog from
   # @param [Status] reblogged_status Status to be reblogged
   # @param [Hash] options
+  # @option [String]  :visibility
+  # @option [Boolean] :with_rate_limit
   # @return [Status]
   def call(account, reblogged_status, options = {})
     reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
@@ -18,9 +20,15 @@ class ReblogService < BaseService
 
     return reblog unless reblog.nil?
 
-    visibility = options[:visibility] || account.user&.setting_default_privacy
-    visibility = reblogged_status.visibility if reblogged_status.hidden?
-    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility)
+    visibility = begin
+      if reblogged_status.hidden?
+        reblogged_status.visibility
+      else
+        options[:visibility] || account.user&.setting_default_privacy
+      end
+    end
+
+    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
 
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
@@ -45,7 +53,9 @@ class ReblogService < BaseService
 
   def bump_potential_friendship(account, reblog)
     ActivityTracker.increment('activity:interactions')
+
     return if account.following?(reblog.reblog.account_id)
+
     PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
   end
 
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index b057d3e42..44b10af6e 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -11,6 +11,8 @@
   %td
     - if account.user_current_sign_in_at
       %time.time-ago{ datetime: account.user_current_sign_in_at.iso8601, title: l(account.user_current_sign_in_at) }= l account.user_current_sign_in_at
+    - elsif account.last_status_at.present?
+      %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
     - else
       \-
   %td
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 3a85324c9..7592161c9 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -19,6 +19,12 @@
     %ul
       %li= filter_link_to t('admin.accounts.moderation.all'), staff: nil
       %li= filter_link_to t('admin.accounts.roles.staff'), staff: '1'
+  .filter-subset
+    %strong= t 'generic.order_by'
+    %ul
+      %li= filter_link_to t('relationships.most_recent'), order: nil
+      %li= filter_link_to t('admin.accounts.username'), order: 'alphabetic'
+      %li= filter_link_to t('relationships.last_active'), order: 'active'
 
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
   .fields-group
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 63b352361..bff706389 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('admin.settings.title')
 
-= simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch } do |f|
+  - content_for :heading_actions do
+    = button_tag t('generic.save_changes'), class: 'button', form: 'edit_admin'
+
+= simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch, id: 'edit_admin' } do |f|
   = render 'shared/error_messages', object: @admin_settings
 
   .fields-group
@@ -27,13 +30,13 @@
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
+      = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: site_upload_delete_hint(t('admin.settings.thumbnail.desc_html'), :thumbnail)
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
+      = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: site_upload_delete_hint(t('admin.settings.hero.desc_html'), :hero)
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: t('admin.settings.mascot.desc_html')
+      = f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: site_upload_delete_hint(t('admin.settings.mascot.desc_html'), :mascot)
 
   %hr.spacer/
 
diff --git a/app/views/errors/429.html.haml b/app/views/errors/429.html.haml
new file mode 100644
index 000000000..2df4f4175
--- /dev/null
+++ b/app/views/errors/429.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.429')
+
+- content_for :content do
+  = t('errors.429')
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index f460cebba..5453177fd 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.appearance')
 
-= simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_user'
+
+= simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f|
   .fields-group
     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false
 
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index a496be21b..d7cc1ed5d 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.notifications')
 
-= simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_notification'
+
+= simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put, id: 'edit_notification' } do |f|
   = render 'shared/error_messages', object: current_user
 
   %h4= t 'notifications.email_events'
@@ -32,6 +35,3 @@
       = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
       = ff.input :must_be_following, as: :boolean, wrapper: :with_label
       = ff.input :must_be_following_dm, as: :boolean, wrapper: :with_label
-
-  .actions
-    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 9bdcb368d..3b5c7016d 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.preferences')
 
-= simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences'
+
+= simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put, id: 'edit_preferences' } do |f|
   = render 'shared/error_messages', object: current_user
 
   .fields-group
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index f5d928233..7413be1db 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.edit_profile')
 
-= simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_profile'
+
+= simple_form_for @account, url: settings_profile_path, html: { method: :put, id: 'edit_profile' } do |f|
   = render 'shared/error_messages', object: @account
 
   .fields-row
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 3cd7ea3a6..8bc1104d4 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -70,7 +70,6 @@ class Rack::Attack
     req.remote_ip if req.post? && req.path == '/api/v1/accounts'
   end
 
-  # Throttle paging, as it is mainly used for public pages and AP collections
   throttle('throttle_authenticated_paging', limit: 300, period: 15.minutes) do |req|
     req.authenticated_user_id if req.paging_request?
   end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6d6f67f91..a1dc1c326 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -550,6 +550,9 @@ en:
       trends:
         desc_html: Publicly display previously reviewed hashtags that are currently trending
         title: Trending hashtags
+    site_uploads:
+      delete: Delete uploaded file
+      destroyed_msg: Site upload successfully deleted!
     statuses:
       back_to_account: Back to account page
       batch:
@@ -738,7 +741,7 @@ en:
     '422':
       content: Security verification failed. Are you blocking cookies?
       title: Security verification failed
-    '429': Throttled
+    '429': Too many requests
     '500':
       content: We're sorry, but something went wrong on our end.
       title: This page is not correct
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index ca105defa..cf8f7120d 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -46,8 +46,8 @@ en:
         setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
         setting_default_sensitive: Sensitive media is hidden by default and can be revealed with a click
         setting_display_media_default: Hide media marked as sensitive
-        setting_display_media_hide_all: Always hide all media
-        setting_display_media_show_all: Always show media marked as sensitive
+        setting_display_media_hide_all: Always hide media
+        setting_display_media_show_all: Always show media
         setting_hide_network: Who you follow and who follows you will not be shown on your profile
         setting_noindex: Affects your public profile and status pages
         setting_show_application: The application you use to toot will be displayed in the detailed view of your toots
diff --git a/config/routes.rb b/config/routes.rb
index b56f7fd87..103abc6c1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -188,6 +188,7 @@ Rails.application.routes.draw do
     end
 
     resource :settings, only: [:edit, :update]
+    resources :site_uploads, only: [:destroy]
 
     resources :invites, only: [:index, :create, :destroy] do
       collection do
diff --git a/config/webpack/development.js b/config/webpack/development.js
index 56f6e43f0..774ecbc07 100644
--- a/config/webpack/development.js
+++ b/config/webpack/development.js
@@ -54,7 +54,7 @@ module.exports = merge(sharedConfig, {
     watchOptions: Object.assign(
       {},
       settings.dev_server.watch_options,
-      watchOptions
+      watchOptions,
     ),
     writeToDisk: filePath => /ocr/.test(filePath),
   },
diff --git a/config/webpack/shared.js b/config/webpack/shared.js
index 1741ffdb9..08526957b 100644
--- a/config/webpack/shared.js
+++ b/config/webpack/shared.js
@@ -98,7 +98,7 @@ module.exports = {
         // temporary fix for https://github.com/ReactTraining/react-router/issues/5576
         // to reduce bundle size
         resource.request = resource.request.replace(/^history/, 'history/es');
-      }
+      },
     ),
     new MiniCssExtractPlugin({
       filename: 'css/[name]-[contenthash:8].css',
diff --git a/dist/nginx.conf b/dist/nginx.conf
index b6591e897..2d5f3a389 100644
--- a/dist/nginx.conf
+++ b/dist/nginx.conf
@@ -3,6 +3,14 @@ map $http_upgrade $connection_upgrade {
   ''      close;
 }
 
+upstream backend {
+    server 127.0.0.1:3000 fail_timeout=0;
+}
+
+upstream streaming {
+    server 127.0.0.1:4000 fail_timeout=0;
+}
+
 proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=CACHE:10m inactive=7d max_size=1g;
 
 server {
@@ -69,7 +77,7 @@ server {
     proxy_set_header Proxy "";
     proxy_pass_header Server;
 
-    proxy_pass http://127.0.0.1:3000;
+    proxy_pass http://backend;
     proxy_buffering on;
     proxy_redirect off;
     proxy_http_version 1.1;
@@ -93,7 +101,7 @@ server {
     proxy_set_header X-Forwarded-Proto https;
     proxy_set_header Proxy "";
 
-    proxy_pass http://127.0.0.1:4000;
+    proxy_pass http://streaming;
     proxy_buffering off;
     proxy_redirect off;
     proxy_http_version 1.1;
diff --git a/lib/mastodon/statuses_cli.rb b/lib/mastodon/statuses_cli.rb
index 875183372..8a18a3b2f 100644
--- a/lib/mastodon/statuses_cli.rb
+++ b/lib/mastodon/statuses_cli.rb
@@ -14,6 +14,7 @@ module Mastodon
 
     option :days, type: :numeric, default: 90
     option :clean_followed, type: :boolean
+    option :skip_media_remove, type: :boolean
     desc 'remove', 'Remove unreferenced statuses'
     long_desc <<~LONG_DESC
       Remove statuses that are not referenced by local user activity, such as
@@ -58,9 +59,10 @@ module Mastodon
 
       scope.in_batches.delete_all
 
-      say('Beginning removal of now-orphaned media attachments to free up disk space...')
-
-      Scheduler::MediaCleanupScheduler.new.perform
+      unless options[:skip_media_remove]
+        say('Beginning removal of now-orphaned media attachments to free up disk space...')
+        Scheduler::MediaCleanupScheduler.new.perform
+      end
 
       say("Done after #{Time.now.to_f - start_at}s", :green)
     ensure
diff --git a/package.json b/package.json
index 789baf842..9e221eefa 100644
--- a/package.json
+++ b/package.json
@@ -60,14 +60,14 @@
   },
   "private": true,
   "dependencies": {
-    "@babel/core": "^7.8.4",
+    "@babel/core": "^7.8.6",
     "@babel/plugin-proposal-class-properties": "^7.8.3",
     "@babel/plugin-proposal-decorators": "^7.8.3",
     "@babel/plugin-transform-react-inline-elements": "^7.8.3",
     "@babel/plugin-transform-runtime": "^7.8.3",
     "@babel/preset-env": "^7.8.3",
     "@babel/preset-react": "^7.8.3",
-    "@babel/runtime": "^7.8.3",
+    "@babel/runtime": "^7.8.4",
     "@clusterws/cws": "^0.17.3",
     "@gamestdio/websocket": "^0.3.2",
     "array-includes": "^3.1.1",
@@ -187,7 +187,7 @@
     "react-intl-translations-manager": "^5.0.3",
     "react-test-renderer": "^16.12.0",
     "sass-lint": "^1.13.1",
-    "webpack-dev-server": "^3.10.1",
+    "webpack-dev-server": "^3.10.3",
     "yargs": "^15.1.0"
   }
 }
diff --git a/spec/controllers/account_follow_controller_spec.rb b/spec/controllers/account_follow_controller_spec.rb
index ac15499be..9a93e1ebe 100644
--- a/spec/controllers/account_follow_controller_spec.rb
+++ b/spec/controllers/account_follow_controller_spec.rb
@@ -25,7 +25,7 @@ describe AccountFollowController do
       sign_in(user)
       subject
 
-      expect(service).to have_received(:call).with(user.account, 'alice')
+      expect(service).to have_received(:call).with(user.account, alice, with_rate_limit: true)
       expect(response).to redirect_to(account_path(alice))
     end
   end
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index 9ff5fcd3b..df8037038 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -39,12 +39,50 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
     describe 'POST #create' do
       let(:scopes) { 'write:statuses' }
 
-      before do
-        post :create, params: { status: 'Hello world' }
+      context do
+        before do
+          post :create, params: { status: 'Hello world' }
+        end
+
+        it 'returns http success' do
+          expect(response).to have_http_status(200)
+        end
+
+        it 'returns rate limit headers' do
+          expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s
+          expect(response.headers['X-RateLimit-Remaining']).to eq (RateLimiter::FAMILIES[:statuses][:limit] - 1).to_s
+        end
       end
 
-      it 'returns http success' do
-        expect(response).to have_http_status(200)
+      context 'with missing parameters' do
+        before do
+          post :create, params: {}
+        end
+
+        it 'returns http unprocessable entity' do
+          expect(response).to have_http_status(422)
+        end
+
+        it 'returns rate limit headers' do
+          expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s
+        end
+      end
+
+      context 'when exceeding rate limit' do
+        before do
+          rate_limiter = RateLimiter.new(user.account, family: :statuses)
+          300.times { rate_limiter.record! }
+          post :create, params: { status: 'Hello world' }
+        end
+
+        it 'returns http too many requests' do
+          expect(response).to have_http_status(429)
+        end
+
+        it 'returns rate limit headers' do
+          expect(response.headers['X-RateLimit-Limit']).to eq RateLimiter::FAMILIES[:statuses][:limit].to_s
+          expect(response.headers['X-RateLimit-Remaining']).to eq '0'
+        end
       end
     end
 
diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb
index 3202167ca..b6de3e9d1 100644
--- a/spec/features/profile_spec.rb
+++ b/spec/features/profile_spec.rb
@@ -39,7 +39,7 @@ feature 'Profile' do
     visit settings_profile_path
     fill_in 'Display name', with: 'Bob'
     fill_in 'Bio', with: 'Bob is silent'
-    click_on 'Save changes'
+    first('.btn[type=submit]').click
     is_expected.to have_content 'Changes successfully saved!'
 
     # View my own public profile and see the changes
diff --git a/yarn.lock b/yarn.lock
index ceea5e811..aaacea167 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18,18 +18,18 @@
     invariant "^2.2.4"
     semver "^5.5.0"
 
-"@babel/core@^7.1.0", "@babel/core@^7.4.5", "@babel/core@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.4.tgz#d496799e5c12195b3602d0fddd77294e3e38e80e"
-  integrity sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA==
+"@babel/core@^7.1.0", "@babel/core@^7.4.5", "@babel/core@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.6.tgz#27d7df9258a45c2e686b6f18b6c659e563aa4636"
+  integrity sha512-Sheg7yEJD51YHAvLEV/7Uvw95AeWqYPL3Vk3zGujJKIhJ+8oLw2ALaf3hbucILhKsgSoADOvtKRJuNVdcJkOrg==
   dependencies:
     "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.4"
+    "@babel/generator" "^7.8.6"
     "@babel/helpers" "^7.8.4"
-    "@babel/parser" "^7.8.4"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.4"
-    "@babel/types" "^7.8.3"
+    "@babel/parser" "^7.8.6"
+    "@babel/template" "^7.8.6"
+    "@babel/traverse" "^7.8.6"
+    "@babel/types" "^7.8.6"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.1"
@@ -39,12 +39,12 @@
     semver "^5.4.1"
     source-map "^0.5.0"
 
-"@babel/generator@^7.0.0", "@babel/generator@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.4.tgz#35bbc74486956fe4251829f9f6c48330e8d0985e"
-  integrity sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA==
+"@babel/generator@^7.0.0", "@babel/generator@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.6.tgz#57adf96d370c9a63c241cd719f9111468578537a"
+  integrity sha512-4bpOR5ZBz+wWcMeVtcf7FbjcFzCp+817z2/gHNncIRcM9MmKzUhtWCYAq27RAfUrAFwb+OCG1s9WEaVxfi6cjg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.8.6"
     jsesc "^2.5.1"
     lodash "^4.17.13"
     source-map "^0.5.0"
@@ -261,10 +261,10 @@
     esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.8.3", "@babel/parser@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.4.tgz#d1dbe64691d60358a974295fa53da074dd2ce8e8"
-  integrity sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw==
+"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.6.tgz#ba5c9910cddb77685a008e3c587af8d27b67962c"
+  integrity sha512-trGNYSfwq5s0SgM1BMEB8hX3NDmO7EP2wsDGDexiaKMB92BaRpS+qZfpkMqUBhcsOTBwNy9B/jieo4ad/t/z2g==
 
 "@babel/plugin-proposal-async-generator-functions@^7.8.3":
   version "7.8.3"
@@ -801,41 +801,41 @@
   dependencies:
     regenerator-runtime "^0.12.0"
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1"
-  integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w==
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4":
+  version "7.8.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308"
+  integrity sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ==
   dependencies:
     regenerator-runtime "^0.13.2"
 
-"@babel/template@^7.0.0", "@babel/template@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
-  integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==
+"@babel/template@^7.0.0", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
+  integrity sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==
   dependencies:
     "@babel/code-frame" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/parser" "^7.8.6"
+    "@babel/types" "^7.8.6"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.4.tgz#f0845822365f9d5b0e312ed3959d3f827f869e3c"
-  integrity sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg==
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.4", "@babel/traverse@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.6.tgz#acfe0c64e1cd991b3e32eae813a6eb564954b5ff"
+  integrity sha512-2B8l0db/DPi8iinITKuo7cbPznLCEk0kCxDoB9/N6gGNg/gxOXiR/IcymAFPiBwk5w6TtQ27w4wpElgp9btR9A==
   dependencies:
     "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.4"
+    "@babel/generator" "^7.8.6"
     "@babel/helper-function-name" "^7.8.3"
     "@babel/helper-split-export-declaration" "^7.8.3"
-    "@babel/parser" "^7.8.4"
-    "@babel/types" "^7.8.3"
+    "@babel/parser" "^7.8.6"
+    "@babel/types" "^7.8.6"
     debug "^4.1.0"
     globals "^11.1.0"
     lodash "^4.17.13"
 
-"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.3.0", "@babel/types@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
-  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.3.0", "@babel/types@^7.8.3", "@babel/types@^7.8.6":
+  version "7.8.6"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.6.tgz#629ecc33c2557fcde7126e58053127afdb3e6d01"
+  integrity sha512-wqz7pgWMIrht3gquyEFPVXeXCti72Rm8ep9b5tQKz9Yg9LzJA3HxosF1SB3Kc81KD1A3XBkkVYtJvCKS2Z/QrA==
   dependencies:
     esutils "^2.0.2"
     lodash "^4.17.13"
@@ -10870,10 +10870,10 @@ webpack-dev-middleware@^3.7.2:
     range-parser "^1.2.1"
     webpack-log "^2.0.0"
 
-webpack-dev-server@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.10.1.tgz#1ff3e5cccf8e0897aa3f5909c654e623f69b1c0e"
-  integrity sha512-AGG4+XrrXn4rbZUueyNrQgO4KGnol+0wm3MPdqGLmmA+NofZl3blZQKxZ9BND6RDNuvAK9OMYClhjOSnxpWRoA==
+webpack-dev-server@^3.10.3:
+  version "3.10.3"
+  resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.10.3.tgz#f35945036813e57ef582c2420ef7b470e14d3af0"
+  integrity sha512-e4nWev8YzEVNdOMcNzNeCN947sWJNd43E5XvsJzbAL08kGc2frm1tQ32hTJslRS+H65LCb/AaUCYU7fjHCpDeQ==
   dependencies:
     ansi-html "0.0.7"
     bonjour "^3.5.0"