Change account suspensions to be reversible by default (#14726)
parent
bbcbf12215
commit
ed099d8bdc
@ -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