Change account suspensions to be reversible by default (#14726)
parent
bbcbf12215
commit
ed099d8bdc
39 changed files with 529 additions and 282 deletions
@ -0,0 +1,20 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
# == Schema Information |
||||||
|
# |
||||||
|
# Table name: account_deletion_requests |
||||||
|
# |
||||||
|
# id :bigint(8) not null, primary key |
||||||
|
# account_id :bigint(8) |
||||||
|
# created_at :datetime not null |
||||||
|
# updated_at :datetime not null |
||||||
|
# |
||||||
|
class AccountDeletionRequest < ApplicationRecord |
||||||
|
DELAY_TO_DELETION = 30.days.freeze |
||||||
|
|
||||||
|
belongs_to :account |
||||||
|
|
||||||
|
def due_at |
||||||
|
created_at + DELAY_TO_DELETION |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,180 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class DeleteAccountService < BaseService |
||||||
|
include Payloadable |
||||||
|
|
||||||
|
ASSOCIATIONS_ON_SUSPEND = %w( |
||||||
|
account_pins |
||||||
|
active_relationships |
||||||
|
block_relationships |
||||||
|
blocked_by_relationships |
||||||
|
conversation_mutes |
||||||
|
conversations |
||||||
|
custom_filters |
||||||
|
domain_blocks |
||||||
|
favourites |
||||||
|
follow_requests |
||||||
|
list_accounts |
||||||
|
mute_relationships |
||||||
|
muted_by_relationships |
||||||
|
notifications |
||||||
|
owned_lists |
||||||
|
passive_relationships |
||||||
|
report_notes |
||||||
|
scheduled_statuses |
||||||
|
status_pins |
||||||
|
).freeze |
||||||
|
|
||||||
|
ASSOCIATIONS_ON_DESTROY = %w( |
||||||
|
reports |
||||||
|
targeted_moderation_notes |
||||||
|
targeted_reports |
||||||
|
).freeze |
||||||
|
|
||||||
|
# Suspend or remove an account and remove as much of its data |
||||||
|
# as possible. If it's a local account and it has not been confirmed |
||||||
|
# or never been approved, then side effects are skipped and both |
||||||
|
# the user and account records are removed fully. Otherwise, |
||||||
|
# it is controlled by options. |
||||||
|
# @param [Account] |
||||||
|
# @param [Hash] options |
||||||
|
# @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts |
||||||
|
# @option [Boolean] :reserve_username Keep account record |
||||||
|
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads |
||||||
|
# @option [Time] :suspended_at Only applicable when :reserve_username is true |
||||||
|
def call(account, **options) |
||||||
|
@account = account |
||||||
|
@options = { reserve_username: true, reserve_email: true }.merge(options) |
||||||
|
|
||||||
|
if @account.local? && @account.user_unconfirmed_or_pending? |
||||||
|
@options[:reserve_email] = false |
||||||
|
@options[:reserve_username] = false |
||||||
|
@options[:skip_side_effects] = true |
||||||
|
end |
||||||
|
|
||||||
|
reject_follows! |
||||||
|
purge_user! |
||||||
|
purge_profile! |
||||||
|
purge_content! |
||||||
|
fulfill_deletion_request! |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def reject_follows! |
||||||
|
return if @account.local? || !@account.activitypub? |
||||||
|
|
||||||
|
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow| |
||||||
|
[build_reject_json(follow), follow.target_account_id, follow.account.inbox_url] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def purge_user! |
||||||
|
return if !@account.local? || @account.user.nil? |
||||||
|
|
||||||
|
if @options[:reserve_email] |
||||||
|
@account.user.disable! |
||||||
|
@account.user.invites.where(uses: 0).destroy_all |
||||||
|
else |
||||||
|
@account.user.destroy |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def purge_content! |
||||||
|
distribute_delete_actor! if @account.local? && !@options[:skip_side_effects] |
||||||
|
|
||||||
|
@account.statuses.reorder(nil).find_in_batches do |statuses| |
||||||
|
statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username] |
||||||
|
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects]) |
||||||
|
end |
||||||
|
|
||||||
|
@account.media_attachments.reorder(nil).find_each do |media_attachment| |
||||||
|
next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id) |
||||||
|
|
||||||
|
media_attachment.destroy |
||||||
|
end |
||||||
|
|
||||||
|
@account.polls.reorder(nil).find_each do |poll| |
||||||
|
next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id) |
||||||
|
|
||||||
|
poll.destroy |
||||||
|
end |
||||||
|
|
||||||
|
associations_for_destruction.each do |association_name| |
||||||
|
destroy_all(@account.public_send(association_name)) |
||||||
|
end |
||||||
|
|
||||||
|
@account.destroy unless @options[:reserve_username] |
||||||
|
end |
||||||
|
|
||||||
|
def purge_profile! |
||||||
|
# If the account is going to be destroyed |
||||||
|
# there is no point wasting time updating |
||||||
|
# its values first |
||||||
|
|
||||||
|
return unless @options[:reserve_username] |
||||||
|
|
||||||
|
@account.silenced_at = nil |
||||||
|
@account.suspended_at = @options[:suspended_at] || Time.now.utc |
||||||
|
@account.locked = false |
||||||
|
@account.memorial = false |
||||||
|
@account.discoverable = false |
||||||
|
@account.display_name = '' |
||||||
|
@account.note = '' |
||||||
|
@account.fields = [] |
||||||
|
@account.statuses_count = 0 |
||||||
|
@account.followers_count = 0 |
||||||
|
@account.following_count = 0 |
||||||
|
@account.moved_to_account = nil |
||||||
|
@account.trust_level = :untrusted |
||||||
|
@account.avatar.destroy |
||||||
|
@account.header.destroy |
||||||
|
@account.save! |
||||||
|
end |
||||||
|
|
||||||
|
def fulfill_deletion_request! |
||||||
|
@account.deletion_request&.destroy |
||||||
|
end |
||||||
|
|
||||||
|
def destroy_all(association) |
||||||
|
association.in_batches.destroy_all |
||||||
|
end |
||||||
|
|
||||||
|
def distribute_delete_actor! |
||||||
|
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| |
||||||
|
[delete_actor_json, @account.id, inbox_url] |
||||||
|
end |
||||||
|
|
||||||
|
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url| |
||||||
|
[delete_actor_json, @account.id, inbox_url] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def delete_actor_json |
||||||
|
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account)) |
||||||
|
end |
||||||
|
|
||||||
|
def build_reject_json(follow) |
||||||
|
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) |
||||||
|
end |
||||||
|
|
||||||
|
def delivery_inboxes |
||||||
|
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url) |
||||||
|
end |
||||||
|
|
||||||
|
def low_priority_delivery_inboxes |
||||||
|
Account.inboxes - delivery_inboxes |
||||||
|
end |
||||||
|
|
||||||
|
def reported_status_ids |
||||||
|
@reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq |
||||||
|
end |
||||||
|
|
||||||
|
def associations_for_destruction |
||||||
|
if @options[:reserve_username] |
||||||
|
ASSOCIATIONS_ON_SUSPEND |
||||||
|
else |
||||||
|
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -1,175 +1,52 @@ |
|||||||
# frozen_string_literal: true |
# frozen_string_literal: true |
||||||
|
|
||||||
class SuspendAccountService < BaseService |
class SuspendAccountService < BaseService |
||||||
include Payloadable |
def call(account) |
||||||
|
|
||||||
ASSOCIATIONS_ON_SUSPEND = %w( |
|
||||||
account_pins |
|
||||||
active_relationships |
|
||||||
block_relationships |
|
||||||
blocked_by_relationships |
|
||||||
conversation_mutes |
|
||||||
conversations |
|
||||||
custom_filters |
|
||||||
domain_blocks |
|
||||||
favourites |
|
||||||
follow_requests |
|
||||||
list_accounts |
|
||||||
mute_relationships |
|
||||||
muted_by_relationships |
|
||||||
notifications |
|
||||||
owned_lists |
|
||||||
passive_relationships |
|
||||||
report_notes |
|
||||||
scheduled_statuses |
|
||||||
status_pins |
|
||||||
).freeze |
|
||||||
|
|
||||||
ASSOCIATIONS_ON_DESTROY = %w( |
|
||||||
reports |
|
||||||
targeted_moderation_notes |
|
||||||
targeted_reports |
|
||||||
).freeze |
|
||||||
|
|
||||||
# Suspend or remove an account and remove as much of its data |
|
||||||
# as possible. If it's a local account and it has not been confirmed |
|
||||||
# or never been approved, then side effects are skipped and both |
|
||||||
# the user and account records are removed fully. Otherwise, |
|
||||||
# it is controlled by options. |
|
||||||
# @param [Account] |
|
||||||
# @param [Hash] options |
|
||||||
# @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts |
|
||||||
# @option [Boolean] :reserve_username Keep account record |
|
||||||
# @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads |
|
||||||
# @option [Time] :suspended_at Only applicable when :reserve_username is true |
|
||||||
def call(account, **options) |
|
||||||
@account = account |
@account = account |
||||||
@options = { reserve_username: true, reserve_email: true }.merge(options) |
|
||||||
|
|
||||||
if @account.local? && @account.user_unconfirmed_or_pending? |
|
||||||
@options[:reserve_email] = false |
|
||||||
@options[:reserve_username] = false |
|
||||||
@options[:skip_side_effects] = true |
|
||||||
end |
|
||||||
|
|
||||||
reject_follows! |
suspend! |
||||||
purge_user! |
unmerge_from_home_timelines! |
||||||
purge_profile! |
unmerge_from_list_timelines! |
||||||
purge_content! |
privatize_media_attachments! |
||||||
end |
end |
||||||
|
|
||||||
private |
private |
||||||
|
|
||||||
def reject_follows! |
def suspend! |
||||||
return if @account.local? || !@account.activitypub? |
@account.suspend! unless @account.suspended? |
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow| |
|
||||||
[build_reject_json(follow), follow.target_account_id, follow.account.inbox_url] |
|
||||||
end |
|
||||||
end |
end |
||||||
|
|
||||||
def purge_user! |
def unmerge_from_home_timelines! |
||||||
return if !@account.local? || @account.user.nil? |
@account.followers_for_local_distribution.find_each do |follower| |
||||||
|
FeedManager.instance.unmerge_from_timeline(@account, follower) |
||||||
if @options[:reserve_email] |
|
||||||
@account.user.disable! |
|
||||||
@account.user.invites.where(uses: 0).destroy_all |
|
||||||
else |
|
||||||
@account.user.destroy |
|
||||||
end |
end |
||||||
end |
end |
||||||
|
|
||||||
def purge_content! |
def unmerge_from_list_timelines! |
||||||
distribute_delete_actor! if @account.local? && !@options[:skip_side_effects] |
@account.lists_for_local_distribution.find_each do |list| |
||||||
|
FeedManager.instance.unmerge_from_list(@account, list) |
||||||
@account.statuses.reorder(nil).find_in_batches do |statuses| |
|
||||||
statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username] |
|
||||||
BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects]) |
|
||||||
end |
end |
||||||
|
|
||||||
@account.media_attachments.reorder(nil).find_each do |media_attachment| |
|
||||||
next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id) |
|
||||||
|
|
||||||
media_attachment.destroy |
|
||||||
end |
|
||||||
|
|
||||||
@account.polls.reorder(nil).find_each do |poll| |
|
||||||
next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id) |
|
||||||
|
|
||||||
poll.destroy |
|
||||||
end |
|
||||||
|
|
||||||
associations_for_destruction.each do |association_name| |
|
||||||
destroy_all(@account.public_send(association_name)) |
|
||||||
end |
|
||||||
|
|
||||||
@account.destroy unless @options[:reserve_username] |
|
||||||
end |
end |
||||||
|
|
||||||
def purge_profile! |
def privatize_media_attachments! |
||||||
# If the account is going to be destroyed |
attachment_names = MediaAttachment.attachment_definitions.keys |
||||||
# there is no point wasting time updating |
|
||||||
# its values first |
|
||||||
|
|
||||||
return unless @options[:reserve_username] |
|
||||||
|
|
||||||
@account.silenced_at = nil |
@account.media_attachments.find_each do |media_attachment| |
||||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc |
attachment_names.each do |attachment_name| |
||||||
@account.locked = false |
attachment = media_attachment.public_send(attachment_name) |
||||||
@account.memorial = false |
styles = [:original] | attachment.styles.keys |
||||||
@account.discoverable = false |
|
||||||
@account.display_name = '' |
|
||||||
@account.note = '' |
|
||||||
@account.fields = [] |
|
||||||
@account.statuses_count = 0 |
|
||||||
@account.followers_count = 0 |
|
||||||
@account.following_count = 0 |
|
||||||
@account.moved_to_account = nil |
|
||||||
@account.trust_level = :untrusted |
|
||||||
@account.avatar.destroy |
|
||||||
@account.header.destroy |
|
||||||
@account.save! |
|
||||||
end |
|
||||||
|
|
||||||
def destroy_all(association) |
|
||||||
association.in_batches.destroy_all |
|
||||||
end |
|
||||||
|
|
||||||
def distribute_delete_actor! |
|
||||||
ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| |
|
||||||
[delete_actor_json, @account.id, inbox_url] |
|
||||||
end |
|
||||||
|
|
||||||
ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url| |
|
||||||
[delete_actor_json, @account.id, inbox_url] |
|
||||||
end |
|
||||||
end |
|
||||||
|
|
||||||
def delete_actor_json |
|
||||||
@delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account)) |
|
||||||
end |
|
||||||
|
|
||||||
def build_reject_json(follow) |
|
||||||
Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer)) |
|
||||||
end |
|
||||||
|
|
||||||
def delivery_inboxes |
|
||||||
@delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url) |
|
||||||
end |
|
||||||
|
|
||||||
def low_priority_delivery_inboxes |
|
||||||
Account.inboxes - delivery_inboxes |
|
||||||
end |
|
||||||
|
|
||||||
def reported_status_ids |
|
||||||
@reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq |
|
||||||
end |
|
||||||
|
|
||||||
def associations_for_destruction |
styles.each do |style| |
||||||
if @options[:reserve_username] |
case Paperclip::Attachment.default_options[:storage] |
||||||
ASSOCIATIONS_ON_SUSPEND |
when :s3 |
||||||
else |
attachment.s3_object(style).acl.put(:private) |
||||||
ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY |
when :fog |
||||||
|
# Not supported |
||||||
|
when :filesystem |
||||||
|
FileUtils.chmod(0o600 & ~File.umask, attachment.path(style)) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
end |
end |
||||||
end |
end |
||||||
end |
end |
||||||
|
@ -0,0 +1,52 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class UnsuspendAccountService < BaseService |
||||||
|
def call(account) |
||||||
|
@account = account |
||||||
|
|
||||||
|
unsuspend! |
||||||
|
merge_into_home_timelines! |
||||||
|
merge_into_list_timelines! |
||||||
|
publish_media_attachments! |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def unsuspend! |
||||||
|
@account.unsuspend! if @account.suspended? |
||||||
|
end |
||||||
|
|
||||||
|
def merge_into_home_timelines! |
||||||
|
@account.followers_for_local_distribution.find_each do |follower| |
||||||
|
FeedManager.instance.merge_into_timeline(@account, follower) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def merge_into_list_timelines! |
||||||
|
@account.lists_for_local_distribution.find_each do |list| |
||||||
|
FeedManager.instance.merge_into_list(@account, list) |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def publish_media_attachments! |
||||||
|
attachment_names = MediaAttachment.attachment_definitions.keys |
||||||
|
|
||||||
|
@account.media_attachments.find_each do |media_attachment| |
||||||
|
attachment_names.each do |attachment_name| |
||||||
|
attachment = media_attachment.public_send(attachment_name) |
||||||
|
styles = [:original] | attachment.styles.keys |
||||||
|
|
||||||
|
styles.each do |style| |
||||||
|
case Paperclip::Attachment.default_options[:storage] |
||||||
|
when :s3 |
||||||
|
attachment.s3_object(style).acl.put(Paperclip::Attachment.default_options[:s3_permissions]) |
||||||
|
when :fog |
||||||
|
# Not supported |
||||||
|
when :filesystem |
||||||
|
FileUtils.chmod(0o666 & ~File.umask, attachment.path(style)) |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class AccountDeletionWorker |
||||||
|
include Sidekiq::Worker |
||||||
|
|
||||||
|
sidekiq_options queue: 'pull' |
||||||
|
|
||||||
|
def perform(account_id) |
||||||
|
DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: false) |
||||||
|
rescue ActiveRecord::RecordNotFound |
||||||
|
true |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Admin::AccountDeletionWorker |
||||||
|
include Sidekiq::Worker |
||||||
|
|
||||||
|
sidekiq_options queue: 'pull' |
||||||
|
|
||||||
|
def perform(account_id) |
||||||
|
DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: true) |
||||||
|
rescue ActiveRecord::RecordNotFound |
||||||
|
true |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,13 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class Admin::UnsuspensionWorker |
||||||
|
include Sidekiq::Worker |
||||||
|
|
||||||
|
sidekiq_options queue: 'pull' |
||||||
|
|
||||||
|
def perform(account_id) |
||||||
|
UnsuspendAccountService.new.call(Account.find(account_id)) |
||||||
|
rescue ActiveRecord::RecordNotFound |
||||||
|
true |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,8 @@ |
|||||||
|
class CreateAccountDeletionRequests < ActiveRecord::Migration[5.2] |
||||||
|
def change |
||||||
|
create_table :account_deletion_requests do |t| |
||||||
|
t.references :account, foreign_key: { on_delete: :cascade } |
||||||
|
t.timestamps |
||||||
|
end |
||||||
|
end |
||||||
|
end |
@ -0,0 +1,3 @@ |
|||||||
|
Fabricator(:account_deletion_request) do |
||||||
|
account |
||||||
|
end |
@ -0,0 +1,4 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe AccountDeletionRequest, type: :model do |
||||||
|
end |
@ -1,6 +1,6 @@ |
|||||||
require 'rails_helper' |
require 'rails_helper' |
||||||
|
|
||||||
RSpec.describe SuspendAccountService, type: :service do |
RSpec.describe DeleteAccountService, type: :service do |
||||||
describe '#call on local account' do |
describe '#call on local account' do |
||||||
before do |
before do |
||||||
stub_request(:post, "https://alice.com/inbox").to_return(status: 201) |
stub_request(:post, "https://alice.com/inbox").to_return(status: 201) |
Loading…
Reference in new issue