about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2019-09-19 20:58:19 +0200
committerGitHub <noreply@github.com>2019-09-19 20:58:19 +0200
commit3ed94dcc1acf73f1d0d1ab43567b88ee953f57c9 (patch)
tree3bec67e9d52943779f1b4070556ccab8d64bf6a4 /app
parentb6df9c10671cd7bf48de3dbd7a94a92fb0a148ec (diff)
Add account migration UI (#11846)
Fix #10736

- Change data export to be available for non-functional accounts
- Change non-functional accounts to include redirecting accounts
Diffstat (limited to 'app')
-rw-r--r--app/controllers/concerns/export_controller_concern.rb7
-rw-r--r--app/controllers/settings/aliases_controller.rb42
-rw-r--r--app/controllers/settings/exports_controller.rb7
-rw-r--r--app/controllers/settings/migrations_controller.rb48
-rw-r--r--app/helpers/settings_helper.rb8
-rw-r--r--app/models/account_alias.rb41
-rw-r--r--app/models/account_migration.rb74
-rw-r--r--app/models/concerns/account_associations.rb2
-rw-r--r--app/models/form/migration.rb25
-rw-r--r--app/models/remote_follow.rb2
-rw-r--r--app/models/user.rb2
-rw-r--r--app/serializers/activitypub/move_serializer.rb26
-rw-r--r--app/views/auth/registrations/_status.html.haml30
-rw-r--r--app/views/auth/registrations/edit.html.haml2
-rw-r--r--app/views/settings/aliases/index.html.haml29
-rw-r--r--app/views/settings/exports/show.html.haml4
-rw-r--r--app/views/settings/migrations/show.html.haml84
-rw-r--r--app/views/settings/profiles/show.html.haml5
-rw-r--r--app/workers/activitypub/move_distribution_worker.rb32
19 files changed, 412 insertions, 58 deletions
diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb
index e20b71a30..bfe990c82 100644
--- a/app/controllers/concerns/export_controller_concern.rb
+++ b/app/controllers/concerns/export_controller_concern.rb
@@ -5,7 +5,10 @@ module ExportControllerConcern
 
   included do
     before_action :authenticate_user!
+    before_action :require_not_suspended!
     before_action :load_export
+
+    skip_before_action :require_functional!
   end
 
   private
@@ -27,4 +30,8 @@ module ExportControllerConcern
   def export_filename
     "#{controller_name}.csv"
   end
+
+  def require_not_suspended!
+    forbidden if current_account.suspended?
+  end
 end
diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb
new file mode 100644
index 000000000..2b675f065
--- /dev/null
+++ b/app/controllers/settings/aliases_controller.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class Settings::AliasesController < Settings::BaseController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_aliases, except: :destroy
+  before_action :set_alias, only: :destroy
+
+  def index
+    @alias = current_account.aliases.build
+  end
+
+  def create
+    @alias = current_account.aliases.build(resource_params)
+
+    if @alias.save
+      redirect_to settings_aliases_path, notice: I18n.t('aliases.created_msg')
+    else
+      render :show
+    end
+  end
+
+  def destroy
+    @alias.destroy!
+    redirect_to settings_aliases_path, notice: I18n.t('aliases.deleted_msg')
+  end
+
+  private
+
+  def resource_params
+    params.require(:account_alias).permit(:acct)
+  end
+
+  def set_alias
+    @alias = current_account.aliases.find(params[:id])
+  end
+
+  def set_aliases
+    @aliases = current_account.aliases.order(id: :desc).reject(&:new_record?)
+  end
+end
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index 3012fbf77..0e93d07a9 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController
   layout 'admin'
 
   before_action :authenticate_user!
+  before_action :require_not_suspended!
+
+  skip_before_action :require_functional!
 
   def show
     @export  = Export.new(current_account)
@@ -34,4 +37,8 @@ class Settings::ExportsController < Settings::BaseController
   def lock_options
     { redis: Redis.current, key: "backup:#{current_user.id}" }
   end
+
+  def require_not_suspended!
+    forbidden if current_account.suspended?
+  end
 end
diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb
index 59eb48779..90092c692 100644
--- a/app/controllers/settings/migrations_controller.rb
+++ b/app/controllers/settings/migrations_controller.rb
@@ -4,31 +4,59 @@ class Settings::MigrationsController < Settings::BaseController
   layout 'admin'
 
   before_action :authenticate_user!
+  before_action :require_not_suspended!
+  before_action :set_migrations
+  before_action :set_cooldown
+
+  skip_before_action :require_functional!
 
   def show
-    @migration = Form::Migration.new(account: current_account.moved_to_account)
+    @migration = current_account.migrations.build
   end
 
-  def update
-    @migration = Form::Migration.new(resource_params)
+  def create
+    @migration = current_account.migrations.build(resource_params)
 
-    if @migration.valid? && migration_account_changed?
-      current_account.update!(moved_to_account: @migration.account)
+    if @migration.save_with_challenge(current_user)
+      current_account.update!(moved_to_account: @migration.target_account)
       ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
-      redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg')
+      ActivityPub::MoveDistributionWorker.perform_async(@migration.id)
+      redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
     else
       render :show
     end
   end
 
+  def cancel
+    if current_account.moved_to_account_id.present?
+      current_account.update!(moved_to_account: nil)
+      ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
+    end
+
+    redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg')
+  end
+
+  helper_method :on_cooldown?
+
   private
 
   def resource_params
-    params.require(:migration).permit(:acct)
+    params.require(:account_migration).permit(:acct, :current_password, :current_username)
+  end
+
+  def set_migrations
+    @migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?)
+  end
+
+  def set_cooldown
+    @cooldown = current_account.migrations.within_cooldown.first
+  end
+
+  def on_cooldown?
+    @cooldown.present?
   end
 
-  def migration_account_changed?
-    current_account.moved_to_account_id != @migration.account&.id &&
-      current_account.id != @migration.account&.id
+  def require_not_suspended!
+    forbidden if current_account.suspended?
   end
 end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 2b3fd1263..ecc73baf5 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -87,4 +87,12 @@ module SettingsHelper
       'desktop'
     end
   end
+
+  def compact_account_link_to(account)
+    return if account.nil?
+
+    link_to ActivityPub::TagManager.instance.url_for(account), class: 'name-tag', title: account.acct do
+      safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
+    end
+  end
 end
diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb
new file mode 100644
index 000000000..e9a0dd79e
--- /dev/null
+++ b/app/models/account_alias.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_aliases
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  acct       :string           default(""), not null
+#  uri        :string           default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class AccountAlias < ApplicationRecord
+  belongs_to :account
+
+  validates :acct, presence: true, domain: { acct: true }
+  validates :uri, presence: true
+
+  before_validation :set_uri
+  after_create :add_to_account
+  after_destroy :remove_from_account
+
+  private
+
+  def set_uri
+    target_account = ResolveAccountService.new.call(acct)
+    self.uri       = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil?
+  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+    # Validation will take care of it
+  end
+
+  def add_to_account
+    account.update(also_known_as: account.also_known_as + [uri])
+  end
+
+  def remove_from_account
+    account.update(also_known_as: account.also_known_as.reject { |x| x == uri })
+  end
+end
diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb
new file mode 100644
index 000000000..15830bffb
--- /dev/null
+++ b/app/models/account_migration.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_migrations
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  acct              :string           default(""), not null
+#  followers_count   :bigint(8)        default(0), not null
+#  target_account_id :bigint(8)
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountMigration < ApplicationRecord
+  COOLDOWN_PERIOD = 30.days.freeze
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  before_validation :set_target_account
+  before_validation :set_followers_count
+
+  validates :acct, presence: true, domain: { acct: true }
+  validate :validate_migration_cooldown
+  validate :validate_target_account
+
+  scope :within_cooldown, ->(now = Time.now.utc) { where(arel_table[:created_at].gteq(now - COOLDOWN_PERIOD)) }
+
+  attr_accessor :current_password, :current_username
+
+  def save_with_challenge(current_user)
+    if current_user.encrypted_password.present?
+      errors.add(:current_password, :invalid) unless current_user.valid_password?(current_password)
+    else
+      errors.add(:current_username, :invalid) unless account.username == current_username
+    end
+
+    return false unless errors.empty?
+
+    save
+  end
+
+  def cooldown_at
+    created_at + COOLDOWN_PERIOD
+  end
+
+  private
+
+  def set_target_account
+    self.target_account = ResolveAccountService.new.call(acct)
+  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+    # Validation will take care of it
+  end
+
+  def set_followers_count
+    self.followers_count = account.followers_count
+  end
+
+  def validate_target_account
+    if target_account.nil?
+      errors.add(:acct, I18n.t('migrations.errors.not_found'))
+    else
+      errors.add(:acct, I18n.t('migrations.errors.missing_also_known_as')) unless target_account.also_known_as.include?(ActivityPub::TagManager.instance.uri_for(account))
+      errors.add(:acct, I18n.t('migrations.errors.already_moved')) if account.moved_to_account_id.present? && account.moved_to_account_id == target_account.id
+      errors.add(:acct, I18n.t('migrations.errors.move_to_self')) if account.id == target_account.id
+    end
+  end
+
+  def validate_migration_cooldown
+    errors.add(:base, I18n.t('migrations.errors.on_cooldown')) if account.migrations.within_cooldown.exists?
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 1db7771c7..c9cc5c610 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -52,6 +52,8 @@ module AccountAssociations
 
     # Account migrations
     belongs_to :moved_to_account, class_name: 'Account', optional: true
+    has_many :migrations, class_name: 'AccountMigration', dependent: :destroy, inverse_of: :account
+    has_many :aliases, class_name: 'AccountAlias', dependent: :destroy, inverse_of: :account
 
     # Hashtags
     has_and_belongs_to_many :tags
diff --git a/app/models/form/migration.rb b/app/models/form/migration.rb
deleted file mode 100644
index c2a8655e1..000000000
--- a/app/models/form/migration.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-class Form::Migration
-  include ActiveModel::Validations
-
-  attr_accessor :acct, :account
-
-  def initialize(attrs = {})
-    @account = attrs[:account]
-    @acct    = attrs[:account].acct unless @account.nil?
-    @acct    = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil?
-  end
-
-  def valid?
-    return false unless super
-    set_account
-    errors.empty?
-  end
-
-  private
-
-  def set_account
-    self.account = (ResolveAccountService.new.call(acct) if account.nil? && acct.present?)
-  end
-end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 52dd3f67b..5ea535287 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -49,7 +49,7 @@ class RemoteFollow
   end
 
   def fetch_template!
-    return missing_resource if acct.blank?
+    return missing_resource_error if acct.blank?
 
     _, domain = acct.split('@')
 
diff --git a/app/models/user.rb b/app/models/user.rb
index b48455802..9a19a53b3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -168,7 +168,7 @@ class User < ApplicationRecord
   end
 
   def functional?
-    confirmed? && approved? && !disabled? && !account.suspended?
+    confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil?
   end
 
   def unconfirmed_or_pending?
diff --git a/app/serializers/activitypub/move_serializer.rb b/app/serializers/activitypub/move_serializer.rb
new file mode 100644
index 000000000..5675875fa
--- /dev/null
+++ b/app/serializers/activitypub/move_serializer.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ActivityPub::MoveSerializer < ActivityPub::Serializer
+  attributes :id, :type, :target, :actor
+  attribute :virtual_object, key: :object
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object.account), '#moves/', object.id].join
+  end
+
+  def type
+    'Move'
+  end
+
+  def target
+    ActivityPub::TagManager.instance.uri_for(object.target_account)
+  end
+
+  def virtual_object
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object.account)
+  end
+end
diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml
index b38a83d67..47112dae0 100644
--- a/app/views/auth/registrations/_status.html.haml
+++ b/app/views/auth/registrations/_status.html.haml
@@ -1,16 +1,22 @@
 %h3= t('auth.status.account_status')
 
-- if @user.account.suspended?
-  %span.negative-hint= t('user_mailer.warning.explanation.suspend')
-- elsif @user.disabled?
-  %span.negative-hint= t('user_mailer.warning.explanation.disable')
-- elsif @user.account.silenced?
-  %span.warning-hint= t('user_mailer.warning.explanation.silence')
-- elsif !@user.confirmed?
-  %span.warning-hint= t('auth.status.confirming')
-- elsif !@user.approved?
-  %span.warning-hint= t('auth.status.pending')
-- else
-  %span.positive-hint= t('auth.status.functional')
+.simple_form
+  %p.hint
+    - if @user.account.suspended?
+      %span.negative-hint= t('user_mailer.warning.explanation.suspend')
+    - elsif @user.disabled?
+      %span.negative-hint= t('user_mailer.warning.explanation.disable')
+    - elsif @user.account.silenced?
+      %span.warning-hint= t('user_mailer.warning.explanation.silence')
+    - elsif !@user.confirmed?
+      %span.warning-hint= t('auth.status.confirming')
+      = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+    - elsif !@user.approved?
+      %span.warning-hint= t('auth.status.pending')
+    - elsif @user.account.moved_to_account_id.present?
+      %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
+      = link_to t('migrations.cancel'), settings_migration_path
+    - else
+      %span.positive-hint= t('auth.status.functional')
 
 %hr.spacer/
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index 710ee5c68..885171c58 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -13,7 +13,7 @@
       .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?
       .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 => 'off' }, required: true, disabled: current_account.suspended?
+        = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, required: true, disabled: current_account.suspended?, hint: false
 
     .fields-row
       .fields-row__column.fields-group.fields-row__column-6
diff --git a/app/views/settings/aliases/index.html.haml b/app/views/settings/aliases/index.html.haml
new file mode 100644
index 000000000..5b6986368
--- /dev/null
+++ b/app/views/settings/aliases/index.html.haml
@@ -0,0 +1,29 @@
+- content_for :page_title do
+  = t('settings.aliases')
+
+= simple_form_for @alias, url: settings_aliases_path do |f|
+  = render 'shared/error_messages', object: @alias
+
+  %p.hint= t('aliases.hint_html')
+
+  %hr.spacer/
+
+  .fields-group
+    = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }
+
+  .actions
+    = f.button :button, t('aliases.add_new'), type: :submit, class: 'button'
+
+%hr.spacer/
+
+.table-wrapper
+  %table.table.inline-table
+    %thead
+      %tr
+        %th= t('simple_form.labels.account_alias.acct')
+        %th
+    %tbody
+      - @aliases.each do |account_alias|
+        %tr
+          %td= account_alias.acct
+          %td= table_link_to 'trash', t('aliases.remove'), settings_alias_path(account_alias), data: { method: :delete }
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index b13cea976..76ff76bd9 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -37,12 +37,16 @@
         %td= number_with_delimiter @export.total_domain_blocks
         %td= table_link_to 'download', t('exports.csv'), settings_exports_domain_blocks_path(format: :csv)
 
+%hr.spacer/
+
 %p.muted-hint= t('exports.archive_takeout.hint_html')
 
 - if policy(:backup).create?
   %p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post
 
 - unless @backups.empty?
+  %hr.spacer/
+
   .table-wrapper
     %table.table
       %thead
diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml
index c69061d50..1e5c47726 100644
--- a/app/views/settings/migrations/show.html.haml
+++ b/app/views/settings/migrations/show.html.haml
@@ -1,17 +1,85 @@
 - content_for :page_title do
   = t('settings.migrate')
 
-= simple_form_for @migration, as: :migration, url: settings_migration_path, html: { method: :put } do |f|
-  - if @migration.account
-    %p.hint= t('migrations.currently_redirecting')
+.simple_form
+  - if current_account.moved_to_account.present?
+    .fields-row
+      .fields-row__column.fields-group.fields-row__column-6
+        = render 'application/card', account: current_account.moved_to_account
+      .fields-row__column.fields-group.fields-row__column-6
+        %p.hint
+          %span.positive-hint= t('migrations.redirecting_to', acct: current_account.moved_to_account.acct)
 
-    .fields-group
-      = render partial: 'application/card', locals: { account: @migration.account }
+        %p.hint= t('migrations.cancel_explanation')
+
+        %p.hint= link_to t('migrations.cancel'), cancel_settings_migration_path, data: { method: :post }
+  - else
+    %p.hint
+      %span.positive-hint= t('migrations.not_redirecting')
+
+%hr.spacer/
+
+%h3= t 'migrations.proceed_with_move'
+
+= simple_form_for @migration, url: settings_migration_path do |f|
+  - if on_cooldown?
+    %span.warning-hint= t('migrations.on_cooldown', count: ((@cooldown.cooldown_at - Time.now.utc) / 1.day.seconds).ceil)
+  - else
+    %p.hint= t('migrations.warning.before')
+
+    %ul.hint
+      %li.warning-hint= t('migrations.warning.followers')
+      %li.warning-hint= t('migrations.warning.other_data')
+      %li.warning-hint= t('migrations.warning.backreference_required')
+      %li.warning-hint= t('migrations.warning.cooldown')
+      %li.warning-hint= t('migrations.warning.disabled_account')
+
+  %hr.spacer/
 
   = render 'shared/error_messages', object: @migration
 
-  .fields-group
-    = f.input :acct, placeholder: t('migrations.acct')
+  .fields-row
+    .fields-row__column.fields-group.fields-row__column-6
+      = f.input :acct, wrapper: :with_block_label, input_html: { autocapitalize: 'none', autocorrect: 'off' }, disabled: on_cooldown?
+
+    .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 => 'off' }, required: true, disabled: on_cooldown?
+      - else
+        = f.input :current_username, wrapper: :with_block_label, input_html: { :autocomplete => 'off' }, required: true, disabled: on_cooldown?
 
   .actions
-    = f.button :button, t('migrations.proceed'), type: :submit, class: 'negative'
+    = f.button :button, t('migrations.proceed_with_move'), type: :submit, class: 'button button--destructive', disabled: on_cooldown?
+
+- unless @migrations.empty?
+  %hr.spacer/
+
+  %h3= t 'migrations.past_migrations'
+
+  %hr.spacer/
+
+  .table-wrapper
+    %table.table.inline-table
+      %thead
+        %tr
+          %th= t('migrations.acct')
+          %th= t('migrations.followers_count')
+          %th
+      %tbody
+        - @migrations.each do |migration|
+          %tr
+            %td
+              - if migration.target_account.present?
+                = compact_account_link_to migration.target_account
+              - else
+                = migration.acct
+
+            %td= number_with_delimiter migration.followers_count
+
+            %td
+              %time.time-ago{ datetime: migration.created_at.iso8601, title: l(migration.created_at) }= l(migration.created_at)
+
+%hr.spacer/
+
+%h3= t 'migrations.incoming_migrations'
+%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path)
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index f042011d6..6929f54f3 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -60,6 +60,11 @@
 %h6= t('auth.migrate_account')
 %p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path)
 
+%hr.spacer/
+
+%h6= t 'migrations.incoming_migrations'
+%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path)
+
 - if open_deletion?
   %hr.spacer/
 
diff --git a/app/workers/activitypub/move_distribution_worker.rb b/app/workers/activitypub/move_distribution_worker.rb
new file mode 100644
index 000000000..396d5258f
--- /dev/null
+++ b/app/workers/activitypub/move_distribution_worker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class ActivityPub::MoveDistributionWorker
+  include Sidekiq::Worker
+  include Payloadable
+
+  sidekiq_options queue: 'push'
+
+  def perform(migration_id)
+    @migration = AccountMigration.find(migration_id)
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [signed_payload, @account.id, inbox_url]
+    end
+
+    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
+      [signed_payload, @account.id, inbox_url]
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def inboxes
+    @inboxes ||= @migration.account.followers.inboxes
+  end
+
+  def signed_payload
+    @signed_payload ||= Oj.dump(serialize_payload(@migration, ActivityPub::MoveSerializer, signer: @account))
+  end
+end