about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/build-image.yml5
-rw-r--r--.rubocop.yml4
-rw-r--r--Dockerfile5
-rw-r--r--app/controllers/accounts_controller.rb2
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb4
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/followed_tags_controller.rb6
-rw-r--r--app/controllers/api/v1/tags_controller.rb2
-rw-r--r--app/controllers/statuses_cleanup_controller.rb4
-rw-r--r--app/javascript/mastodon/features/emoji/__tests__/emoji-test.js5
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js23
-rw-r--r--app/javascript/mastodon/features/explore/index.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/header.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json2
-rw-r--r--app/models/form/account_batch.rb4
-rw-r--r--app/models/user.rb5
-rw-r--r--app/policies/email_domain_block_policy.rb4
-rw-r--r--app/serializers/manifest_serializer.rb1
-rw-r--r--app/services/verify_link_service.rb4
-rw-r--r--app/views/application/_card.html.haml2
-rw-r--r--app/views/auth/challenges/new.html.haml2
-rw-r--r--app/views/auth/confirmations/new.html.haml2
-rw-r--r--app/views/auth/passwords/edit.html.haml4
-rw-r--r--app/views/auth/passwords/new.html.haml2
-rw-r--r--app/views/auth/registrations/_sessions.html.haml2
-rw-r--r--app/views/auth/registrations/edit.html.haml8
-rw-r--r--app/views/auth/registrations/new.html.haml14
-rw-r--r--app/views/auth/sessions/new.html.haml6
-rw-r--r--app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml2
-rw-r--r--app/views/auth/setup/show.html.haml2
-rw-r--r--app/views/settings/deletes/show.html.haml4
-rw-r--r--app/views/settings/migration/redirects/new.html.haml4
-rw-r--r--app/views/settings/migrations/show.html.haml4
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml2
-rw-r--r--app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml2
-rw-r--r--app/views/statuses/_detailed_status.html.haml4
-rw-r--r--app/views/statuses/_simple_status.html.haml4
-rw-r--r--chart/Chart.yaml4
-rw-r--r--config/environments/production.rb24
-rw-r--r--config/initializers/doorkeeper.rb10
-rw-r--r--config/locales/en.yml26
-rw-r--r--config/navigation.rb2
-rw-r--r--spec/controllers/admin/statuses_controller_spec.rb2
-rw-r--r--spec/services/favourite_service_spec.rb2
-rw-r--r--spec/services/follow_service_spec.rb2
-rw-r--r--spec/services/verify_link_service_spec.rb20
-rw-r--r--spec/views/statuses/show.html.haml_spec.rb2
47 files changed, 184 insertions, 84 deletions
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 4c3cade3b..3a880fabf 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -37,7 +37,8 @@ jobs:
         with:
           context: .
           platforms: linux/amd64,linux/arm64
+          builder: ${{ steps.buildx.outputs.name }}
           push: ${{ github.event_name != 'pull_request' }}
           tags: ${{ steps.meta.outputs.tags }}
-          cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/mastodon:edge
-          cache-to: type=inline
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
diff --git a/.rubocop.yml b/.rubocop.yml
index 8dc2d1c47..38a413c2e 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -243,6 +243,10 @@ Style/HashTransformKeys:
 Style/HashTransformValues:
   Enabled: false
 
+Style/HashSyntax:
+  Enabled: true
+  EnforcedStyle: ruby19_no_mixed_keys
+
 Style/IfUnlessModifier:
   Enabled: false
 
diff --git a/Dockerfile b/Dockerfile
index cf311fef2..57274cfd9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,4 @@
+# syntax=docker/dockerfile:1.4
 FROM ubuntu:20.04 as build-dep
 
 # Use bash for the shell
@@ -65,8 +66,8 @@ RUN cd /opt/mastodon && \
 FROM ubuntu:20.04
 
 # Copy over all the langs needed for runtime
-COPY --from=build-dep /opt/node /opt/node
-COPY --from=build-dep /opt/ruby /opt/ruby
+COPY --from=build-dep --link /opt/node /opt/node
+COPY --from=build-dep --link /opt/ruby /opt/ruby
 
 # Add more PATHs to the PATH
 ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin"
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index f36a0c859..4d03a04b7 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -17,6 +17,8 @@ class AccountsController < ApplicationController
     respond_to do |format|
       format.html do
         expires_in 0, public: true unless user_signed_in?
+
+        @rss_url = rss_url
       end
 
       format.rss do
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 32f1f9a5d..e79f7a43e 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -9,9 +9,9 @@ module Admin
       @form = Form::DomainBlockBatch.new(form_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
       @form.save
     rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.email_domain_blocks.no_domain_block_selected')
+      flash[:alert] = I18n.t('admin.domain_blocks.no_domain_block_selected')
     rescue Mastodon::NotPermittedError
-      flash[:alert] = I18n.t('admin.domain_blocks.created_msg')
+      flash[:alert] = I18n.t('admin.domain_blocks.not_permitted')
     else
       redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
     end
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index 593457b94..a0a43de19 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -19,7 +19,7 @@ module Admin
     rescue ActionController::ParameterMissing
       flash[:alert] = I18n.t('admin.email_domain_blocks.no_email_domain_block_selected')
     rescue Mastodon::NotPermittedError
-      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+      flash[:alert] = I18n.t('admin.email_domain_blocks.not_permitted')
     ensure
       redirect_to admin_email_domain_blocks_path
     end
diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb
index f0dfd044c..eae2bdc01 100644
--- a/app/controllers/api/v1/followed_tags_controller.rb
+++ b/app/controllers/api/v1/followed_tags_controller.rb
@@ -3,11 +3,11 @@
 class Api::V1::FollowedTagsController < Api::BaseController
   TAGS_LIMIT = 100
 
-  before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }, except: :show
+  before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }
   before_action :require_user!
   before_action :set_results
 
-  after_action :insert_pagination_headers, only: :show
+  after_action :insert_pagination_headers
 
   def index
     render json: @results.map(&:tag), each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@results.map(&:tag), current_user&.account_id)
@@ -43,7 +43,7 @@ class Api::V1::FollowedTagsController < Api::BaseController
   end
 
   def records_continue?
-    @results.size == limit_param(TAG_LIMIT)
+    @results.size == limit_param(TAGS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb
index 32f71bdce..0966ee469 100644
--- a/app/controllers/api/v1/tags_controller.rb
+++ b/app/controllers/api/v1/tags_controller.rb
@@ -12,7 +12,7 @@ class Api::V1::TagsController < Api::BaseController
   end
 
   def follow
-    TagFollow.create!(tag: @tag, account: current_account, rate_limit: true)
+    TagFollow.first_or_create!(tag: @tag, account: current_account, rate_limit: true)
     render json: @tag, serializer: REST::TagSerializer
   end
 
diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb
index 3d4f4af02..0e7bb835f 100644
--- a/app/controllers/statuses_cleanup_controller.rb
+++ b/app/controllers/statuses_cleanup_controller.rb
@@ -20,6 +20,10 @@ class StatusesCleanupController < ApplicationController
     # Do nothing
   end
 
+  def require_functional!
+    redirect_to edit_user_registration_path unless current_user.functional_or_moved?
+  end
+
   private
 
   def set_pack
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
index 2f19aab7e..72a732e3b 100644
--- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
@@ -88,5 +88,10 @@ describe('emoji', () => {
       expect(emojify('💂‍♀️💂‍♂️'))
         .toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
     });
+
+    it('keeps ordering as expected (issue fixed by PR 20677)', () => {
+      expect(emojify('<p>💕 <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>'))
+        .toEqual('<p><img draggable="false" class="emojione" alt="💕" title=":two_hearts:" src="/emoji/1f495.svg"> <a class="hashtag" href="https://example.com/tags/foo" rel="nofollow noopener noreferrer" target="_blank">#<span>foo</span></a> test: foo.</p>');
+    });
   });
 });
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 52a8458fb..bc3dd8c60 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -19,8 +19,6 @@ const emojiFilename = (filename) => {
   return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
 };
 
-const domParser = new DOMParser();
-
 const emojifyTextNode = (node, customEmojis) => {
   let str = node.textContent;
 
@@ -39,7 +37,7 @@ const emojifyTextNode = (node, customEmojis) => {
       }
     }
 
-    let rend, replacement = '';
+    let rend, replacement = null;
     if (i === str.length) {
       break;
     } else if (str[i] === ':') {
@@ -51,7 +49,14 @@ const emojifyTextNode = (node, customEmojis) => {
         // if you want additional emoji handler, add statements below which set replacement and return true.
         if (shortname in customEmojis) {
           const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
-          replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
+          replacement = document.createElement('img');
+          replacement.setAttribute('draggable', false);
+          replacement.setAttribute('class', 'emojione custom-emoji');
+          replacement.setAttribute('alt', shortname);
+          replacement.setAttribute('title', shortname);
+          replacement.setAttribute('src', filename);
+          replacement.setAttribute('data-original', customEmojis[shortname].url);
+          replacement.setAttribute('data-static', customEmojis[shortname].static_url);
           return true;
         }
         return false;
@@ -59,7 +64,12 @@ const emojifyTextNode = (node, customEmojis) => {
     } else { // matched to unicode emoji
       const { filename, shortCode } = unicodeMapping[match];
       const title = shortCode ? `:${shortCode}:` : '';
-      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
+      replacement = document.createElement('img');
+      replacement.setAttribute('draggable', false);
+      replacement.setAttribute('class', 'emojione');
+      replacement.setAttribute('alt', match);
+      replacement.setAttribute('title', title);
+      replacement.setAttribute('src', `${assetHost}/emoji/${emojiFilename(filename)}.svg`);
       rend = i + match.length;
       // If the matched character was followed by VS15 (for selecting text presentation), skip it.
       if (str.codePointAt(rend) === 65038) {
@@ -69,9 +79,8 @@ const emojifyTextNode = (node, customEmojis) => {
 
     fragment.append(document.createTextNode(str.slice(0, i)));
     if (replacement) {
-      fragment.append(domParser.parseFromString(replacement, 'text/html').documentElement.getElementsByTagName('img')[0]);
+      fragment.append(replacement);
     }
-    node.textContent = str.slice(0, i);
     str = str.slice(rend);
   }
 
diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js
index 552def142..286170c9f 100644
--- a/app/javascript/mastodon/features/explore/index.js
+++ b/app/javascript/mastodon/features/explore/index.js
@@ -24,6 +24,16 @@ const mapStateToProps = state => ({
   isSearching: state.getIn(['search', 'submitted']) || !showTrends,
 });
 
+// Fix strange bug on Safari where <span> (rendered by FormattedMessage) disappears
+// after clicking around Explore top bar (issue #20885).
+// Removing width=100% from <a> also fixes it, as well as replacing <span> with <div>
+// We're choosing to wrap span with div to keep the changes local only to this tool bar.
+const WrapFormattedMessage = ({ children, ...props }) => <div><FormattedMessage {...props}>{children}</FormattedMessage></div>;
+WrapFormattedMessage.propTypes = {
+  children: PropTypes.any,
+};
+
+
 export default @connect(mapStateToProps)
 @injectIntl
 class Explore extends React.PureComponent {
@@ -47,7 +57,7 @@ class Explore extends React.PureComponent {
     this.column = c;
   }
 
-  render () {
+  render() {
     const { intl, multiColumn, isSearching } = this.props;
     const { signedIn } = this.context.identity;
 
@@ -70,10 +80,10 @@ class Explore extends React.PureComponent {
           ) : (
             <React.Fragment>
               <div className='account__section-headline'>
-                <NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
-                <NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
-                <NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
-                {signedIn && <NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
+                <NavLink exact to='/explore'><WrapFormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
+                <NavLink exact to='/explore/tags'><WrapFormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
+                <NavLink exact to='/explore/links'><WrapFormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
+                {signedIn && <NavLink exact to='/explore/suggestions'><WrapFormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
               </div>
 
               <Switch>
diff --git a/app/javascript/mastodon/features/ui/components/header.js b/app/javascript/mastodon/features/ui/components/header.js
index 4e109080e..bbb0ca1c6 100644
--- a/app/javascript/mastodon/features/ui/components/header.js
+++ b/app/javascript/mastodon/features/ui/components/header.js
@@ -35,7 +35,7 @@ class Header extends React.PureComponent {
     if (signedIn) {
       content = (
         <>
-          {location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish' defaultMessage='Publish' /></Link>}
+          {location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>}
           <Account />
         </>
       );
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index f7ea661d7..2b99ac502 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -3989,7 +3989,7 @@
     "descriptors": [
       {
         "defaultMessage": "Publish",
-        "id": "compose_form.publish"
+        "id": "compose_form.publish_form"
       },
       {
         "defaultMessage": "Sign in",
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 5cfcf7205..473622edf 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -115,6 +115,10 @@ class Form::AccountBatch
     authorize(account, :suspend?)
     log_action(:suspend, account)
     account.suspend!(origin: :local)
+    account.strikes.create!(
+      account: current_account,
+      action: :suspend
+    )
     Admin::SuspensionWorker.perform_async(account.id)
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 0e8a87aea..0eb975dec 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -237,6 +237,11 @@ class User < ApplicationRecord
   end
 
   def functional?
+
+    functional_or_moved?
+  end
+
+  def functional_or_moved?
     confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial?
   end
 
diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb
index 1a0ddfa87..0d167ea3e 100644
--- a/app/policies/email_domain_block_policy.rb
+++ b/app/policies/email_domain_block_policy.rb
@@ -5,6 +5,10 @@ class EmailDomainBlockPolicy < ApplicationPolicy
     role.can?(:manage_blocks)
   end
 
+  def show?
+    role.can?(:manage_blocks)
+  end
+
   def create?
     role.can?(:manage_blocks)
   end
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
index 5604325be..48f3aa7a6 100644
--- a/app/serializers/manifest_serializer.rb
+++ b/app/serializers/manifest_serializer.rb
@@ -35,6 +35,7 @@ class ManifestSerializer < ActiveModel::Serializer
         src: full_pack_url("media/icons/android-chrome-#{size}x#{size}.png"),
         sizes: "#{size}x#{size}",
         type: 'image/png',
+        purpose: 'any maskable',
       }
     end
   end
diff --git a/app/services/verify_link_service.rb b/app/services/verify_link_service.rb
index 0a39d7f26..7496fe2d5 100644
--- a/app/services/verify_link_service.rb
+++ b/app/services/verify_link_service.rb
@@ -28,7 +28,7 @@ class VerifyLinkService < BaseService
 
     links = Nokogiri::HTML(@body).xpath('//a[contains(concat(" ", normalize-space(@rel), " "), " me ")]|//link[contains(concat(" ", normalize-space(@rel), " "), " me ")]')
 
-    if links.any? { |link| link['href'].downcase == @link_back.downcase }
+    if links.any? { |link| link['href']&.downcase == @link_back.downcase }
       true
     elsif links.empty?
       false
@@ -38,6 +38,8 @@ class VerifyLinkService < BaseService
   end
 
   def link_redirects_back?(test_url)
+    return false if test_url.blank?
+
     redirect_to_url = Request.new(:head, test_url, follow: false).perform do |res|
       res.headers['Location']
     end
diff --git a/app/views/application/_card.html.haml b/app/views/application/_card.html.haml
index 909d9ff81..3d0e6b1da 100644
--- a/app/views/application/_card.html.haml
+++ b/app/views/application/_card.html.haml
@@ -13,4 +13,4 @@
           %strong.emojify.p-name= display_name(account, custom_emojify: true)
         %span
           = acct(account)
-          = fa_icon('lock', { :data => ({hidden: true} unless account.locked?)})
+          = fa_icon('lock', { data: ({hidden: true} unless account.locked?)})
diff --git a/app/views/auth/challenges/new.html.haml b/app/views/auth/challenges/new.html.haml
index ff4b7a506..4f21e4af6 100644
--- a/app/views/auth/challenges/new.html.haml
+++ b/app/views/auth/challenges/new.html.haml
@@ -5,7 +5,7 @@
   = f.input :return_to, as: :hidden
 
   .field-group
-    = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password', :autofocus => true }, label: t('challenge.prompt'), required: true
+    = f.input :current_password, wrapper: :with_block_label, input_html: { autocomplete: 'current-password', autofocus: true }, label: t('challenge.prompt'), required: true
 
   .actions
     = f.button :button, t('challenge.confirm'), type: :submit
diff --git a/app/views/auth/confirmations/new.html.haml b/app/views/auth/confirmations/new.html.haml
index 4a1bedaa4..a294d3cb5 100644
--- a/app/views/auth/confirmations/new.html.haml
+++ b/app/views/auth/confirmations/new.html.haml
@@ -5,7 +5,7 @@
   = render 'shared/error_messages', object: resource
 
   .fields-group
-    = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+    = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label': t('simple_form.labels.defaults.email') }, hint: false
 
   .actions
     = f.button :button, t('auth.resend_confirmation'), type: :submit
diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml
index c7dbebe75..b95a9b676 100644
--- a/app/views/auth/passwords/edit.html.haml
+++ b/app/views/auth/passwords/edit.html.haml
@@ -8,9 +8,9 @@
     = f.input :reset_password_token, as: :hidden
 
     .fields-group
-      = f.input :password, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, required: true
+      = f.input :password, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label': t('simple_form.labels.defaults.new_password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, required: true
     .fields-group
-      = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'new-password' }, required: true
+      = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_new_password'), autocomplete: 'new-password' }, required: true
 
     .actions
       = f.button :button, t('auth.set_new_password'), type: :submit
diff --git a/app/views/auth/passwords/new.html.haml b/app/views/auth/passwords/new.html.haml
index bae5b24ba..10ad108ea 100644
--- a/app/views/auth/passwords/new.html.haml
+++ b/app/views/auth/passwords/new.html.haml
@@ -5,7 +5,7 @@
   = render 'shared/error_messages', object: resource
 
   .fields-group
-    = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+    = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label': t('simple_form.labels.defaults.email') }, hint: false
 
   .actions
     = f.button :button, t('auth.reset_password'), type: :submit
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index 5d993f574..c094dfd25 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -18,7 +18,7 @@
         %tr
           %td
             %span{ title: session.user_agent }<
-              = fa_icon "#{session_device_icon(session)} fw", 'aria-label' => session_device_icon(session)
+              = fa_icon "#{session_device_icon(session)} fw", 'aria-label': session_device_icon(session)
               = ' '
               = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}", default: "#{session.browser}"), platform: t("sessions.platforms.#{session.platform}", default: "#{session.platform}")
           %td
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index c642c2293..60fd1635e 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -11,15 +11,15 @@
   - if !use_seamless_external_login? || resource.encrypted_password.present?
     .fields-row
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :email, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, required: true, disabled: current_account.suspended?
+        = f.input :email, wrapper: :with_label, input_html: { 'aria-label': t('simple_form.labels.defaults.email') }, required: true, disabled: current_account.suspended?
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'current-password' }, required: true, disabled: current_account.suspended?, hint: false
+        = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label': t('simple_form.labels.defaults.current_password'), autocomplete: 'current-password' }, required: true, disabled: current_account.suspended?, hint: false
 
     .fields-row
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: t('simple_form.hints.defaults.password'), disabled: current_account.suspended?
+        = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label': t('simple_form.labels.defaults.new_password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, hint: t('simple_form.hints.defaults.password'), disabled: current_account.suspended?
       .fields-row__column.fields-group.fields-row__column-6
-        = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'new-password' }, disabled: current_account.suspended?
+        = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_new_password'), autocomplete: 'new-password' }, disabled: current_account.suspended?
 
     .actions
       = f.button :button, t('generic.save_changes'), type: :submit, class: 'button', disabled: current_account.suspended?
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index b1d52dd0c..0d8fd800f 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -17,13 +17,13 @@
 
   .fields-group
     = f.simple_fields_for :account do |ff|
-      = ff.input :display_name, wrapper: :with_label, label: false, required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.display_name'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.display_name') }
-      = ff.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false
-    = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'username' }, hint: false
-    = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'new-password', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false
-    = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'new-password' }, hint: false
-    = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false
-    = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }
+      = ff.input :display_name, wrapper: :with_label, label: false, required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.display_name'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.display_name') }
+      = ff.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false
+    = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' }, hint: false
+    = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, hint: false
+    = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password' }, hint: false
+    = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), autocomplete: 'off' }, hint: false
+    = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.honeypot', label: 'Website'), autocomplete: 'off' }
 
   - if approved_registrations? && !@invite.present?
     .fields-group
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index 943618e39..304e3ab84 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -8,11 +8,11 @@
   = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
     .fields-group
       - if use_seamless_external_login?
-        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
+        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label': t('simple_form.labels.defaults.username_or_email') }, hint: false
       - else
-        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+        = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label': t('simple_form.labels.defaults.email') }, hint: false
     .fields-group
-      = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'current-password' }, hint: false
+      = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'current-password' }, hint: false
 
     .actions
       = f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
index 82f957527..094b502b1 100644
--- a/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
+++ b/app/views/auth/sessions/two_factor/_otp_authentication_form.html.haml
@@ -5,7 +5,7 @@
   %p.hint.authentication-hint= t('simple_form.hints.sessions.otp')
 
   .fields-group
-    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'one-time-code' }, autofocus: true
+    = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label': t('simple_form.labels.defaults.otp_attempt'), autocomplete: 'one-time-code' }, autofocus: true
 
   .actions
     = f.button :button, t('auth.login'), type: :submit
diff --git a/app/views/auth/setup/show.html.haml b/app/views/auth/setup/show.html.haml
index c14fed56f..1a6611ceb 100644
--- a/app/views/auth/setup/show.html.haml
+++ b/app/views/auth/setup/show.html.haml
@@ -9,7 +9,7 @@
       %p.hint= t('auth.setup.email_below_hint_html')
 
     .fields-group
-      = f.input :email, required: true, hint: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
+      = f.input :email, required: true, hint: false, input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'off' }
 
     .actions
       = f.submit t('admin.accounts.change_email.label'), class: 'button'
diff --git a/app/views/settings/deletes/show.html.haml b/app/views/settings/deletes/show.html.haml
index c08ee85b0..2e9785c89 100644
--- a/app/views/settings/deletes/show.html.haml
+++ b/app/views/settings/deletes/show.html.haml
@@ -21,9 +21,9 @@
   %hr.spacer/
 
   - if current_user.encrypted_password.present?
-    = f.input :password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, hint: t('deletes.confirm_password')
+    = f.input :password, wrapper: :with_block_label, input_html: { autocomplete: 'current-password' }, hint: t('deletes.confirm_password')
   - else
-    = f.input :username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, hint: t('deletes.confirm_username')
+    = f.input :username, wrapper: :with_block_label, input_html: { autocomplete: 'off' }, hint: t('deletes.confirm_username')
 
   .actions
     = f.button :button, t('deletes.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/settings/migration/redirects/new.html.haml b/app/views/settings/migration/redirects/new.html.haml
index d7868e900..370087879 100644
--- a/app/views/settings/migration/redirects/new.html.haml
+++ b/app/views/settings/migration/redirects/new.html.haml
@@ -19,9 +19,9 @@
 
     .fields-row__column.fields-group.fields-row__column-6
       - if current_user.encrypted_password.present?
-        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true
+        = f.input :current_password, wrapper: :with_block_label, input_html: { autocomplete: 'current-password' }, required: true
       - else
-        = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true
+        = f.input :current_username, wrapper: :with_block_label, input_html: { autocomplete: 'off' }, required: true
 
   .actions
     = f.button :button, t('migrations.set_redirect'), type: :submit, class: 'button button--destructive'
diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml
index 1ecf7302a..31f7d5e58 100644
--- a/app/views/settings/migrations/show.html.haml
+++ b/app/views/settings/migrations/show.html.haml
@@ -48,9 +48,9 @@
 
     .fields-row__column.fields-group.fields-row__column-6
       - if current_user.encrypted_password.present?
-        = f.input :current_password, wrapper: :with_block_label, input_html: { :autocomplete => 'current-password' }, required: true, disabled: on_cooldown?
+        = f.input :current_password, wrapper: :with_block_label, input_html: { autocomplete: 'current-password' }, required: true, disabled: on_cooldown?
       - else
-        = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown?
+        = f.input :current_username, wrapper: :with_block_label, input_html: { autocomplete: 'off' }, required: true, disabled: on_cooldown?
 
   .actions
     = f.button :button, t('migrations.proceed_with_move'), type: :submit, class: 'button button--destructive', disabled: on_cooldown?
diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
index 671237db5..43830ac27 100644
--- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml
+++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml
@@ -12,7 +12,7 @@
       %samp.qr-alternative__code= @new_otp_secret.scan(/.{4}/).join(' ')
 
   .fields-group
-    = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true
+    = f.input :otp_attempt, wrapper: :with_label, hint: t('otp_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { autocomplete: 'off' }, required: true
 
   .actions
     = f.button :button, t('otp_authentication.enable'), type: :submit
diff --git a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
index c5a323ee5..5ec024757 100644
--- a/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
+++ b/app/views/settings/two_factor_authentication/webauthn_credentials/new.html.haml
@@ -8,7 +8,7 @@
   %p.hint= t('webauthn_credentials.description_html')
 
   .fields_group
-    = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { :autocomplete => 'off' }, required: true
+    = f.input :nickname, wrapper: :with_block_label, hint: t('webauthn_credentials.nickname_hint'), input_html: { autocomplete: 'off' }, required: true
 
   .actions
     = f.button :button, t('webauthn_credentials.add'), class: 'js-webauthn', type: :submit
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 619406d89..bf498e33d 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -15,12 +15,12 @@
 
   = account_action_button(status.account)
 
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+  .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
     - if status.spoiler_text?
       %p<
         %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
-    .e-content{ :lang => status.language }
+    .e-content{ lang: status.language }
       = prerender_custom_emojis(status_content_format(status), status.emojis)
 
       - if status.preloadable_poll
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 1e37b6cf3..ecbabf34c 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -27,12 +27,12 @@
           %span.display-name__account
             = acct(status.account)
             = fa_icon('lock') if status.account.locked?
-  .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
+  .status__content.emojify{ data: ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
     - if status.spoiler_text?
       %p<
         %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
-    .e-content{ :lang => status.language }<
+    .e-content{ lang: status.language }<
       = prerender_custom_emojis(status_content_format(status), status.emojis)
 
       - if status.preloadable_poll
diff --git a/chart/Chart.yaml b/chart/Chart.yaml
index 7080095f2..8d67e55eb 100644
--- a/chart/Chart.yaml
+++ b/chart/Chart.yaml
@@ -15,12 +15,12 @@ type: application
 # This is the chart version. This version number should be incremented each time you make changes
 # to the chart and its templates, including the app version.
 # Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 2.3.0
+version: 3.0.0
 
 # This is the version number of the application being deployed. This version number should be
 # incremented each time you make changes to the application. Versions are not expected to
 # follow Semantic Versioning. They should reflect the version the application is using.
-appVersion: v3.5.3
+appVersion: v4.0.2
 
 dependencies:
   - name: elasticsearch
diff --git a/config/environments/production.rb b/config/environments/production.rb
index c50ece2f9..8e2f0cc7f 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -116,18 +116,18 @@ Rails.application.configure do
   end
 
   config.action_mailer.smtp_settings = {
-    :port                 => ENV['SMTP_PORT'],
-    :address              => ENV['SMTP_SERVER'],
-    :user_name            => ENV['SMTP_LOGIN'].presence,
-    :password             => ENV['SMTP_PASSWORD'].presence,
-    :domain               => ENV['SMTP_DOMAIN'] || ENV['LOCAL_DOMAIN'],
-    :authentication       => ENV['SMTP_AUTH_METHOD'] == 'none' ? nil : ENV['SMTP_AUTH_METHOD'] || :plain,
-    :ca_file              => ENV['SMTP_CA_FILE'].presence || '/etc/ssl/certs/ca-certificates.crt',
-    :openssl_verify_mode  => ENV['SMTP_OPENSSL_VERIFY_MODE'],
-    :enable_starttls      => enable_starttls,
-    :enable_starttls_auto => enable_starttls_auto,
-    :tls                  => ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true',
-    :ssl                  => ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true',
+    port: ENV['SMTP_PORT'],
+    address: ENV['SMTP_SERVER'],
+    user_name: ENV['SMTP_LOGIN'].presence,
+    password: ENV['SMTP_PASSWORD'].presence,
+    domain: ENV['SMTP_DOMAIN'] || ENV['LOCAL_DOMAIN'],
+    authentication: ENV['SMTP_AUTH_METHOD'] == 'none' ? nil : ENV['SMTP_AUTH_METHOD'] || :plain,
+    ca_file: ENV['SMTP_CA_FILE'].presence || '/etc/ssl/certs/ca-certificates.crt',
+    openssl_verify_mode: ENV['SMTP_OPENSSL_VERIFY_MODE'],
+    enable_starttls: enable_starttls,
+    enable_starttls_auto: enable_starttls_auto,
+    tls: ENV['SMTP_TLS'].presence && ENV['SMTP_TLS'] == 'true',
+    ssl: ENV['SMTP_SSL'].presence && ENV['SMTP_SSL'] == 'true',
   }
 
   config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 84b649f5c..43aac5769 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -98,9 +98,19 @@ Doorkeeper.configure do
                   :'admin:read',
                   :'admin:read:accounts',
                   :'admin:read:reports',
+                  :'admin:read:domain_allows',
+                  :'admin:read:domain_blocks',
+                  :'admin:read:ip_blocks',
+                  :'admin:read:email_domain_blocks',
+                  :'admin:read:canonical_email_blocks',
                   :'admin:write',
                   :'admin:write:accounts',
                   :'admin:write:reports',
+                  :'admin:write:domain_allows',
+                  :'admin:write:domain_blocks',
+                  :'admin:write:ip_blocks',
+                  :'admin:write:email_domain_blocks',
+                  :'admin:write:canonical_email_blocks',
                   :crypto
 
   # Change the way client credentials are retrieved from the request object.
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2f766cef3..1cc53dca4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -373,6 +373,8 @@ en:
       add_new: Allow federation with domain
       created_msg: Domain has been successfully allowed for federation
       destroyed_msg: Domain has been disallowed from federation
+      export: Export
+      import: Import
       undo: Disallow federation with domain
     domain_blocks:
       add_new: Add new domain block
@@ -382,15 +384,19 @@ en:
       edit: Edit domain block
       existing_domain_block: You have already imposed stricter limits on %{name}.
       existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first.
+      export: Export
+      import: Import
       new:
         create: Create block
         hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
         severity:
-          desc_html: "<strong>Silence</strong> will make the account's posts invisible to anyone who isn't following them. <strong>Suspend</strong> will remove all of the account's content, media, and profile data. Use <strong>None</strong> if you just want to reject media files."
+          desc_html: "<strong>Limit</strong> will make posts from accounts at this domain invisible to anyone who isn't following them. <strong>Suspend</strong> will remove all content, media, and profile data for this domain's accounts from your server. Use <strong>None</strong> if you just want to reject media files."
           noop: None
-          silence: Silence
+          silence: Limit
           suspend: Suspend
         title: New domain block
+      no_domain_block_selected: No domain blocks were changed as none were selected
+      not_permitted: You are not permitted to perform this action
       obfuscate: Obfuscate domain name
       obfuscate_hint: Partially obfuscate the domain name in the list if advertising the list of domain limitations is enabled
       private_comment: Private comment
@@ -422,6 +428,20 @@ en:
       resolved_dns_records_hint_html: The domain name resolves to the following MX domains, which are ultimately responsible for accepting e-mail. Blocking an MX domain will block sign-ups from any e-mail address which uses the same MX domain, even if the visible domain name is different. <strong>Be careful not to block major e-mail providers.</strong>
       resolved_through_html: Resolved through %{domain}
       title: Blocked e-mail domains
+    export_domain_allows:
+      new:
+        title: Import domain allows
+      no_file: No file selected
+    export_domain_blocks:
+      import:
+        description_html: You are about to import a list of domain blocks. Please review this list very carefully, especially if you have not authored this list yourself.
+        existing_relationships_warning: Existing follow relationships
+        private_comment_description_html: 'To help you track where imported blocks come from, imported blocks will be created with the following private comment: <q>%{comment}</q>'
+        private_comment_template: Imported from %{source} on %{date}
+        title: Import domain blocks
+      new:
+        title: Import domain blocks
+      no_file: No file selected
     follow_recommendations:
       description_html: "<strong>Follow recommendations help new users quickly find interesting content</strong>. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language."
       language: For language
@@ -914,7 +934,7 @@ en:
     warning: Be very careful with this data. Never share it with anyone!
     your_token: Your access token
   auth:
-    apply_for_account: Get on waitlist
+    apply_for_account: Request an account
     change_password: Password
     delete_account: Delete account
     delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
diff --git a/config/navigation.rb b/config/navigation.rb
index 4d2da7aa4..aab72d27c 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -23,7 +23,7 @@ SimpleNavigation::Configuration.run do |navigation|
 
     n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? }
     n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
-    n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional? }
+    n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional_or_moved? }
 
     n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_path do |s|
       s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_path, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes}
diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb
index 227688e23..7f912c1c0 100644
--- a/spec/controllers/admin/statuses_controller_spec.rb
+++ b/spec/controllers/admin/statuses_controller_spec.rb
@@ -41,7 +41,7 @@ describe Admin::StatusesController do
 
   describe 'POST #batch' do
     before do
-      post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } }
+      post :batch, params: { account_id: account.id, action => '', admin_status_batch_action: { status_ids: status_ids } }
     end
 
     let(:status_ids) { [media_attached_status.id] }
diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb
index 94a8111dd..9781f0d78 100644
--- a/spec/services/favourite_service_spec.rb
+++ b/spec/services/favourite_service_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe FavouriteService, type: :service do
     let(:status) { Fabricate(:status, account: bob) }
 
     before do
-      stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {})
+      stub_request(:post, "http://example.com/inbox").to_return(status: 200, body: "", headers: {})
       subject.call(sender, status)
     end
 
diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb
index 88346ec54..412c04d76 100644
--- a/spec/services/follow_service_spec.rb
+++ b/spec/services/follow_service_spec.rb
@@ -140,7 +140,7 @@ RSpec.describe FollowService, type: :service do
     let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
 
     before do
-      stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {})
+      stub_request(:post, "http://example.com/inbox").to_return(status: 200, body: "", headers: {})
       subject.call(sender, bob)
     end
 
diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb
index 3fc88e60e..52ba454cc 100644
--- a/spec/services/verify_link_service_spec.rb
+++ b/spec/services/verify_link_service_spec.rb
@@ -76,7 +76,25 @@ RSpec.describe VerifyLinkService, type: :service do
     context 'when a link does not contain a link back' do
       let(:html) { '' }
 
-      it 'marks the field as verified' do
+      it 'does not mark the field as verified' do
+        expect(field.verified?).to be false
+      end
+    end
+
+    context 'when link has no `href` attribute' do
+      let(:html) do
+        <<-HTML
+          <!doctype html>
+          <head>
+            <link type="text/html" rel="me" />
+          </head>
+          <body>
+            <a rel="me" target="_blank">Follow me on Mastodon</a>
+          </body>
+        HTML
+      end
+
+      it 'does not mark the field as verified' do
         expect(field.verified?).to be false
       end
     end
diff --git a/spec/views/statuses/show.html.haml_spec.rb b/spec/views/statuses/show.html.haml_spec.rb
index eeea2f698..ca5bb2ae8 100644
--- a/spec/views/statuses/show.html.haml_spec.rb
+++ b/spec/views/statuses/show.html.haml_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
 
 describe 'statuses/show.html.haml', without_verify_partial_doubles: true do
   before do
-    double(:api_oembed_url => '')
+    double(api_oembed_url: '')
     allow(view).to receive(:show_landing_strip?).and_return(true)
     allow(view).to receive(:site_title).and_return('example site')
     allow(view).to receive(:site_hostname).and_return('example.com')