From ed099d8bdc5b3d9e7df7ce5358441887e6bb7e48 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 15 Sep 2020 14:37:58 +0200 Subject: Change account suspensions to be reversible by default (#14726) --- app/controllers/admin/accounts_controller.rb | 31 ++-- app/controllers/api/base_controller.rb | 4 +- .../api/v1/admin/accounts_controller.rb | 9 +- app/controllers/settings/deletes_controller.rb | 2 +- app/lib/activitypub/activity/delete.rb | 2 +- app/mailers/notification_mailer.rb | 16 +- app/mailers/user_mailer.rb | 28 ++-- app/models/account.rb | 9 +- app/models/account_deletion_request.rb | 20 +++ app/models/admin/account_action.rb | 2 +- app/models/concerns/account_associations.rb | 3 + app/models/form/account_batch.rb | 2 +- app/models/invite.rb | 2 +- app/models/user.rb | 4 +- app/policies/account_policy.rb | 4 + app/services/after_unallow_domain_service.rb | 2 +- app/services/block_domain_service.rb | 2 +- app/services/delete_account_service.rb | 180 ++++++++++++++++++++ app/services/suspend_account_service.rb | 183 ++++----------------- app/services/unsuspend_account_service.rb | 52 ++++++ app/views/admin/accounts/show.html.haml | 114 ++++++------- app/workers/account_deletion_worker.rb | 13 ++ app/workers/admin/account_deletion_worker.rb | 13 ++ app/workers/admin/suspension_worker.rb | 6 +- app/workers/admin/unsuspension_worker.rb | 13 ++ app/workers/scheduler/user_cleanup_scheduler.rb | 13 ++ 26 files changed, 468 insertions(+), 261 deletions(-) create mode 100644 app/models/account_deletion_request.rb create mode 100644 app/services/delete_account_service.rb create mode 100644 app/services/unsuspend_account_service.rb create mode 100644 app/workers/account_deletion_worker.rb create mode 100644 app/workers/admin/account_deletion_worker.rb create mode 100644 app/workers/admin/unsuspension_worker.rb (limited to 'app') diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7b1783542..b9b75727d 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,7 +2,7 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] + before_action :set_account, except: [:index] before_action :require_remote_account!, only: [:redownload] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] @@ -14,49 +14,58 @@ module Admin def show authorize @account, :show? + @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest @warnings = @account.targeted_account_warnings.latest.custom + @domain_block = DomainBlock.rule_for(@account.domain) end def memorialize authorize @account, :memorialize? @account.memorialize! log_action :memorialize, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct) end def enable authorize @account.user, :enable? @account.user.enable! log_action :enable, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct) end def approve authorize @account.user, :approve? @account.user.approve! - redirect_to admin_pending_accounts_path + redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct) end def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) - redirect_to admin_pending_accounts_path + DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) + redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) + end + + def destroy + authorize @account, :destroy? + Admin::AccountDeletionWorker.perform_async(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct) end def unsilence authorize @account, :unsilence? @account.unsilence! log_action :unsilence, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct) end def unsuspend authorize @account, :unsuspend? @account.unsuspend! + Admin::UnsuspensionWorker.perform_async(@account.id) log_action :unsuspend, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct) end def redownload @@ -65,7 +74,7 @@ module Admin @account.update!(last_webfingered_at: nil) ResolveAccountService.new.call(@account) - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct) end def remove_avatar @@ -76,7 +85,7 @@ module Admin log_action :remove_avatar, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct) end def remove_header @@ -87,7 +96,7 @@ module Admin log_action :remove_header, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct) end private diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 467225547..e962c4e97 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -96,12 +96,12 @@ class Api::BaseController < ApplicationController def require_user! if !current_user render json: { error: 'This method requires an authenticated user' }, status: 422 - elsif current_user.disabled? - render json: { error: 'Your login is currently disabled' }, status: 403 elsif !current_user.confirmed? render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 elsif !current_user.approved? render json: { error: 'Your login is currently pending approval' }, status: 403 + elsif !current_user.functional? + render json: { error: 'Your login is currently disabled' }, status: 403 else set_user_activity end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index 24c7fbef1..3af572f25 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -58,7 +58,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) + DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def destroy + authorize @account, :destroy? + Admin::AccountDeletionWorker.perform_async(@account.id) render json: @account, serializer: REST::Admin::AccountSerializer end @@ -72,6 +78,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController def unsuspend authorize @account, :unsuspend? @account.unsuspend! + Admin::UnsuspensionWorker.perform_async(@account.id) log_action :unsuspend, @account render json: @account, serializer: REST::Admin::AccountSerializer end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 7d4844e60..f96c83b80 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -43,7 +43,7 @@ class Settings::DeletesController < Settings::BaseController def destroy_account! current_account.suspend! - Admin::SuspensionWorker.perform_async(current_user.account_id, true) + AccountDeletionWorker.perform_async(current_user.account_id) sign_out end end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index dc9ff580c..09b9e5e0e 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def delete_person lock_or_return("delete_in_progress:#{@account.id}") do - SuspendAccountService.new.call(@account, reserve_username: false) + DeleteAccountService.new.call(@account, reserve_username: false) end end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 9d8a7886c..54db892cc 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -10,7 +10,7 @@ class NotificationMailer < ApplicationMailer @me = recipient @status = notification.target_status - return if @me.user.disabled? || @status.nil? + return unless @me.user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) @@ -22,7 +22,7 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account - return if @me.user.disabled? + return unless @me.user.functional? locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) @@ -34,7 +34,7 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status - return if @me.user.disabled? || @status.nil? + return unless @me.user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) @@ -47,7 +47,7 @@ class NotificationMailer < ApplicationMailer @account = notification.from_account @status = notification.target_status - return if @me.user.disabled? || @status.nil? + return unless @me.user.functional? && @status.present? locale_for_account(@me) do thread_by_conversation(@status.conversation) @@ -59,7 +59,7 @@ class NotificationMailer < ApplicationMailer @me = recipient @account = notification.from_account - return if @me.user.disabled? + return unless @me.user.functional? locale_for_account(@me) do mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) @@ -67,7 +67,7 @@ class NotificationMailer < ApplicationMailer end def digest(recipient, **opts) - return if recipient.user.disabled? + return unless recipient.user.functional? @me = recipient @since = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max @@ -88,8 +88,10 @@ class NotificationMailer < ApplicationMailer def thread_by_conversation(conversation) return if conversation.nil? + msg_id = "" + headers['In-Reply-To'] = msg_id - headers['References'] = msg_id + headers['References'] = msg_id end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index b55768551..95996ba3f 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -15,7 +15,7 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.unconfirmed_email.presence || @resource.email, @@ -29,7 +29,7 @@ class UserMailer < Devise::Mailer @token = token @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') @@ -40,7 +40,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') @@ -51,7 +51,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject') @@ -62,7 +62,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject') @@ -73,7 +73,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject') @@ -84,7 +84,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') @@ -95,7 +95,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject') @@ -106,7 +106,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject') @@ -118,7 +118,7 @@ class UserMailer < Devise::Mailer @instance = Rails.configuration.x.local_domain @webauthn_credential = webauthn_credential - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject') @@ -130,7 +130,7 @@ class UserMailer < Devise::Mailer @instance = Rails.configuration.x.local_domain @webauthn_credential = webauthn_credential - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject') @@ -141,7 +141,7 @@ class UserMailer < Devise::Mailer @resource = user @instance = Rails.configuration.x.local_domain - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject') @@ -153,7 +153,7 @@ class UserMailer < Devise::Mailer @instance = Rails.configuration.x.local_domain @backup = backup - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject') @@ -181,7 +181,7 @@ class UserMailer < Devise::Mailer @detection = Browser.new(user_agent) @timestamp = timestamp.to_time.utc - return if @resource.disabled? + return unless @resource.active_for_authentication? I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, diff --git a/app/models/account.rb b/app/models/account.rb index 6b7ebda9e..5acc8d621 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -222,23 +222,20 @@ class Account < ApplicationRecord def suspend!(date = Time.now.utc) transaction do - user&.disable! if local? + create_deletion_request! update!(suspended_at: date) end end def unsuspend! transaction do - user&.enable! if local? + deletion_request&.destroy! update!(suspended_at: nil) end end def memorialize! - transaction do - user&.disable! if local? - update!(memorial: true) - end + update!(memorial: true) end def sign? diff --git a/app/models/account_deletion_request.rb b/app/models/account_deletion_request.rb new file mode 100644 index 000000000..7d0c346cc --- /dev/null +++ b/app/models/account_deletion_request.rb @@ -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 diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index 9edd152f5..c4ac09520 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -134,7 +134,7 @@ class Admin::AccountAction end def process_email! - UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable? + UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable? end def warnable? diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index cca3a17fa..98849f8fc 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -60,5 +60,8 @@ module AccountAssociations # Hashtags has_and_belongs_to_many :tags has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account + + # Account deletion requests + has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy end end diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index 0b285fde9..7b9e40f68 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -69,6 +69,6 @@ class Form::AccountBatch records = accounts.includes(:user) records.each { |account| authorize(account.user, :reject?) } - .each { |account| SuspendAccountService.new.call(account, reserve_email: false, reserve_username: false) } + .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) } end end diff --git a/app/models/invite.rb b/app/models/invite.rb index 29d25eae8..7ea4e2f98 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -28,7 +28,7 @@ class Invite < ApplicationRecord before_validation :set_code def valid_for_use? - (max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?) + (max_uses.nil? || uses < max_uses) && !expired? && user&.functional? end private diff --git a/app/models/user.rb b/app/models/user.rb index dbee08988..6b21d6ed6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -168,7 +168,7 @@ class User < ApplicationRecord end def active_for_authentication? - true + !account.memorial? end def suspicious_sign_in?(ip) @@ -176,7 +176,7 @@ class User < ApplicationRecord end def functional? - confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil? + confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial? && account.moved_to_account_id.nil? end def unconfirmed_or_pending? diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 9c145979d..1b105e92a 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -17,6 +17,10 @@ class AccountPolicy < ApplicationPolicy staff? && !record.user&.staff? end + def destroy? + record.suspended? && record.deletion_request.present? && admin? + end + def unsuspend? staff? end diff --git a/app/services/after_unallow_domain_service.rb b/app/services/after_unallow_domain_service.rb index ccd0b8ae9..d3008a105 100644 --- a/app/services/after_unallow_domain_service.rb +++ b/app/services/after_unallow_domain_service.rb @@ -3,7 +3,7 @@ class AfterUnallowDomainService < BaseService def call(domain) Account.where(domain: domain).find_each do |account| - SuspendAccountService.new.call(account, reserve_username: false) + DeleteAccountService.new.call(account, reserve_username: false) end end end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index dc23ef8d8..1cf3382b3 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -36,7 +36,7 @@ class BlockDomainService < BaseService def suspend_accounts! blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at) blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account| - SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at) + DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at) end end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb new file mode 100644 index 000000000..15bdd13e3 --- /dev/null +++ b/app/services/delete_account_service.rb @@ -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 diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index ecc893931..5a079c3ac 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -1,175 +1,52 @@ # frozen_string_literal: true class SuspendAccountService < 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) + def call(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! - purge_user! - purge_profile! - purge_content! + suspend! + unmerge_from_home_timelines! + unmerge_from_list_timelines! + privatize_media_attachments! 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 + def suspend! + @account.suspend! unless @account.suspended? 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 + def unmerge_from_home_timelines! + @account.followers_for_local_distribution.find_each do |follower| + FeedManager.instance.unmerge_from_timeline(@account, follower) 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]) + def unmerge_from_list_timelines! + @account.lists_for_local_distribution.find_each do |list| + FeedManager.instance.unmerge_from_list(@account, list) 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] + def privatize_media_attachments! + attachment_names = MediaAttachment.attachment_definitions.keys - @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 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 + @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 - def associations_for_destruction - if @options[:reserve_username] - ASSOCIATIONS_ON_SUSPEND - else - ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY + styles.each do |style| + case Paperclip::Attachment.default_options[:storage] + when :s3 + attachment.s3_object(style).acl.put(:private) + when :fog + # Not supported + when :filesystem + FileUtils.chmod(0o600 & ~File.umask, attachment.path(style)) + end + end + end end end end diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb new file mode 100644 index 000000000..3e731ddd9 --- /dev/null +++ b/app/services/unsuspend_account_service.rb @@ -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 diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index e6461aad0..2c48692b7 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -56,19 +56,21 @@ = link_to admin_action_logs_path(target_account_id: @account.id) do .dashboard__counters__text - if @account.local? && @account.user.nil? - %span.neutral= t('admin.accounts.deleted') + = t('admin.accounts.deleted') + - elsif @account.memorial? + = t('admin.accounts.memorialized') - elsif @account.suspended? - %span.red= t('admin.accounts.suspended') + = t('admin.accounts.suspended') - elsif @account.silenced? - %span.red= t('admin.accounts.silenced') + = t('admin.accounts.silenced') - elsif @account.local? && @account.user&.disabled? - %span.red= t('admin.accounts.disabled') + = t('admin.accounts.disabled') - elsif @account.local? && !@account.user&.confirmed? - %span.neutral= t('admin.accounts.confirming') + = t('admin.accounts.confirming') - elsif @account.local? && !@account.user_approved? - %span.neutral= t('admin.accounts.pending') + = t('admin.accounts.pending') - else - %span.neutral= t('admin.accounts.no_limits_imposed') + = t('admin.accounts.no_limits_imposed') .dashboard__counters__label= t 'admin.accounts.login_status' - unless @account.local? && @account.user.nil? @@ -122,19 +124,6 @@ = t('admin.accounts.confirming') %td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user) - %tr - %th= t('admin.accounts.login_status') - %td - - if @account.user&.disabled? - = t('admin.accounts.disabled') - - else - = t('admin.accounts.enabled') - %td - - if @account.user&.disabled? - = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user) - - elsif @account.user_approved? - = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user) - %tr %th= t('simple_form.labels.defaults.locale') %td= @account.user_locale @@ -172,49 +161,62 @@ %td = @account.inbox_url = fa_icon DeliveryFailureTracker.available?(@account.inbox_url) ? 'check' : 'times' + %td + = table_link_to 'search', @domain_block.present? ? t('admin.domain_blocks.view') : t('admin.accounts.view_domain'), admin_instance_path(@account.domain) %tr %th= t('admin.accounts.shared_inbox_url') %td = @account.shared_inbox_url = fa_icon DeliveryFailureTracker.available?(@account.shared_inbox_url) ? 'check': 'times' + %td + - if @domain_block.nil? + = table_link_to 'ban', t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain) + + - if @account.suspended? + %hr.spacer/ + + %p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible') + + = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account) - %div.action-buttons - %div - - if @account.local? && @account.user_approved? - = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account) - - if @account.silenced? - = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account) - - elsif !@account.local? || @account.user_approved? - = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account) - - - if @account.local? - - if @account.user_pending? - = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user) - = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user) - - - unless @account.user_confirmed? - = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user) - - - if @account.suspended? - = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account) - - elsif !@account.local? || @account.user_approved? - = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account) - - - unless @account.local? - - if DomainBlock.rule_for(@account.domain) - = link_to t('admin.domain_blocks.view'), admin_instance_path(@account.domain), class: 'button' + - if @deletion_request.present? + = link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :destroy, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account) + - else + %div.action-buttons + %div + - if @account.local? && @account.user_approved? + = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account) + + - if @account.user_disabled? + = link_to t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post, class: 'button' if can?(:enable, @account.user) + - else + = link_to t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable'), class: 'button' if can?(:disable, @account.user) + + - if @account.silenced? + = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account) + - elsif !@account.local? || @account.user_approved? + = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button' if can?(:silence, @account) + + - if @account.local? + - if @account.user_pending? + = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user) + = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user) + + - unless @account.user_confirmed? + = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user) + + - if !@account.local? || @account.user_approved? + = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button' if can?(:suspend, @account) + + %div + - if @account.local? + = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user) + - if @account.user&.otp_required_for_login? + = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user) + - if !@account.memorial? && @account.user_approved? + = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account) - else - = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive' - - %div - - if @account.local? - = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user) - - if @account.user&.otp_required_for_login? - = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user) - - if !@account.memorial? && @account.user_approved? - = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account) - - else - = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) + = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) %hr.spacer/ diff --git a/app/workers/account_deletion_worker.rb b/app/workers/account_deletion_worker.rb new file mode 100644 index 000000000..0f6be71e1 --- /dev/null +++ b/app/workers/account_deletion_worker.rb @@ -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 diff --git a/app/workers/admin/account_deletion_worker.rb b/app/workers/admin/account_deletion_worker.rb new file mode 100644 index 000000000..82f269ad6 --- /dev/null +++ b/app/workers/admin/account_deletion_worker.rb @@ -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 diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb index 83c815efd..35c570336 100644 --- a/app/workers/admin/suspension_worker.rb +++ b/app/workers/admin/suspension_worker.rb @@ -5,7 +5,9 @@ class Admin::SuspensionWorker sidekiq_options queue: 'pull' - def perform(account_id, remove_user = false) - SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user) + def perform(account_id) + SuspendAccountService.new.call(Account.find(account_id)) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/app/workers/admin/unsuspension_worker.rb b/app/workers/admin/unsuspension_worker.rb new file mode 100644 index 000000000..7cb2349b1 --- /dev/null +++ b/app/workers/admin/unsuspension_worker.rb @@ -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 diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb index 6113edde1..8571b59e1 100644 --- a/app/workers/scheduler/user_cleanup_scheduler.rb +++ b/app/workers/scheduler/user_cleanup_scheduler.rb @@ -6,9 +6,22 @@ class Scheduler::UserCleanupScheduler sidekiq_options lock: :until_executed, retry: 0 def perform + clean_unconfirmed_accounts! + clean_suspended_accounts! + end + + private + + def clean_unconfirmed_accounts! User.where('confirmed_at is NULL AND confirmation_sent_at <= ?', 2.days.ago).reorder(nil).find_in_batches do |batch| Account.where(id: batch.map(&:account_id)).delete_all User.where(id: batch.map(&:id)).delete_all end end + + def clean_suspended_accounts! + AccountDeletionRequest.where('created_at <= ?', AccountDeletionRequest::DELAY_TO_DELETION.ago).reorder(nil).find_each do |deletion_request| + Admin::AccountDeletionWorker.perform_async(deletion_request.account_id) + end + end end -- cgit