diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 948e70d5b5..0786985fac 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -117,6 +117,16 @@ module Admin
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
end
+ def unblock_email
+ authorize @account, :unblock_email?
+
+ CanonicalEmailBlock.where(reference_account: @account).delete_all
+
+ log_action :unblock_email, @account
+
+ redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unblocked_email_msg', username: @account.acct)
+ end
+
private
def set_account
diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb
index 748c5de5ad..306ec1f532 100644
--- a/app/controllers/admin/instances_controller.rb
+++ b/app/controllers/admin/instances_controller.rb
@@ -14,6 +14,15 @@ module Admin
authorize :instance, :show?
end
+ def destroy
+ authorize :instance, :destroy?
+
+ Admin::DomainPurgeWorker.perform_async(@instance.domain)
+
+ log_action :destroy, @instance
+ redirect_to admin_instances_path, notice: I18n.t('admin.instances.destroyed_msg', domain: @instance.domain)
+ end
+
def clear_delivery_errors
authorize :delivery, :clear_delivery_errors?
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index ae96f7a344..f3aa4be4f2 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -31,6 +31,8 @@ module Admin::ActionLogsHelper
link_to truncate(record.text), edit_admin_announcement_path(record.id)
when 'IpBlock'
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
+ when 'Instance'
+ record.domain
end
end
@@ -54,6 +56,8 @@ module Admin::ActionLogsHelper
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
when 'IpBlock'
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
+ when 'Instance'
+ attributes['domain']
end
end
end
diff --git a/app/javascript/mastodon/components/admin/Retention.js b/app/javascript/mastodon/components/admin/Retention.js
index aa06722f7e..3a7aaed9d8 100644
--- a/app/javascript/mastodon/components/admin/Retention.js
+++ b/app/javascript/mastodon/components/admin/Retention.js
@@ -42,6 +42,7 @@ export default class Retention extends React.PureComponent {
render () {
const { loading, data } = this.state;
+ const { frequency } = this.props;
let content;
@@ -129,9 +130,18 @@ export default class Retention extends React.PureComponent {
);
}
+ let title = null;
+ switch(frequency) {
+ case 'day':
+ title = ;
+ break;
+ default:
+ title = ;
+ };
+
return (
-
+ {title}
{content}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 3343e9bd38..90c1bb88be 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3074,17 +3074,20 @@ a.account__display-name {
box-sizing: border-box;
width: 100%;
margin: 0;
- color: $inverted-text-color;
- background: $simple-background-color;
- padding: 10px;
+ color: $darker-text-color;
+ background: transparent;
+ padding: 7px 0;
font-family: inherit;
font-size: 14px;
resize: vertical;
border: 0;
+ border-bottom: 2px solid $ui-primary-color;
outline: 0;
- border-radius: 4px;
- &:focus {
+ &:focus,
+ &:active {
+ color: $primary-text-color;
+ border-bottom-color: $ui-highlight-color;
outline: 0;
}
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index 2af9d7c9c6..12136223be 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -26,6 +26,7 @@ class Admin::ActionLogFilter
destroy_domain_allow: { target_type: 'DomainAllow', action: 'destroy' }.freeze,
destroy_domain_block: { target_type: 'DomainBlock', action: 'destroy' }.freeze,
destroy_email_domain_block: { target_type: 'EmailDomainBlock', action: 'destroy' }.freeze,
+ destroy_instance: { target_type: 'Instance', action: 'destroy' }.freeze,
destroy_unavailable_domain: { target_type: 'UnavailableDomain', action: 'destroy' }.freeze,
destroy_status: { target_type: 'Status', action: 'destroy' }.freeze,
disable_2fa_user: { target_type: 'User', action: 'disable' }.freeze,
@@ -49,6 +50,7 @@ class Admin::ActionLogFilter
update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
update_custom_emoji: { target_type: 'CustomEmoji', action: 'update' }.freeze,
update_status: { target_type: 'Status', action: 'update' }.freeze,
+ unblock_email_account: { target_type: 'Account', action: 'unblock_email' }.freeze,
}.freeze
attr_reader :params
diff --git a/app/models/canonical_email_block.rb b/app/models/canonical_email_block.rb
index be8c45bfe3..94781386c9 100644
--- a/app/models/canonical_email_block.rb
+++ b/app/models/canonical_email_block.rb
@@ -24,4 +24,8 @@ class CanonicalEmailBlock < ApplicationRecord
def self.block?(email)
where(canonical_email_hash: email_to_canonical_email_hash(email)).exists?
end
+
+ def self.find_blocks(email)
+ where(canonical_email_hash: email_to_canonical_email_hash(email))
+ end
end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 672e1786bf..46237e45c1 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -64,4 +64,8 @@ class AccountPolicy < ApplicationPolicy
def memorialize?
admin? && !record.user&.admin? && !record.instance_actor?
end
+
+ def unblock_email?
+ staff?
+ end
end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
index a73823556c..801ca162e3 100644
--- a/app/policies/instance_policy.rb
+++ b/app/policies/instance_policy.rb
@@ -8,4 +8,8 @@ class InstancePolicy < ApplicationPolicy
def show?
admin?
end
+
+ def destroy?
+ admin?
+ end
end
diff --git a/app/services/purge_domain_service.rb b/app/services/purge_domain_service.rb
new file mode 100644
index 0000000000..e10a8f0c86
--- /dev/null
+++ b/app/services/purge_domain_service.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class PurgeDomainService < BaseService
+ def call(domain)
+ Account.remote.where(domain: domain).reorder(nil).find_each do |account|
+ DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true)
+ end
+ Instance.refresh
+ end
+end
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 2b6e28e8dd..64cfc9a77b 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -71,7 +71,9 @@
= t('admin.accounts.no_limits_imposed')
.dashboard__counters__label= t 'admin.accounts.login_status'
-- unless @account.local? && @account.user.nil?
+- if @account.local? && @account.user.nil?
+ = link_to t('admin.accounts.unblock_email'), unblock_email_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unblock_email, @account) && CanonicalEmailBlock.where(reference_account_id: @account.id).exists?
+- else
.table-wrapper
%table.table.inline-table
%tbody
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index d6542ac3e2..e520bca0cc 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -84,3 +84,5 @@
= link_to t('admin.instances.delivery.stop'), stop_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button'
- else
= link_to t('admin.instances.delivery.restart'), restart_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }, class: 'button'
+ - unless @instance.delivery_failure_tracker.available? && @instance.accounts_count > 0
+ = link_to t('admin.instances.purge'), admin_instance_path(@instance), data: { confirm: t('admin.instances.confirm_purge'), method: :delete }, class: 'button'
diff --git a/app/workers/admin/domain_purge_worker.rb b/app/workers/admin/domain_purge_worker.rb
new file mode 100644
index 0000000000..7cba2c89e6
--- /dev/null
+++ b/app/workers/admin/domain_purge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Admin::DomainPurgeWorker
+ include Sidekiq::Worker
+
+ def perform(domain)
+ PurgeDomainService.new.call(domain)
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e9a0aea548..32b48dbfff 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -208,6 +208,8 @@ en:
suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
suspension_reversible_hint_html: The account has been suspended, and the data will be fully removed on %{date}. Until then, the account can be restored without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
title: Accounts
+ unblock_email: Unblock email address
+ unblocked_email_msg: Successfully unblocked %{username}'s email address
unconfirmed_email: Unconfirmed email
undo_sensitized: Undo force-sensitive
undo_silenced: Undo limit
@@ -240,6 +242,7 @@ en:
destroy_domain_allow: Delete Domain Allow
destroy_domain_block: Delete Domain Block
destroy_email_domain_block: Delete E-mail Domain Block
+ destroy_instance: Purge Domain
destroy_ip_block: Delete IP rule
destroy_status: Delete Post
destroy_unavailable_domain: Delete Unavailable Domain
@@ -261,6 +264,7 @@ en:
silence_account: Limit Account
suspend_account: Suspend Account
unassigned_report: Unassign Report
+ unblock_email_account: Unblock email address
unsensitive_account: Undo Force-Sensitive Account
unsilence_account: Undo Limit Account
unsuspend_account: Unsuspend Account
@@ -287,6 +291,7 @@ en:
destroy_domain_allow_html: "%{name} disallowed federation with domain %{target}"
destroy_domain_block_html: "%{name} unblocked domain %{target}"
destroy_email_domain_block_html: "%{name} unblocked e-mail domain %{target}"
+ destroy_instance_html: "%{name} purged domain %{target}"
destroy_ip_block_html: "%{name} deleted rule for IP %{target}"
destroy_status_html: "%{name} removed post by %{target}"
destroy_unavailable_domain_html: "%{name} resumed delivery to domain %{target}"
@@ -308,6 +313,7 @@ en:
silence_account_html: "%{name} limited %{target}'s account"
suspend_account_html: "%{name} suspended %{target}'s account"
unassigned_report_html: "%{name} unassigned report %{target}"
+ unblock_email_account_html: "%{name} unblocked %{target}'s email address"
unsensitive_account_html: "%{name} unmarked %{target}'s media as sensitive"
unsilence_account_html: "%{name} undid limit of %{target}'s account"
unsuspend_account_html: "%{name} unsuspended %{target}'s account"
@@ -465,6 +471,7 @@ en:
back_to_limited: Limited
back_to_warning: Warning
by_domain: Domain
+ confirm_purge: Are you sure you want to permanently delete data from this domain?
delivery:
all: All
clear: Clear delivery errors
@@ -480,6 +487,7 @@ en:
delivery_available: Delivery is available
delivery_error_days: Delivery error days
delivery_error_hint: If delivery is not possible for %{count} days, it will be automatically marked as undeliverable.
+ destroyed_msg: Data from %{domain} is now queued for imminent deletion.
empty: No domains found.
known_accounts:
one: "%{count} known account"
@@ -490,6 +498,7 @@ en:
title: Moderation
private_comment: Private comment
public_comment: Public comment
+ purge: Purge
title: Federation
total_blocked_by_us: Blocked by us
total_followed_by_them: Followed by them
diff --git a/config/routes.rb b/config/routes.rb
index 8b1da422f6..285a1cdc93 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -216,7 +216,7 @@ Rails.application.routes.draw do
end
end
- resources :instances, only: [:index, :show], constraints: { id: /[^\/]+/ } do
+ resources :instances, only: [:index, :show, :destroy], constraints: { id: /[^\/]+/ } do
member do
post :clear_delivery_errors
post :restart_delivery
@@ -251,6 +251,7 @@ Rails.application.routes.draw do
post :memorialize
post :approve
post :reject
+ post :unblock_email
end
collection do
diff --git a/lib/cli.rb b/lib/cli.rb
index 8815e137ad..35c00e736f 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli'
require_relative 'mastodon/cache_cli'
require_relative 'mastodon/upgrade_cli'
require_relative 'mastodon/email_domain_blocks_cli'
+require_relative 'mastodon/canonical_email_blocks_cli'
require_relative 'mastodon/ip_blocks_cli'
require_relative 'mastodon/maintenance_cli'
require_relative 'mastodon/version'
@@ -62,6 +63,9 @@ module Mastodon
desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
subcommand 'ip_blocks', Mastodon::IpBlocksCLI
+ desc 'canonical_email_blocks SUBCOMMAND ...ARGS', 'Manage canonical e-mail blocks'
+ subcommand 'canonical_email_blocks', Mastodon::CanonicalEmailBlocksCLI
+
desc 'maintenance SUBCOMMAND ...ARGS', 'Various maintenance utilities'
subcommand 'maintenance', Mastodon::MaintenanceCLI
diff --git a/lib/mastodon/canonical_email_blocks_cli.rb b/lib/mastodon/canonical_email_blocks_cli.rb
new file mode 100644
index 0000000000..64b72e6031
--- /dev/null
+++ b/lib/mastodon/canonical_email_blocks_cli.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'concurrent'
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+ class CanonicalEmailBlocksCLI < Thor
+ include CLIHelper
+
+ def self.exit_on_failure?
+ true
+ end
+
+ desc 'find EMAIL', 'Find a given e-mail address in the canonical e-mail blocks'
+ long_desc <<-LONG_DESC
+ When suspending a local user, a hash of a "canonical" version of their e-mail
+ address is stored to prevent them from signing up again.
+
+ This command can be used to find whether a known email address is blocked,
+ and if so, which account it was attached to.
+ LONG_DESC
+ def find(email)
+ accts = CanonicalEmailBlock.find_blocks(email).map(&:reference_account).map(&:acct).to_a
+ if accts.empty?
+ say("#{email} is not blocked", :yellow)
+ else
+ accts.each do |acct|
+ say(acct, :white)
+ end
+ end
+ end
+
+ desc 'remove EMAIL', 'Remove a canonical e-mail block'
+ long_desc <<-LONG_DESC
+ When suspending a local user, a hash of a "canonical" version of their e-mail
+ address is stored to prevent them from signing up again.
+
+ This command allows removing a canonical email block.
+ LONG_DESC
+ def remove(email)
+ blocks = CanonicalEmailBlock.find_blocks(email)
+ if blocks.empty?
+ say("#{email} is not blocked", :yellow)
+ else
+ blocks.destroy_all
+ say("Removed canonical email block for #{email}", :green)
+ end
+ end
+
+ private
+
+ def color(processed, failed)
+ if !processed.zero? && failed.zero?
+ :green
+ elsif failed.zero?
+ :yellow
+ else
+ :red
+ end
+ end
+ end
+end
diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb
index a5ef396ae4..3edbde03cb 100644
--- a/spec/controllers/admin/accounts_controller_spec.rb
+++ b/spec/controllers/admin/accounts_controller_spec.rb
@@ -192,4 +192,36 @@ RSpec.describe Admin::AccountsController, type: :controller do
end
end
end
+
+ describe 'POST #unblock_email' do
+ subject do
+ -> { post :unblock_email, params: { id: account.id } }
+ end
+
+ let(:current_user) { Fabricate(:user, admin: admin) }
+ let(:account) { Fabricate(:account, suspended: true) }
+ let!(:email_block) { Fabricate(:canonical_email_block, reference_account: account) }
+
+ context 'when user is admin' do
+ let(:admin) { true }
+
+ it 'succeeds in removing email blocks' do
+ is_expected.to change { CanonicalEmailBlock.where(reference_account: account).count }.from(1).to(0)
+ end
+
+ it 'redirects to admin account path' do
+ subject.call
+ expect(response).to redirect_to admin_account_path(account.id)
+ end
+ end
+
+ context 'when user is not admin' do
+ let(:admin) { false }
+
+ it 'fails to remove avatar' do
+ subject.call
+ expect(response).to have_http_status :forbidden
+ end
+ end
+ end
end
diff --git a/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb
index 8c0b309f2a..53427b8748 100644
--- a/spec/controllers/admin/instances_controller_spec.rb
+++ b/spec/controllers/admin/instances_controller_spec.rb
@@ -3,8 +3,14 @@ require 'rails_helper'
RSpec.describe Admin::InstancesController, type: :controller do
render_views
+ let(:current_user) { Fabricate(:user, admin: true) }
+
+ let!(:account) { Fabricate(:account, domain: 'popular') }
+ let!(:account2) { Fabricate(:account, domain: 'popular') }
+ let!(:account3) { Fabricate(:account, domain: 'less.popular') }
+
before do
- sign_in Fabricate(:user, admin: true), scope: :user
+ sign_in current_user, scope: :user
end
describe 'GET #index' do
@@ -16,10 +22,6 @@ RSpec.describe Admin::InstancesController, type: :controller do
end
it 'renders instances' do
- Fabricate(:account, domain: 'popular')
- Fabricate(:account, domain: 'popular')
- Fabricate(:account, domain: 'less.popular')
-
get :index, params: { page: 2 }
instances = assigns(:instances).to_a
@@ -29,4 +31,27 @@ RSpec.describe Admin::InstancesController, type: :controller do
expect(response).to have_http_status(200)
end
end
+
+ describe 'DELETE #destroy' do
+ subject { delete :destroy, params: { id: Instance.first.id } }
+
+ let(:current_user) { Fabricate(:user, admin: admin) }
+ let(:account) { Fabricate(:account) }
+
+ context 'when user is admin' do
+ let(:admin) { true }
+
+ it 'succeeds in purging instance' do
+ is_expected.to redirect_to admin_instances_path
+ end
+ end
+
+ context 'when user is not admin' do
+ let(:admin) { false }
+
+ it 'fails to purge instance' do
+ is_expected.to have_http_status :forbidden
+ end
+ end
+ end
end
diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb
index 1347ca4a03..8a5e62c06e 100644
--- a/spec/policies/account_policy_spec.rb
+++ b/spec/policies/account_policy_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe AccountPolicy do
end
end
- permissions :unsuspend? do
+ permissions :unsuspend?, :unblock_email? do
before do
alice.suspend!
end
diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb
index 77a3bde3fb..72cf25f568 100644
--- a/spec/policies/instance_policy_spec.rb
+++ b/spec/policies/instance_policy_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe InstancePolicy do
let(:admin) { Fabricate(:user, admin: true).account }
let(:john) { Fabricate(:user).account }
- permissions :index? do
+ permissions :index?, :show?, :destroy? do
context 'admin' do
it 'permits' do
expect(subject).to permit(admin, Instance)
diff --git a/spec/services/purge_domain_service_spec.rb b/spec/services/purge_domain_service_spec.rb
new file mode 100644
index 0000000000..59285f1269
--- /dev/null
+++ b/spec/services/purge_domain_service_spec.rb
@@ -0,0 +1,27 @@
+require 'rails_helper'
+
+RSpec.describe PurgeDomainService, type: :service do
+ let!(:old_account) { Fabricate(:account, domain: 'obsolete.org') }
+ let!(:old_status1) { Fabricate(:status, account: old_account) }
+ let!(:old_status2) { Fabricate(:status, account: old_account) }
+ let!(:old_attachment) { Fabricate(:media_attachment, account: old_account, status: old_status2, file: attachment_fixture('attachment.jpg')) }
+
+ subject { PurgeDomainService.new }
+
+ describe 'for a suspension' do
+ before do
+ subject.call('obsolete.org')
+ end
+
+ it 'removes the remote accounts\'s statuses and media attachments' do
+ expect { old_account.reload }.to raise_exception ActiveRecord::RecordNotFound
+ expect { old_status1.reload }.to raise_exception ActiveRecord::RecordNotFound
+ expect { old_status2.reload }.to raise_exception ActiveRecord::RecordNotFound
+ expect { old_attachment.reload }.to raise_exception ActiveRecord::RecordNotFound
+ end
+
+ it 'refreshes instances view' do
+ expect(Instance.where(domain: 'obsolete.org').exists?).to be false
+ end
+ end
+end
diff --git a/spec/workers/admin/domain_purge_worker_spec.rb b/spec/workers/admin/domain_purge_worker_spec.rb
new file mode 100644
index 0000000000..b67c58b234
--- /dev/null
+++ b/spec/workers/admin/domain_purge_worker_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Admin::DomainPurgeWorker do
+ subject { described_class.new }
+
+ describe 'perform' do
+ it 'calls domain purge service for relevant domain block' do
+ service = double(call: nil)
+ allow(PurgeDomainService).to receive(:new).and_return(service)
+ result = subject.perform('example.com')
+
+ expect(result).to be_nil
+ expect(service).to have_received(:call).with('example.com')
+ end
+ end
+end