From ad245adf510cf56953ebb8a7dfc5db16c0f58403 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 31 Jul 2026 15:55:33 +0200 Subject: [PATCH] Add `requested_deletion_at` attribute to `Account` for deleted-but-not-suspended accounts (#23617) --- .github/workflows/test-migrations.yml | 4 +- .../activitypub/base_controller.rb | 4 ++ .../activitypub/inboxes_controller.rb | 4 ++ .../api/v1/accounts/base_controller.rb | 9 +++ .../v1/accounts/endorsements_controller.rb | 6 +- .../v1/accounts/featured_tags_controller.rb | 6 +- .../accounts/follower_accounts_controller.rb | 6 +- .../accounts/following_accounts_controller.rb | 6 +- .../v1/accounts/identity_proofs_controller.rb | 7 --- .../api/v1/accounts/lists_controller.rb | 8 +-- .../api/v1/accounts/lookup_controller.rb | 1 + .../api/v1/accounts/notes_controller.rb | 6 +- .../v1/accounts/relationships_controller.rb | 2 +- .../api/v1/accounts/statuses_controller.rb | 6 +- app/controllers/api/v1/accounts_controller.rb | 2 +- .../api/v1/collection_items_controller.rb | 2 +- .../api/v1/collections_controller.rb | 2 +- .../api/v1/in_collections_controller.rb | 2 +- .../concerns/account_owned_concern.rb | 6 +- .../settings/deletes_controller.rb | 2 +- app/lib/activitypub/activity/flag.rb | 2 +- app/models/account.rb | 16 +++++ app/models/concerns/account/search.rb | 6 +- app/models/concerns/account/suspensions.rb | 13 +++- app/models/user.rb | 4 +- app/serializers/rest/account_serializer.rb | 4 +- app/services/delete_account_service.rb | 9 ++- app/views/admin/accounts/_buttons.html.haml | 4 ++ app/views/admin/accounts/_counters.html.haml | 6 +- app/views/admin/reports/index.html.haml | 2 + .../scheduler/self_destruct_scheduler.rb | 10 +-- config/locales/en.yml | 2 + ...7_add_requested_deletion_at_to_accounts.rb | 7 +++ ...3_update_account_summaries_to_version_3.rb | 26 ++++++++ ..._backfill_account_requested_deletion_at.rb | 29 +++++++++ db/schema.rb | 6 +- db/views/account_summaries_v03.sql | 24 +++++++ lib/tasks/db.rake | 6 +- lib/tasks/tests.rake | 46 ++++++++++---- .../application_controller_spec.rb | 18 +++++- .../auth/registrations_controller_spec.rb | 9 +++ .../auth/sessions_controller_spec.rb | 13 ++++ .../account_controller_concern_spec.rb | 24 ++++++- .../following_accounts_controller_spec.rb | 63 +++++++++++++++++++ spec/fabricators/account_fabricator.rb | 23 +++---- spec/models/user_spec.rb | 7 ++- spec/requests/accounts_spec.rb | 27 ++++++++ spec/requests/activitypub/collections_spec.rb | 27 ++++++++ spec/requests/activitypub/inboxes_spec.rb | 27 ++++++++ spec/requests/activitypub/outboxes_spec.rb | 54 ++++++++++++++++ spec/requests/activitypub/replies_spec.rb | 31 +++++++++ .../api/v1/accounts/follower_accounts_spec.rb | 25 ++++++++ .../v1/accounts/following_accounts_spec.rb | 25 ++++++++ spec/requests/api/v1/accounts/lists_spec.rb | 25 ++++++++ .../requests/api/v1/accounts/statuses_spec.rb | 29 +++++++++ spec/requests/api/v1/accounts_spec.rb | 25 ++++++++ spec/requests/settings/deletes_spec.rb | 22 +++++++ spec/requests/statuses_spec.rb | 27 ++++++++ spec/requests/well_known/webfinger_spec.rb | 27 +++++++- spec/services/account_search_service_spec.rb | 17 +++++ spec/services/notify_service_spec.rb | 5 ++ spec/system/settings/deletes_spec.rb | 4 +- .../scheduler/self_destruct_scheduler_spec.rb | 10 +-- 63 files changed, 757 insertions(+), 120 deletions(-) create mode 100644 app/controllers/api/v1/accounts/base_controller.rb create mode 100644 db/migrate/20260728124057_add_requested_deletion_at_to_accounts.rb create mode 100644 db/migrate/20260728145403_update_account_summaries_to_version_3.rb create mode 100644 db/post_migrate/20260728145507_backfill_account_requested_deletion_at.rb create mode 100644 db/views/account_summaries_v03.sql diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index f0a011e645c..79268f3e60e 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -104,8 +104,8 @@ jobs: bin/rails db:create SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails tests:migrations:prepare_database - # Migrate up to v4.2.0 breakpoint - bin/rails db:migrate VERSION=20230907150100 + # Migrate up to v4.3.0 breakpoint + bin/rails db:migrate VERSION=20241007071624 # Migrate the rest SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:migrate diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index c2563c492ef..784604b93ae 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -13,4 +13,8 @@ class ActivityPub::BaseController < Api::BaseController def skip_temporary_suspension_response? false end + + def skip_pending_deletion_response? + false + end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index b5926d94fda..d76b650a00d 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -40,6 +40,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController true end + def skip_pending_deletion_response? + true + end + def body return @body if defined?(@body) diff --git a/app/controllers/api/v1/accounts/base_controller.rb b/app/controllers/api/v1/accounts/base_controller.rb new file mode 100644 index 00000000000..6931d07645d --- /dev/null +++ b/app/controllers/api/v1/accounts/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::BaseController < Api::BaseController + private + + def set_account + @account = Account.without_requested_deletion.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/endorsements_controller.rb b/app/controllers/api/v1/accounts/endorsements_controller.rb index 1e21994a907..eaa48c2c122 100644 --- a/app/controllers/api/v1/accounts/endorsements_controller.rb +++ b/app/controllers/api/v1/accounts/endorsements_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::EndorsementsController < Api::BaseController +class Api::V1::Accounts::EndorsementsController < Api::V1::Accounts::BaseController include Authorization before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :index @@ -28,10 +28,6 @@ class Api::V1::Accounts::EndorsementsController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def set_endorsed_accounts @endorsed_accounts = @account.unavailable? ? [] : paginated_endorsed_accounts end diff --git a/app/controllers/api/v1/accounts/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb index f95846366c8..40b35cd90ab 100644 --- a/app/controllers/api/v1/accounts/featured_tags_controller.rb +++ b/app/controllers/api/v1/accounts/featured_tags_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::FeaturedTagsController < Api::BaseController +class Api::V1::Accounts::FeaturedTagsController < Api::V1::Accounts::BaseController before_action :set_account before_action :set_featured_tags @@ -12,10 +12,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def set_featured_tags @featured_tags = @account.unavailable? ? [] : @account.featured_tags end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 3f2ecb892dc..78415bd8e71 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::FollowerAccountsController < Api::BaseController +class Api::V1::Accounts::FollowerAccountsController < Api::V1::Accounts::BaseController before_action -> { authorize_if_got_token! :read, :'read:accounts' } before_action :set_account after_action :insert_pagination_headers @@ -13,10 +13,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def load_accounts return [] if hide_results? diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 7c16a3487e4..138e7289916 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::FollowingAccountsController < Api::BaseController +class Api::V1::Accounts::FollowingAccountsController < Api::V1::Accounts::BaseController before_action -> { authorize_if_got_token! :read, :'read:accounts' } before_action :set_account after_action :insert_pagination_headers @@ -13,10 +13,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def load_accounts return [] if hide_results? diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb index 02a45e87587..d01a8fb27e8 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -6,15 +6,8 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController deprecate_api '2022-03-30' before_action :require_user! - before_action :set_account def index render json: [] end - - private - - def set_account - @account = Account.find(params[:account_id]) - end end diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb index c92f1f8a08d..42a6bdff323 100644 --- a/app/controllers/api/v1/accounts/lists_controller.rb +++ b/app/controllers/api/v1/accounts/lists_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::ListsController < Api::BaseController +class Api::V1::Accounts::ListsController < Api::V1::Accounts::BaseController before_action -> { doorkeeper_authorize! :read, :'read:lists' } before_action :require_user! before_action :set_account @@ -9,10 +9,4 @@ class Api::V1::Accounts::ListsController < Api::BaseController @lists = @account.suspended? ? [] : @account.lists.where(account: current_account) render json: @lists, each_serializer: REST::ListSerializer end - - private - - def set_account - @account = Account.find(params[:account_id]) - end end diff --git a/app/controllers/api/v1/accounts/lookup_controller.rb b/app/controllers/api/v1/accounts/lookup_controller.rb index 6d63398781c..db938176565 100644 --- a/app/controllers/api/v1/accounts/lookup_controller.rb +++ b/app/controllers/api/v1/accounts/lookup_controller.rb @@ -13,6 +13,7 @@ class Api::V1::Accounts::LookupController < Api::BaseController def set_account @account = ResolveAccountService.new.call(params[:acct], skip_webfinger: true) || raise(ActiveRecord::RecordNotFound) + raise ActiveRecord::RecordNotFound if @account.deleted? rescue Addressable::URI::InvalidURIError raise(ActiveRecord::RecordNotFound) end diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb index b9b58b23d44..03d9a77a644 100644 --- a/app/controllers/api/v1/accounts/notes_controller.rb +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::NotesController < Api::BaseController +class Api::V1::Accounts::NotesController < Api::V1::Accounts::BaseController include Authorization before_action -> { doorkeeper_authorize! :write, :'write:accounts' } @@ -20,10 +20,6 @@ class Api::V1::Accounts::NotesController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def relationships_presenter AccountRelationshipsPresenter.new([@account], current_user.account_id) end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index d43832177a5..b6af5e02915 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController before_action :require_user! def index - @accounts = Account.where(id: account_ids).select(:id, :domain) + @accounts = Account.without_requested_deletion.where(id: account_ids).select(:id, :domain) @accounts.merge!(Account.without_suspended) unless truthy_param?(:with_suspended) render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index c42f27776ca..a297defd497 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Accounts::StatusesController < Api::BaseController +class Api::V1::Accounts::StatusesController < Api::V1::Accounts::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' } before_action :set_account @@ -14,10 +14,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController private - def set_account - @account = Account.find(params[:account_id]) - end - def load_statuses @account.unavailable? ? [] : preloaded_account_statuses end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 8738da3c941..a7b06f2fa4b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -84,7 +84,7 @@ class Api::V1::AccountsController < Api::BaseController private def set_account - @account = Account.find(params[:id]) + @account = Account.without_requested_deletion.find(params[:id]) end def set_accounts diff --git a/app/controllers/api/v1/collection_items_controller.rb b/app/controllers/api/v1/collection_items_controller.rb index 6b7db97b069..a142bead8ba 100644 --- a/app/controllers/api/v1/collection_items_controller.rb +++ b/app/controllers/api/v1/collection_items_controller.rb @@ -50,7 +50,7 @@ class Api::V1::CollectionItemsController < Api::BaseController def set_account return render(json: { error: '`account_id` parameter is missing' }, status: 422) if params[:account_id].blank? - @account = Account.find(params[:account_id]) + @account = Account.without_requested_deletion.find(params[:account_id]) end def set_collection_item diff --git a/app/controllers/api/v1/collections_controller.rb b/app/controllers/api/v1/collections_controller.rb index 08453a7ed6a..b478d4f44e3 100644 --- a/app/controllers/api/v1/collections_controller.rb +++ b/app/controllers/api/v1/collections_controller.rb @@ -69,7 +69,7 @@ class Api::V1::CollectionsController < Api::BaseController private def set_account - @account = Account.find(params[:account_id]) + @account = Account.without_requested_deletion.find(params[:account_id]) end def set_collections diff --git a/app/controllers/api/v1/in_collections_controller.rb b/app/controllers/api/v1/in_collections_controller.rb index c34845e463e..1fc6639fa6f 100644 --- a/app/controllers/api/v1/in_collections_controller.rb +++ b/app/controllers/api/v1/in_collections_controller.rb @@ -25,7 +25,7 @@ class Api::V1::InCollectionsController < Api::BaseController private def set_account - @account = Account.find(params[:account_id]) + @account = Account.without_requested_deletion.find(params[:account_id]) end def set_collections diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 7b3cd4d3ea6..6e94e22d96b 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -40,7 +40,7 @@ module AccountOwnedConcern def check_account_suspension if @account.permanently_unavailable? permanent_unavailability_response - elsif @account.suspended? && !skip_temporary_suspension_response? + elsif (@account.suspended? && !skip_temporary_suspension_response?) || (@account.deleted? && !skip_pending_deletion_response?) temporary_suspension_response end end @@ -49,6 +49,10 @@ module AccountOwnedConcern false end + def skip_pending_deletion_response? + false + end + def permanent_unavailability_response expires_in(3.minutes, public: true) gone diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 815d95ad83a..c499c128317 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -37,7 +37,7 @@ class Settings::DeletesController < Settings::BaseController end def destroy_account! - current_account.suspend!(origin: :local, block_email: false) + current_account.mark_deleted! AccountDeletionWorker.perform_async(current_user.account_id) sign_out end diff --git a/app/lib/activitypub/activity/flag.rb b/app/lib/activitypub/activity/flag.rb index bc25e5c29ff..ead25f12cc1 100644 --- a/app/lib/activitypub/activity/flag.rb +++ b/app/lib/activitypub/activity/flag.rb @@ -15,7 +15,7 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity target_collections = target_collections_by_account.fetch(target_account.id, []) replied_to_accounts = target_statuses.nil? ? [] : Account.local.where(id: target_statuses.filter_map(&:in_reply_to_account_id)) - next if target_account.suspended? || (!target_account.local? && replied_to_accounts.none?) + next if target_account.suspended? || target_account.permanently_deleted? || (!target_account.local? && replied_to_accounts.none?) ReportService.new.call( @account, diff --git a/app/models/account.rb b/app/models/account.rb index 2203c96ca6a..a6a39fe4b4e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -43,6 +43,7 @@ # private_key :text # protocol :integer default("ostatus"), not null # public_key :text default(""), not null +# requested_deletion_at :datetime # requested_review_at :datetime # reviewed_at :datetime # sensitized_at :datetime @@ -285,6 +286,21 @@ class Account < ApplicationRecord ResolveAccountService.new.call(acct) unless local? end + def deleted? + requested_deletion_at.present? && !instance_actor? + end + + def permanently_deleted? + deleted? && deletion_request.nil? + end + + def mark_deleted!(date: Time.now.utc) + transaction do + create_deletion_request! + update!(requested_deletion_at: date) + end + end + def memorialize! update!(memorial: true) end diff --git a/app/models/concerns/account/search.rb b/app/models/concerns/account/search.rb index 077e5d57b18..3846c4baf11 100644 --- a/app/models/concerns/account/search.rb +++ b/app/models/concerns/account/search.rb @@ -57,7 +57,7 @@ module Account::Search LEFT JOIN users ON accounts.id = users.account_id LEFT JOIN account_stats AS s ON accounts.id = s.account_id WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS} - AND accounts.suspended_at IS NULL + AND accounts.suspended_at IS NULL AND accounts.requested_deletion_at IS NULL AND accounts.moved_to_account_id IS NULL AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL)) ORDER BY rank DESC @@ -80,7 +80,7 @@ module Account::Search LEFT JOIN account_stats AS s ON accounts.id = s.account_id WHERE accounts.id IN (SELECT * FROM first_degree) AND to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS} - AND accounts.suspended_at IS NULL + AND accounts.suspended_at IS NULL AND accounts.requested_deletion_at IS NULL AND accounts.moved_to_account_id IS NULL GROUP BY accounts.id, s.id ORDER BY rank DESC @@ -98,7 +98,7 @@ module Account::Search LEFT JOIN users ON accounts.id = users.account_id LEFT JOIN account_stats AS s ON accounts.id = s.account_id WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS} - AND accounts.suspended_at IS NULL + AND accounts.suspended_at IS NULL AND accounts.requested_deletion_at IS NULL AND accounts.moved_to_account_id IS NULL AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL)) GROUP BY accounts.id, s.id diff --git a/app/models/concerns/account/suspensions.rb b/app/models/concerns/account/suspensions.rb index 28c6bb8c660..3baa9463285 100644 --- a/app/models/concerns/account/suspensions.rb +++ b/app/models/concerns/account/suspensions.rb @@ -5,13 +5,13 @@ module Account::Suspensions included do scope :suspended, -> { where.not(suspended_at: nil) } - scope :without_suspended, -> { where(suspended_at: nil) } + scope :without_requested_deletion, -> { where(requested_deletion_at: nil) } + scope :without_suspended, -> { without_requested_deletion.where(suspended_at: nil) } end def suspended? suspended_at.present? && !instance_actor? end - alias unavailable? suspended? def suspended_locally? suspended? && suspension_origin_local? @@ -20,12 +20,19 @@ module Account::Suspensions def suspended_permanently? suspended? && deletion_request.nil? end - alias permanently_unavailable? suspended_permanently? def suspended_temporarily? suspended? && deletion_request.present? end + def unavailable? + deleted? || suspended? + end + + def permanently_unavailable? + unavailable? && deletion_request.nil? + end + def suspend!(date: Time.now.utc, origin: :local, block_email: true) transaction do create_deletion_request! diff --git a/app/models/user.rb b/app/models/user.rb index b1fbfbd9ff2..1bddd36dc5d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -102,13 +102,13 @@ class User < ApplicationRecord validates :date_of_birth, presence: true, date_of_birth: true, on: :create, if: -> { Setting.min_age.present? && !bypass_registration_checks? } validate :validate_role_elevation - scope :account_not_suspended, -> { joins(:account).merge(Account.without_suspended) } + scope :account_available, -> { joins(:account).merge(Account.without_suspended.without_requested_deletion) } scope :recent, -> { order(id: :desc) } scope :pending, -> { where(approved: false) } scope :approved, -> { where(approved: true) } scope :enabled, -> { where(disabled: false) } scope :disabled, -> { where(disabled: true) } - scope :active, -> { confirmed.signed_in_recently.account_not_suspended } + scope :active, -> { confirmed.signed_in_recently.account_available } scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } scope :matches_ip, ->(value) { left_joins(:ips).merge(UserIp.contained_by(value)).group(users: [:id]) } diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index e24cdfab856..76b822e521d 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -15,7 +15,7 @@ class REST::AccountSerializer < ActiveModel::Serializer has_many :emojis, serializer: REST::CustomEmojiSerializer - attribute :suspended, if: :suspended? + attribute :suspended, if: :unavailable? attribute :silenced, key: :limited, if: :silenced? attribute :noindex, if: :local? @@ -164,7 +164,7 @@ class REST::AccountSerializer < ActiveModel::Serializer object.user_prefers_noindex? end - delegate :suspended?, :silenced?, :local?, :memorial?, to: :object + delegate :unavailable?, :silenced?, :local?, :memorial?, to: :object def moved_and_not_nested? object.moved? diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index e9ae117ceb2..a9ecc8be989 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -226,9 +226,14 @@ class DeleteAccountService < BaseService return unless keep_account_record? + if @options[:suspended_at] + @account.suspended_at = @options[:suspended_at] + @account.suspension_origin = :local + else + @account.requested_deletion_at ||= Time.now.utc + end + @account.silenced_at = nil - @account.suspended_at = @options[:suspended_at] || Time.now.utc - @account.suspension_origin = :local @account.locked = false @account.memorial = false @account.discoverable = false diff --git a/app/views/admin/accounts/_buttons.html.haml b/app/views/admin/accounts/_buttons.html.haml index 2aaca8962c1..5564d272519 100644 --- a/app/views/admin/accounts/_buttons.html.haml +++ b/app/views/admin/accounts/_buttons.html.haml @@ -8,6 +8,10 @@ = link_to t('admin.accounts.redownload'), redownload_admin_account_path(account.id), method: :post, class: 'button' if can?(:redownload, account) && account.suspension_origin_remote? - if deletion_request.present? && can?(:destroy, account) = link_to t('admin.accounts.delete'), admin_account_path(account.id), method: :delete, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } +- elsif account.deleted? + %hr.spacer/ + + %p.muted-hint= deletion_request.present? ? t('admin.accounts.deletion_reversible_hint_html', date: content_tag(:strong, l(deletion_request.due_at.to_date))) : t('admin.accounts.deletion_irreversible') - else .action-buttons %div diff --git a/app/views/admin/accounts/_counters.html.haml b/app/views/admin/accounts/_counters.html.haml index 3c99da9f2c8..94946d54e2b 100644 --- a/app/views/admin/accounts/_counters.html.haml +++ b/app/views/admin/accounts/_counters.html.haml @@ -22,12 +22,12 @@ %div = link_to admin_action_logs_path(target_account_id: account.id) do .dashboard__counters__text - - if account.local? && account.user.nil? + - if account.suspended? + = t('admin.accounts.suspended') + - elsif account.deleted? || (account.local? && account.user.nil?) = t('admin.accounts.deleted') - elsif account.memorial? = t('admin.accounts.memorialized') - - elsif account.suspended? - = t('admin.accounts.suspended') - elsif account.silenced? = t('admin.accounts.silenced') - elsif account.local? && account.user_disabled? diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml index b6b25c190ef..4ce0de4d245 100644 --- a/app/views/admin/reports/index.html.haml +++ b/app/views/admin/reports/index.html.haml @@ -39,6 +39,8 @@ %br/ - if target_account.suspended? %span.red= t('admin.accounts.suspended') + - elsif target_account.deleted? + %span.red= t('admin.accounts.deleted') - elsif target_account.silenced? %span.red= t('admin.accounts.silenced') - elsif target_account.user_disabled? diff --git a/app/workers/scheduler/self_destruct_scheduler.rb b/app/workers/scheduler/self_destruct_scheduler.rb index 4d8ee2cd85f..05bca4f540b 100644 --- a/app/workers/scheduler/self_destruct_scheduler.rb +++ b/app/workers/scheduler/self_destruct_scheduler.rb @@ -36,7 +36,7 @@ class Scheduler::SelfDestructScheduler # deletion request. # This targets accounts that have not been deleted nor marked for deletion yet - Account.local.without_suspended.reorder(id: :asc).take(MAX_ACCOUNT_DELETIONS_PER_JOB).each do |account| + Account.local.without_requested_deletion.reorder(id: :asc).take(MAX_ACCOUNT_DELETIONS_PER_JOB).each do |account| delete_account!(account) end @@ -44,9 +44,8 @@ class Scheduler::SelfDestructScheduler # This targets accounts that have been marked for deletion but have not been # deleted yet - Account.local.suspended.joins(:deletion_request).take(MAX_ACCOUNT_DELETIONS_PER_JOB).each do |account| + Account.local.joins(:deletion_request).take(MAX_ACCOUNT_DELETIONS_PER_JOB).each do |account| delete_account!(account) - account.deletion_request&.destroy end end @@ -64,8 +63,9 @@ class Scheduler::SelfDestructScheduler [json, account.id, inbox_url] end - # Do not call `Account#suspend!` because we don't want to issue a deletion request - account.update!(suspended_at: Time.now.utc, suspension_origin: :local) + # Do not call `Account#mark_deleted!` because we don't want to issue a deletion request + account.update!(requested_deletion_at: Time.now.utc) + account.deletion_request&.destroy end def deletion_payload(account) diff --git a/config/locales/en.yml b/config/locales/en.yml index 8b3aca6b68e..0b9115aba1b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -64,6 +64,8 @@ en: custom: Custom delete: Delete data deleted: Deleted + deletion_irreversible: The data of this account has been irreversibly deleted. + deletion_reversible_hint_html: This account is pending deletion, and the data will be fully removed on %{date}. demote: Demote destroyed_msg: "%{username}'s data is now queued to be deleted imminently" disable: Freeze diff --git a/db/migrate/20260728124057_add_requested_deletion_at_to_accounts.rb b/db/migrate/20260728124057_add_requested_deletion_at_to_accounts.rb new file mode 100644 index 00000000000..85fd7d0e617 --- /dev/null +++ b/db/migrate/20260728124057_add_requested_deletion_at_to_accounts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRequestedDeletionAtToAccounts < ActiveRecord::Migration[8.0] + def change + add_column :accounts, :requested_deletion_at, :datetime + end +end diff --git a/db/migrate/20260728145403_update_account_summaries_to_version_3.rb b/db/migrate/20260728145403_update_account_summaries_to_version_3.rb new file mode 100644 index 00000000000..748ba16a5bd --- /dev/null +++ b/db/migrate/20260728145403_update_account_summaries_to_version_3.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class UpdateAccountSummariesToVersion3 < ActiveRecord::Migration[8.0] + def up + reapplication_global_follow_recommendations_v1 do + drop_view :account_summaries, materialized: true + create_view :account_summaries, version: 3, materialized: { no_data: true } + safety_assured { add_index :account_summaries, :account_id, unique: true } + end + end + + def down + reapplication_global_follow_recommendations_v1 do + drop_view :account_summaries, materialized: true + create_view :account_summaries, version: 2, materialized: { no_data: true } + safety_assured { add_index :account_summaries, :account_id, unique: true } + end + end + + def reapplication_global_follow_recommendations_v1 + drop_view :global_follow_recommendations, materialized: true + yield + create_view :global_follow_recommendations, version: 1, materialized: { no_data: true } + safety_assured { add_index :global_follow_recommendations, :account_id, unique: true } + end +end diff --git a/db/post_migrate/20260728145507_backfill_account_requested_deletion_at.rb b/db/post_migrate/20260728145507_backfill_account_requested_deletion_at.rb new file mode 100644 index 00000000000..9c0b4583a58 --- /dev/null +++ b/db/post_migrate/20260728145507_backfill_account_requested_deletion_at.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class BackfillAccountRequestedDeletionAt < ActiveRecord::Migration[8.1] + def up + safety_assured do + execute <<~SQL + UPDATE + accounts + SET + requested_deletion_at = suspended_at, suspended_at = NULL, suspension_origin = NULL + WHERE + -- rule out remote accounts, as deleted remote accounts are deleted locally + domain IS NULL + -- only care about accounts marked as suspended (which prior to this migration applies to both suspended and self-deleted accounts) + AND suspended_at IS NOT NULL + -- rule accounts that were not self-deleted (might still include suspended but self-deleted accounts) + AND NOT EXISTS (SELECT 1 FROM users WHERE account_id = accounts.id) AND NOT EXISTS (SELECT 1 FROM canonical_email_blocks WHERE reference_account_id = accounts.id) + -- rule out accounts that have a canonical email block: sign they were suspended and not unsuspended + AND NOT EXISTS (SELECT 1 FROM canonical_email_blocks WHERE reference_account_id = accounts.id) + SQL + end + end + + def down + safety_assured do + execute 'UPDATE accounts SET suspended_at = requested_deletion_at WHERE domain is NULL AND suspended_at IS NULL AND requested_deletion_at IS NOT NULL' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4062dba3531..69310f47e78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_20_124731) do +ActiveRecord::Schema[8.1].define(version: 2026_07_28_145507) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -192,6 +192,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_20_124731) do t.text "private_key" t.integer "protocol", default: 0, null: false t.text "public_key", default: "", null: false + t.datetime "requested_deletion_at" t.datetime "requested_review_at", precision: nil t.datetime "reviewed_at", precision: nil t.datetime "sensitized_at", precision: nil @@ -1632,10 +1633,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_20_124731) do WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL) AND (statuses.reblog_of_id IS NULL)) ORDER BY statuses.id DESC LIMIT 20) t0) - WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false)) + WHERE ((accounts.suspended_at IS NULL) AND (accounts.requested_deletion_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false)) GROUP BY accounts.id; SQL - add_index "account_summaries", ["account_id", "language", "sensitive"], name: "idx_on_account_id_language_sensitive_250461e1eb" add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL diff --git a/db/views/account_summaries_v03.sql b/db/views/account_summaries_v03.sql new file mode 100644 index 00000000000..bcccb10dcfc --- /dev/null +++ b/db/views/account_summaries_v03.sql @@ -0,0 +1,24 @@ +SELECT + accounts.id AS account_id, + mode() WITHIN GROUP (ORDER BY language ASC) AS language, + mode() WITHIN GROUP (ORDER BY sensitive ASC) AS sensitive +FROM accounts +CROSS JOIN LATERAL ( + SELECT + statuses.account_id, + statuses.language, + statuses.sensitive + FROM statuses + WHERE statuses.account_id = accounts.id + AND statuses.deleted_at IS NULL + AND statuses.reblog_of_id IS NULL + ORDER BY statuses.id DESC + LIMIT 20 +) t0 +WHERE accounts.suspended_at IS NULL + AND accounts.requested_deletion_at IS NULL + AND accounts.silenced_at IS NULL + AND accounts.moved_to_account_id IS NULL + AND accounts.discoverable = 't' + AND accounts.locked = 'f' +GROUP BY accounts.id diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index e5ca6ac2ac5..e9b7d6a6b4c 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -66,9 +66,9 @@ namespace :db do abort 'This version of Mastodon requires PostgreSQL 14.0 or newer. Please update PostgreSQL before updating Mastodon.' if pg_version < 140_000 schema_version = ActiveRecord::Migrator.current_version - abort <<~MESSAGE if ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS'] && schema_version < 2023_09_07_150100 - Zero-downtime migrations from Mastodon versions earlier than 4.2.0 are not supported. - Please update to Mastodon 4.2.x first or upgrade by stopping all services and running migrations without `SKIP_POST_DEPLOYMENT_MIGRATIONS`. + abort <<~MESSAGE if ENV['SKIP_POST_DEPLOYMENT_MIGRATIONS'] && schema_version < 2024_10_07_071624 + Zero-downtime migrations from Mastodon versions earlier than 4.3.0 are not supported. + Please update to Mastodon 4.3.x first or upgrade by stopping all services and running migrations without `SKIP_POST_DEPLOYMENT_MIGRATIONS`. MESSAGE end diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index 73fa2dfd228..238a5025ac4 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -117,6 +117,21 @@ namespace :tests do exit(1) end + unless Account.find_by(username: 'suspended', domain: nil).suspended? + puts 'Unexpected value for Account#suspended? for user @suspended' + exit(1) + end + + if Account.find_by(username: 'deleted', domain: nil).suspended? + puts 'Unexpected value for Account#suspended? for user @deleted' + exit(1) + end + + unless Account.find_by(username: 'deleted', domain: nil).deleted? + puts 'Unexpected value for Account#deleted? for user @deleted' + exit(1) + end + unless Identity.where(provider: 'foo', uid: 0).one? puts 'Identities not deduplicated as expected' exit(1) @@ -226,11 +241,11 @@ namespace :tests do INSERT INTO "accounts" (id, username, domain, uri, private_key, public_key, created_at, updated_at, last_webfingered_at) VALUES - (12, 'alice', 'social.example.com', 'https://social.example.com/alice', NULL, #{user_public_key}, '2021-01-01'::date, now(), '2021-01-09'::date), - (13, 'alice', 'example.com', 'https://social.example.com/alice', NULL, #{user_public_key}, '2021-01-10'::date, now(), '2021-01-11'::date), - (14, 'bogus1', 'example.com', '', NULL, '', now(), now(), now()), - (15, 'bogus2', 'example.com', '', NULL, '', now(), now(), now()), - (16, 'bogus3', 'example.com', '', NULL, '', now(), now(), now()); + (14, 'alice', 'social.example.com', 'https://social.example.com/alice', NULL, #{user_public_key}, '2021-01-01'::date, now(), '2021-01-09'::date), + (15, 'alice', 'example.com', 'https://social.example.com/alice', NULL, #{user_public_key}, '2021-01-10'::date, now(), '2021-01-11'::date), + (16, 'bogus1', 'example.com', '', NULL, '', now(), now(), now()), + (17, 'bogus2', 'example.com', '', NULL, '', now(), now(), now()), + (18, 'bogus3', 'example.com', '', NULL, '', now(), now(), now()); SQL end @@ -291,20 +306,20 @@ namespace :tests do INSERT INTO "accounts" (id, username, domain, private_key, public_key, created_at, updated_at) VALUES - (10, 'kmruser', NULL, #{user_private_key}, #{user_public_key}, now(), now()), - (11, 'qcuser', NULL, #{user_private_key}, #{user_public_key}, now(), now()); + (12, 'kmruser', NULL, #{user_private_key}, #{user_public_key}, now(), now()), + (13, 'qcuser', NULL, #{user_private_key}, #{user_public_key}, now(), now()); INSERT INTO "users" (id, account_id, email, created_at, updated_at, admin, locale, chosen_languages) VALUES - (4, 10, 'kmruser@localhost', now(), now(), false, 'ku', '{en,kmr,ku,ckb}'); + (5, 12, 'kmruser@localhost', now(), now(), false, 'ku', '{en,kmr,ku,ckb}'); INSERT INTO "users" (id, account_id, email, created_at, updated_at, locale, encrypted_otp_secret, encrypted_otp_secret_iv, encrypted_otp_secret_salt, otp_required_for_login) VALUES - (5, 11, 'qcuser@localhost', now(), now(), 'fr-QC', + (6, 13, 'qcuser@localhost', now(), now(), 'fr-QC', E'Fttsy7QAa0edaDfdfSz094rRLAxc8cJweDQ4BsWH/zozcdVA8o9GLqcKhn2b\nGi/V\n', 'rys3THICkr60BoWC', '_LMkAGvdg7a+sDIKjI3mR2Q==', @@ -313,7 +328,7 @@ namespace :tests do INSERT INTO "settings" (id, thing_type, thing_id, var, value, created_at, updated_at) VALUES - (5, 'User', 4, 'default_language', E'--- kmr\n', now(), now()), + (5, 'User', 5, 'default_language', E'--- kmr\n', now(), now()), (6, 'User', 1, 'interactions', E'--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nmust_be_follower: false\nmust_be_following: true\nmust_be_following_dm: false\n', now(), now()); INSERT INTO "identities" @@ -390,18 +405,25 @@ namespace :tests do 'https://activitypub.com/users/evil/inbox', 'https://activitypub.com/users/evil/outbox', 'https://activitypub.com/users/evil/followers', true); + INSERT INTO "accounts" + (id, username, domain, private_key, public_key, created_at, updated_at, suspended) + VALUES + (10, 'suspended', NULL, #{admin_private_key}, #{admin_public_key}, now(), now(), true), + (11, 'deleted', NULL, #{user_private_key}, #{user_public_key}, now(), now(), true); + -- users INSERT INTO "users" (id, account_id, email, created_at, updated_at, admin) VALUES (1, 1, 'admin@localhost', now(), now(), true), - (2, 2, 'user@localhost', now(), now(), false); + (2, 2, 'user@localhost', now(), now(), false), + (3, 10, 'suspended@localhost', now(), now(), false); INSERT INTO "users" (id, account_id, email, created_at, updated_at, admin, locale) VALUES - (3, 8, 'ptuser@localhost', now(), now(), false, 'pt'); + (4, 8, 'ptuser@localhost', now(), now(), false, 'pt'); -- conversations INSERT INTO "conversations" (id, created_at, updated_at) VALUES (1, now(), now()); diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 898edb3e233..a0365eb5c91 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -54,7 +54,7 @@ RSpec.describe ApplicationController do end end - describe 'before_action :check_suspension' do + describe 'before_action :require_functional!' do before do routes.draw { get 'success' => 'anonymous#success' } end @@ -64,17 +64,29 @@ RSpec.describe ApplicationController do expect(response).to have_http_status(200) end - it 'does nothing if user who signed in is not suspended' do + it 'does nothing if user who signed in is functional' do sign_in(Fabricate(:account, suspended: false).user) get 'success' expect(response).to have_http_status(200) end - it 'redirects to account status page' do + it 'redirects to account status page if the account is suspended' do sign_in(Fabricate(:account, suspended: true).user) get 'success' expect(response).to redirect_to(edit_user_registration_path) end + + it 'redirects to account status page if the account is pending deletion' do + sign_in(Fabricate(:account, requested_deletion: true).user) + get 'success' + expect(response).to redirect_to(edit_user_registration_path) + end + + it 'redirects to auth setup page if the account is unconfirmed' do + sign_in(Fabricate(:user, confirmed_at: nil)) + get 'success' + expect(response).to redirect_to(auth_setup_path) + end end describe 'raise_not_found' do diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index 6531608a014..3b474dc2ead 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -141,6 +141,15 @@ RSpec.describe Auth::RegistrationsController do expect(response).to have_http_status(403) end end + + context 'when deleted' do + let(:user) { Fabricate(:user, account_attributes: { username: 'test', requested_deletion_at: Time.now.utc }) } + + it 'returns http forbidden' do + put :update + expect(response).to have_http_status(403) + end + end end describe 'GET #new' do diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index d8d640a0583..88c5f508d5e 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -48,6 +48,19 @@ RSpec.describe Auth::SessionsController do expect(response).to redirect_to(new_user_session_path) end end + + context 'with a deleted user' do + before do + user.account.mark_deleted! + end + + it 'redirects to home after sign out' do + sign_in(user, scope: :user) + delete :destroy + + expect(response).to redirect_to(new_user_session_path) + end + end end describe 'POST #create' do diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb index 2f7e230d482..ace3d8e93b0 100644 --- a/spec/controllers/concerns/account_controller_concern_spec.rb +++ b/spec/controllers/concerns/account_controller_concern_spec.rb @@ -33,7 +33,7 @@ RSpec.describe AccountControllerConcern do end end - context 'when account is suspended' do + context 'when account is permanently suspended' do it 'returns http gone' do account = Fabricate(:account, suspended: true) get 'success', params: { account_username: account.username } @@ -41,14 +41,32 @@ RSpec.describe AccountControllerConcern do end end - context 'when account is deleted by owner' do + context 'when account is temporarily suspended' do + it 'returns http forbidden' do + account = Fabricate(:account) + account.suspend! + get 'success', params: { account_username: account.username } + expect(response).to have_http_status(403) + end + end + + context 'when account is permanently deleted' do it 'returns http gone' do - account = Fabricate(:account, suspended: true, user: nil) + account = Fabricate(:account, requested_deletion: true) get 'success', params: { account_username: account.username } expect(response).to have_http_status(410) end end + context 'when account is pending deletion' do + it 'returns http forbidden' do + account = Fabricate(:account) + account.mark_deleted! + get 'success', params: { account_username: account.username } + expect(response).to have_http_status(403) + end + end + context 'when account is not suspended' do let(:account) { Fabricate(:account, username: 'username') } diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb index 7f11a50395a..ba514eb8bd2 100644 --- a/spec/controllers/following_accounts_controller_spec.rb +++ b/spec/controllers/following_accounts_controller_spec.rb @@ -36,6 +36,27 @@ RSpec.describe FollowingAccountsController do expect(response).to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + alice.mark_deleted! + alice.deletion_request.destroy + end + + it 'returns http gone' do + expect(response).to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + alice.mark_deleted! + end + + it 'returns http forbidden' do + expect(response).to have_http_status(403) + end + end end context 'when format is json' do @@ -108,6 +129,27 @@ RSpec.describe FollowingAccountsController do expect(response).to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + alice.mark_deleted! + alice.deletion_request.destroy + end + + it 'returns http gone' do + expect(response).to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + alice.mark_deleted! + end + + it 'returns http forbidden' do + expect(response).to have_http_status(403) + end + end end context 'without page' do @@ -161,6 +203,27 @@ RSpec.describe FollowingAccountsController do expect(response).to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + alice.mark_deleted! + alice.deletion_request.destroy + end + + it 'returns http gone' do + expect(response).to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + alice.mark_deleted! + end + + it 'returns http forbidden' do + expect(response).to have_http_status(403) + end + end end end end diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index 4280b7f7d69..6a5218cee98 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -3,17 +3,18 @@ require_relative '../support/signing_keys_helpers' Fabricator(:account) do - transient :suspended, :silenced, :legacy_keypair - username { sequence(:username) { |i| "#{Faker::Internet.user_name(separators: %w(_))}#{i}" } } - last_webfingered_at { Time.now.utc } - public_key { |attrs| attrs[:legacy_keypair] ? SigningKeysHelpers::PUBLIC_RSA_TEST_KEY : '' } - private_key { |attrs| attrs[:legacy_keypair] && attrs[:domain].nil? ? SigningKeysHelpers::PRIVATE_RSA_TEST_KEY : nil } - suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil } - silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil } - user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil } - uri { |attrs| attrs[:domain].nil? ? nil : "https://#{attrs[:domain]}/users/#{attrs[:username]}" } - discoverable true - indexable true + transient :suspended, :silenced, :legacy_keypair, :requested_deletion + username { sequence(:username) { |i| "#{Faker::Internet.user_name(separators: %w(_))}#{i}" } } + last_webfingered_at { Time.now.utc } + public_key { |attrs| attrs[:legacy_keypair] ? SigningKeysHelpers::PUBLIC_RSA_TEST_KEY : '' } + private_key { |attrs| attrs[:legacy_keypair] && attrs[:domain].nil? ? SigningKeysHelpers::PRIVATE_RSA_TEST_KEY : nil } + suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil } + silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil } + requested_deletion_at { |attrs| attrs[:requested_deletion] ? Time.now.utc : nil } + user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil } + uri { |attrs| attrs[:domain].nil? ? nil : "https://#{attrs[:domain]}/users/#{attrs[:username]}" } + discoverable true + indexable true # This is not strictly needed but this avoids generating multiple keys # and, when `store_private_key` is passed, stores private keys for use in request specs diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 35f0b987614..708c2a02d9f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -74,16 +74,19 @@ RSpec.describe User do end end - describe 'account_not_suspended' do + describe 'account_available' do it 'returns with linked accounts that are not suspended' do suspended_account = Fabricate(:account, suspended_at: 10.days.ago) non_suspended_account = Fabricate(:account, suspended_at: nil) suspended_user = Fabricate(:user, account: suspended_account) non_suspended_user = Fabricate(:user, account: non_suspended_account) + deleted_account = Fabricate(:account, requested_deletion_at: 10.days.ago) + deleted_user = Fabricate(:user, account: deleted_account) - expect(described_class.account_not_suspended) + expect(described_class.account_available) .to include(non_suspended_user) .and not_include(suspended_user) + .and not_include(deleted_user) end end diff --git a/spec/requests/accounts_spec.rb b/spec/requests/accounts_spec.rb index cd67e89d458..26f8f577b09 100644 --- a/spec/requests/accounts_spec.rb +++ b/spec/requests/accounts_spec.rb @@ -62,6 +62,33 @@ RSpec.describe 'Accounts show response' do end end + describe 'permanently deleted account check' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns appropriate http response code' do + { html: 410, json: 410, rss: 410 }.each do |format, code| + get short_account_path(username: account.username), as: format + + expect(response).to have_http_status(code) + end + end + end + + describe 'pending deletion account check' do + before { account.mark_deleted! } + + it 'returns appropriate http response code' do + { html: 403, json: 403, rss: 403 }.each do |format, code| + get short_account_path(username: account.username), as: format + + expect(response).to have_http_status(code) + end + end + end + describe 'GET to short username paths' do context 'with existing statuses' do context 'with HTML' do diff --git a/spec/requests/activitypub/collections_spec.rb b/spec/requests/activitypub/collections_spec.rb index 39bd2252e78..b10f7c868b1 100644 --- a/spec/requests/activitypub/collections_spec.rb +++ b/spec/requests/activitypub/collections_spec.rb @@ -62,6 +62,33 @@ RSpec.describe 'ActivityPub Collections' do .to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end end context 'with signature' do diff --git a/spec/requests/activitypub/inboxes_spec.rb b/spec/requests/activitypub/inboxes_spec.rb index fd013f38d67..fd9f9ad554b 100644 --- a/spec/requests/activitypub/inboxes_spec.rb +++ b/spec/requests/activitypub/inboxes_spec.rb @@ -62,6 +62,33 @@ RSpec.describe 'ActivityPub Inboxes' do .to have_http_status(202) end end + + context 'when account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http accepted' do + subject + + expect(response) + .to have_http_status(202) + end + end end end diff --git a/spec/requests/activitypub/outboxes_spec.rb b/spec/requests/activitypub/outboxes_spec.rb index cb6c460f9ed..b2af762ed91 100644 --- a/spec/requests/activitypub/outboxes_spec.rb +++ b/spec/requests/activitypub/outboxes_spec.rb @@ -61,6 +61,33 @@ RSpec.describe 'ActivityPub Outboxes' do .to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end end context 'with page requested' do @@ -110,6 +137,33 @@ RSpec.describe 'ActivityPub Outboxes' do .to have_http_status(403) end end + + context 'when account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end end end diff --git a/spec/requests/activitypub/replies_spec.rb b/spec/requests/activitypub/replies_spec.rb index 95bc1afe61d..a002f702afd 100644 --- a/spec/requests/activitypub/replies_spec.rb +++ b/spec/requests/activitypub/replies_spec.rb @@ -77,6 +77,37 @@ RSpec.describe 'ActivityPub Replies' do end end + context 'when account is permanently deleted' do + let(:parent_visibility) { :public } + + before do + status.account.mark_deleted! + status.account.deletion_request.destroy + end + + it 'returns http gone' do + subject + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is pending deletion' do + let(:parent_visibility) { :public } + + before do + status.account.mark_deleted! + end + + it 'returns http forbidden' do + subject + + expect(response) + .to have_http_status(403) + end + end + context 'when status is public' do let(:parent_visibility) { :public } diff --git a/spec/requests/api/v1/accounts/follower_accounts_spec.rb b/spec/requests/api/v1/accounts/follower_accounts_spec.rb index 1f0779701b2..ed21769aa8e 100644 --- a/spec/requests/api/v1/accounts/follower_accounts_spec.rb +++ b/spec/requests/api/v1/accounts/follower_accounts_spec.rb @@ -66,5 +66,30 @@ RSpec.describe 'API V1 Accounts FollowerAccounts' do ) end end + + context 'when request account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when request account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/followers", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end end end diff --git a/spec/requests/api/v1/accounts/following_accounts_spec.rb b/spec/requests/api/v1/accounts/following_accounts_spec.rb index 193cf2196f0..4df93c5cfed 100644 --- a/spec/requests/api/v1/accounts/following_accounts_spec.rb +++ b/spec/requests/api/v1/accounts/following_accounts_spec.rb @@ -66,5 +66,30 @@ RSpec.describe 'API V1 Accounts FollowingAccounts' do ) end end + + context 'when request account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when request account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/following", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end end end diff --git a/spec/requests/api/v1/accounts/lists_spec.rb b/spec/requests/api/v1/accounts/lists_spec.rb index 63b1dc7816f..4e2dc653656 100644 --- a/spec/requests/api/v1/accounts/lists_spec.rb +++ b/spec/requests/api/v1/accounts/lists_spec.rb @@ -22,4 +22,29 @@ RSpec.describe 'Accounts Lists API' do .to start_with('application/json') end end + + context 'when requested account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/lists", headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when requested account is pending deletion' do + before do + account.mark_deleted! + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/lists", headers: headers + + expect(response).to have_http_status(404) + end + end end diff --git a/spec/requests/api/v1/accounts/statuses_spec.rb b/spec/requests/api/v1/accounts/statuses_spec.rb index 6ba3b47ff08..7202558f502 100644 --- a/spec/requests/api/v1/accounts/statuses_spec.rb +++ b/spec/requests/api/v1/accounts/statuses_spec.rb @@ -152,5 +152,34 @@ RSpec.describe 'API V1 Accounts Statuses' do end end end + + context 'when requested account is permanently deleted' do + let(:account) { Fabricate(:account) } + + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/statuses", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end + + context 'when requested account is pending deletion' do + let(:account) { Fabricate(:account) } + + before do + account.mark_deleted! + end + + it 'returns http not found' do + get "/api/v1/accounts/#{account.id}/statuses", params: { limit: 2 }, headers: headers + + expect(response).to have_http_status(404) + end + end end end diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index 0ea5c3921ef..6d6b8f7db5d 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -48,6 +48,31 @@ RSpec.describe '/api/v1/accounts' do end end + context 'when requesting a permanently deleted account' do + let(:other_account) { Fabricate(:account, requested_deletion: true) } + + before do + get "/api/v1/accounts/#{other_account.id}" + end + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + + context 'when requesting an account pending deletion' do + let(:other_account) { Fabricate(:account) } + + before do + other_account.mark_deleted! + get "/api/v1/accounts/#{other_account.id}" + end + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + context 'when logged in' do subject do get "/api/v1/accounts/#{account.id}", headers: headers diff --git a/spec/requests/settings/deletes_spec.rb b/spec/requests/settings/deletes_spec.rb index c2771819991..2b15fd72309 100644 --- a/spec/requests/settings/deletes_spec.rb +++ b/spec/requests/settings/deletes_spec.rb @@ -26,6 +26,17 @@ RSpec.describe 'Settings Deletes' do .to have_http_status(403) end end + + context 'when already deleted' do + let(:user) { Fabricate(:user, account_attributes: { requested_deletion_at: Time.now.utc }) } + + it 'returns http forbidden' do + delete settings_delete_path + + expect(response) + .to have_http_status(403) + end + end end context 'when not signed in' do @@ -54,6 +65,17 @@ RSpec.describe 'Settings Deletes' do .to include('private, no-store') end end + + context 'when already deleted' do + let(:user) { Fabricate(:user, account_attributes: { requested_deletion_at: Time.now.utc }) } + + it 'returns http forbidden' do + get settings_delete_path + + expect(response) + .to have_http_status(403) + end + end end context 'when not signed in' do diff --git a/spec/requests/statuses_spec.rb b/spec/requests/statuses_spec.rb index d12f4f28cf3..ec329739cf5 100644 --- a/spec/requests/statuses_spec.rb +++ b/spec/requests/statuses_spec.rb @@ -33,6 +33,33 @@ RSpec.describe 'Statuses' do end end + context 'when account is permanently deleted' do + before do + account.mark_deleted! + account.deletion_request.destroy + end + + it 'returns http gone' do + get "/@#{account.username}/#{status.id}" + + expect(response) + .to have_http_status(410) + end + end + + context 'when account is temporarily deleted' do + before do + account.mark_deleted! + end + + it 'returns http forbidden' do + get "/@#{account.username}/#{status.id}" + + expect(response) + .to have_http_status(403) + end + end + context 'when status is a reblog' do let(:original_account) { Fabricate(:account, domain: 'example.com') } let(:original_status) { Fabricate(:status, account: original_account, url: 'https://example.com/123') } diff --git a/spec/requests/well_known/webfinger_spec.rb b/spec/requests/well_known/webfinger_spec.rb index 593cecbb896..9606a78a47c 100644 --- a/spec/requests/well_known/webfinger_spec.rb +++ b/spec/requests/well_known/webfinger_spec.rb @@ -53,7 +53,18 @@ RSpec.describe 'The /.well-known/webfinger endpoint' do it_behaves_like 'a successful response' end - context 'when an account is permanently suspended or deleted' do + context 'when an account is pending deletion' do + let(:resource) { alice.to_webfinger_s } + + before do + alice.mark_deleted! + perform_request! + end + + it_behaves_like 'a successful response' + end + + context 'when an account is permanently suspended' do let(:resource) { alice.to_webfinger_s } before do @@ -67,6 +78,20 @@ RSpec.describe 'The /.well-known/webfinger endpoint' do end end + context 'when an account is permanently deleted' do + let(:resource) { alice.to_webfinger_s } + + before do + alice.mark_deleted! + alice.deletion_request.destroy + perform_request! + end + + it 'returns http gone' do + expect(response).to have_http_status(410) + end + end + context 'when an account is not found' do let(:resource) { 'acct:not@existing.com' } diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb index 6295d2f8fff..a7a4ceae70a 100644 --- a/spec/services/account_search_service_spec.rb +++ b/spec/services/account_search_service_spec.rb @@ -78,6 +78,15 @@ RSpec.describe AccountSearchService do expect(results).to eq [partial] end + it 'returns the fuzzy match first, and does not return deleted exacts' do + partial = Fabricate(:account, username: 'exactness') + Fabricate(:account, username: 'exact', requested_deletion: true) + results = subject.call('exact', nil, limit: 10) + + expect(results.size).to eq 1 + expect(results).to eq [partial] + end + it 'does not return suspended remote accounts' do Fabricate(:account, username: 'a', domain: 'remote', display_name: 'e', suspended: true) results = subject.call('a@example.com', nil, limit: 2) @@ -85,6 +94,14 @@ RSpec.describe AccountSearchService do expect(results.size).to eq 0 expect(results).to eq [] end + + it 'does not return deleted remote accounts' do + Fabricate(:account, username: 'a', domain: 'remote', display_name: 'e', requested_deletion: true) + results = subject.call('a@example.com', nil, limit: 2) + + expect(results.size).to eq 0 + expect(results).to eq [] + end end context 'when elasticsearch is enabled', :search do diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index df51bce8bf2..bbb0674e0ed 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -50,6 +50,11 @@ RSpec.describe NotifyService do expect { subject }.to_not change(Notification, :count) end + it 'does not notify when recipient is deleted' do + recipient.mark_deleted! + expect { subject }.to_not change(Notification, :count) + end + describe 'reblogs' do let(:status) { Fabricate(:status, account: Fabricate(:account)) } let(:activity) { Fabricate(:status, account: sender, reblog: status) } diff --git a/spec/system/settings/deletes_spec.rb b/spec/system/settings/deletes_spec.rb index 2a18a276948..e8acee73d72 100644 --- a/spec/system/settings/deletes_spec.rb +++ b/spec/system/settings/deletes_spec.rb @@ -27,10 +27,8 @@ RSpec.describe 'Settings Deletes' do .to have_text(I18n.t('deletes.success_msg')) expect(page) .to have_title(I18n.t('auth.login')) - expect(User.find_by(id: user.id)) - .to be_nil expect(user.account.reload) - .to be_suspended + .to be_deleted expect(CanonicalEmailBlock.block?(user.email)) .to be(false) end diff --git a/spec/workers/scheduler/self_destruct_scheduler_spec.rb b/spec/workers/scheduler/self_destruct_scheduler_spec.rb index 0ff0faf05a7..bf9c989fcf4 100644 --- a/spec/workers/scheduler/self_destruct_scheduler_spec.rb +++ b/spec/workers/scheduler/self_destruct_scheduler_spec.rb @@ -41,14 +41,14 @@ RSpec.describe Scheduler::SelfDestructScheduler do context 'when sidekiq is operational' do let!(:other_account) { Fabricate :account, inbox_url: 'https://host.example/inbox', domain: 'host.example', protocol: :activitypub } - it 'suspends local non-suspended accounts' do + it 'deletes local non-deleted accounts' do worker.perform - expect(account.reload.suspended_at).to_not be_nil + expect(account.reload.requested_deletion_at).to_not be_nil end - it 'suspends local suspended accounts marked for deletion' do - account.update(suspended_at: 10.days.ago) + it 'deletes local accounts marked for deletion' do + account.update(requested_deletion_at: 10.days.ago) deletion_request = Fabricate(:account_deletion_request, account: account) worker.perform @@ -56,7 +56,7 @@ RSpec.describe Scheduler::SelfDestructScheduler do expect(ActivityPub::DeliveryWorker) .to have_enqueued_sidekiq_job(match_json_values(type: 'Delete', signature: be_present), account.id, other_account.inbox_url) - expect(account.reload.suspended_at).to be > 1.day.ago + expect(account.reload.requested_deletion_at).to be > 1.day.ago expect { deletion_request.reload }.to raise_error(ActiveRecord::RecordNotFound) end end