users: delete more data when user deactivates their account.

* Don't delete the user's favorites unless private favorites are enabled. The general rule is that
  public account activity is kept and private account activity is deleted.
* Delete the user's API keys, forum topics visits, private favgroups, downvotes, and upvotes (if
  privacy is enabled).
* Reset all of the user's account settings to default. This means custom CSS is deleted, where it
  wasn't before.
* Delete everything but the user's name and password asynchronously.
* Don't log the current user out if it's the owner deleting another user's account.
* Fix #5067 (Mod actions sometimes not created for user deletions) by wrapping the deletion process
  in a transaction.
This commit is contained in:
evazion
2022-11-05 23:26:13 -05:00
parent 3ffde5b23d
commit b43a913ad7
7 changed files with 143 additions and 53 deletions

View File

@@ -131,7 +131,6 @@ class UsersController < ApplicationController
user_deletion.delete! user_deletion.delete!
if user_deletion.errors.none? if user_deletion.errors.none?
session.delete(:user_id)
flash[:notice] = "Your account has been deactivated" flash[:notice] = "Your account has been deactivated"
respond_with(user_deletion, location: posts_path) respond_with(user_deletion, location: posts_path)
else else

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
# A job that deletes a user's settings and other personal data when they delete their account.
class DeleteUserJob < ApplicationJob
queue_as :default
queue_with_priority 20
def perform(user)
UserDeletion.new(user: user).delete_user
end
end

View File

@@ -46,11 +46,11 @@ class SessionLoader
end end
# Logs the current user out. Deletes their session cookie and records a logout event. # Logs the current user out. Deletes their session cookie and records a logout event.
def logout def logout(user = CurrentUser.user)
session.delete(:user_id) session.delete(:user_id)
session.delete(:last_authenticated_at) session.delete(:last_authenticated_at)
return if CurrentUser.user.is_anonymous? return if user.is_anonymous?
UserEvent.create_from_request!(CurrentUser.user, :logout, request) UserEvent.create_from_request!(user, :logout, request)
end end
# Sets the current user. Runs on each HTTP request. The user is set based on # Sets the current user. Runs on each HTTP request. The user is set based on

View File

@@ -12,8 +12,9 @@ class UserDeletion
validate :validate_deletion validate :validate_deletion
# Initialize a user deletion. # Initialize a user deletion.
#
# @param user [User] the user to delete # @param user [User] the user to delete
# @param user [User] the user performing the deletion # @param deleter [User] the user performing the deletion
# @param password [String] the user's password (for confirmation) # @param password [String] the user's password (for confirmation)
# @param request the HTTP request (for logging the deletion in the user event log) # @param request the HTTP request (for logging the deletion in the user event log)
def initialize(user:, deleter: user, password: nil, request: nil) def initialize(user:, deleter: user, password: nil, request: nil)
@@ -24,43 +25,64 @@ class UserDeletion
end end
# Delete the account, if the deletion is allowed. # Delete the account, if the deletion is allowed.
# @return [Boolean] if the deletion failed #
# @return [User] if the deletion succeeded # @return [Boolean] True if the deletion was successful, false otherwise.
def delete! def delete!
return false if invalid? return false if invalid?
clear_user_settings user.with_lock do
remove_favorites rename
clear_saved_searches reset_password
rename async_delete_user
reset_password ModAction.log("deleted user ##{user.id}", :user_delete, subject: user, user: deleter)
create_mod_action UserEvent.create_from_request!(user, :user_deletion, request) if request.present?
create_user_event SessionLoader.new(request).logout(user) if user == deleter
user end
true
end end
private # Calls `delete_user`.
def async_delete_user
def create_mod_action DeleteUserJob.perform_later(user)
ModAction.log("deleted user ##{user.id}", :user_delete, subject: user, user: deleter)
end end
def create_user_event def delete_user
UserEvent.create_from_request!(user, :user_deletion, request) if request.present? delete_user_data
delete_user_settings
end end
def clear_saved_searches def delete_user_data
SavedSearch.where(user_id: user.id).destroy_all user.api_keys.destroy_all
user.forum_topic_visits.destroy_all
user.saved_searches.destroy_all
user.favorite_groups.is_private.destroy_all
user.post_votes.active.negative.find_each do |vote|
vote.soft_delete!(updater: user)
end
if user.enable_private_favorites
user.favorites.destroy_all
user.post_votes.active.positive.find_each do |vote|
vote.soft_delete!(updater: user)
end
end
end end
def clear_user_settings def delete_user_settings
user.email_address = nil user.email_address = nil
user.last_logged_in_at = nil user.last_logged_in_at = nil
user.last_forum_read_at = nil user.last_forum_read_at = nil
user.favorite_tags = ""
user.blacklisted_tags = "" User::USER_PREFERENCE_BOOLEAN_ATTRIBUTES.each do |attribute|
user.show_deleted_children = false user.send("#{attribute}=", false)
user.time_zone = "Eastern Time (US & Canada)" end
%w[time_zone comment_threshold default_image_size favorite_tags blacklisted_tags custom_style per_page theme].each do |attribute|
user[attribute] = User.column_defaults[attribute]
end
user.save! user.save!
end end
@@ -68,10 +90,6 @@ class UserDeletion
user.update!(password: SecureRandom.hex(16)) user.update!(password: SecureRandom.hex(16))
end end
def remove_favorites
DeleteFavoritesJob.perform_later(user)
end
def rename def rename
name = "user_#{user.id}" name = "user_#{user.id}"
name += "~" while User.exists?(name: name) name += "~" while User.exists?(name: name)

View File

@@ -58,6 +58,9 @@ class User < ApplicationRecord
ACTIVE_BOOLEAN_ATTRIBUTES = BOOLEAN_ATTRIBUTES.grep_v(/unused/) ACTIVE_BOOLEAN_ATTRIBUTES = BOOLEAN_ATTRIBUTES.grep_v(/unused/)
# Personal preferences that are editable by the user, rather than internal flags. These will be cleared when the user deactivates their account.
USER_PREFERENCE_BOOLEAN_ATTRIBUTES = ACTIVE_BOOLEAN_ATTRIBUTES - %w[is_banned requires_verification is_verified]
DEFAULT_BLACKLIST = ["guro", "scat", "furry -rating:g"].join("\n") DEFAULT_BLACKLIST = ["guro", "scat", "furry -rating:g"].join("\n")
attribute :id attribute :id

View File

@@ -9,17 +9,19 @@
<h1>Deactivate Account: <%= link_to_user @user %></h1> <h1>Deactivate Account: <%= link_to_user @user %></h1>
<% end %> <% end %>
<div class="prose mb-4"> <div class="prose mb-4 fixed-width-container">
<p> <p>
You can deactivate your <%= Danbooru.config.app_name %> account by entering your password below. Deactivating You can deactivate your account by entering your password below. Deactivating your account will delete your
your account will do the following things: private account information, but it will not delete your contributions to the site.
</p>
</p>Deactivating your account will do the following things: </p>
<ul> <ul>
<li>Change your username to a generic username (<i>user_<%= @user.id %></i>).</li> <li>Change your username to a generic username (<i>user_<%= @user.id %></i>).</li>
<li>Delete your password, email address, and account settings.</li> <li>Delete your password, email address, <%= link_to_wiki "API keys", "help:api" %>, and account settings.</li>
<li>Delete your favorites.</li> <li>Delete your <%= link_to_wiki "saved searches", "help:saved_searches" %>.</li>
<li>Delete your saved searches.</li> <li>Delete your <%= link_to_wiki "private favorite groups", "help:favorite_groups" %>.</li>
<li>Delete your private favorites and upvotes (only if <%= link_to_wiki "privacy mode", "help:privacy_mode" %> is enabled).</li>
</ul> </ul>
<p> <p>
@@ -33,6 +35,8 @@
<li>Your login history, including your IP address and geographic location. This is kept for moderation purposes.</li> <li>Your login history, including your IP address and geographic location. This is kept for moderation purposes.</li>
</ul> </ul>
<p>If you just want to change your username, you can <%= link_to "change your name here", change_name_user_path(@user) %>.</p>
<p> <p>
Enter your password below to deactivate your account. This cannot be Enter your password below to deactivate your account. This cannot be
undone. Your account cannot be recovered after it is deactivated. undone. Your account cannot be recovered after it is deactivated.

View File

@@ -6,6 +6,9 @@ class UserDeletionTest < ActiveSupport::TestCase
@request.stubs(:remote_ip).returns("1.1.1.1") @request.stubs(:remote_ip).returns("1.1.1.1")
@request.stubs(:user_agent).returns("Firefox") @request.stubs(:user_agent).returns("Firefox")
@request.stubs(:session).returns(session_id: "1234") @request.stubs(:session).returns(session_id: "1234")
@request.stubs(:query_parameters).returns({})
@request.stubs(:delete).with(:user_id).returns(nil)
@request.stubs(:delete).with(:last_authenticatd_at).returns(nil)
end end
context "an invalid user deletion" do context "an invalid user deletion" do
@@ -39,12 +42,20 @@ class UserDeletionTest < ActiveSupport::TestCase
context "a valid user deletion" do context "a valid user deletion" do
setup do setup do
@user = create(:user, name: "foo", email_address: build(:email_address)) @user = create(:gold_user, name: "foo", email_address: build(:email_address))
@api_key = create(:api_key, user: @user)
@favorite = create(:favorite, user: @user)
@forum_topic_visit = as(@user) { create(:forum_topic_visit, user: @user) }
@saved_search = create(:saved_search, user: @user)
@public_favgroup = create(:favorite_group, creator: @user, is_public: true)
@private_favgroup = create(:favorite_group, creator: @user, is_public: false)
@post_downvote = create(:post_vote, score: -1)
@post_upvote = create(:post_vote, score: 1)
@deletion = UserDeletion.new(user: @user, password: "password", request: @request) @deletion = UserDeletion.new(user: @user, password: "password", request: @request)
end end
should "blank out the email" do should "blank out the email" do
@deletion.delete! perform_enqueued_jobs { @deletion.delete! }
assert_nil(@user.reload.email_address) assert_nil(@user.reload.email_address)
end end
@@ -54,12 +65,10 @@ class UserDeletionTest < ActiveSupport::TestCase
end end
should "generate a user name change request" do should "generate a user name change request" do
assert_difference("UserNameChangeRequest.count") do @deletion.delete!
@deletion.delete! assert_equal(1, @user.user_name_change_requests.count)
end assert_equal("foo", @user.user_name_change_requests.last.original_name)
assert_equal("user_#{@user.id}", @user.user_name_change_requests.last.desired_name)
assert_equal("foo", UserNameChangeRequest.last.original_name)
assert_equal("user_#{@user.id}", UserNameChangeRequest.last.desired_name)
end end
should "reset the password" do should "reset the password" do
@@ -75,14 +84,60 @@ class UserDeletionTest < ActiveSupport::TestCase
assert_equal(@deletion.deleter, ModAction.last.creator) assert_equal(@deletion.deleter, ModAction.last.creator)
end end
should "remove any favorites" do should "remove the user's favorites if they have private favorites" do
@post = create(:post) @user.update!(enable_private_favorites: true)
Favorite.create!(post: @post, user: @user)
perform_enqueued_jobs { @deletion.delete! } perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, Favorite.count) assert_equal(0, @user.favorites.count)
assert_equal(0, @post.reload.fav_count) assert_equal(0, @user.reload.favorite_count)
end
should "not remove the user's favorites if they have public favorites" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(1, @user.favorites.count)
assert_equal(1, @user.favorite_count)
end
should "remove the user's API keys" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.api_keys.count)
end
should "remove the user's forum topic visits" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.forum_topic_visits.count)
end
should "remove the user's saved searches" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.saved_searches.count)
end
should "remove the user's private favgroups but not their public favgroups" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.favorite_groups.is_private.count)
assert_equal(1, @user.favorite_groups.is_public.count)
assert_not_nil(@public_favgroup.reload)
end
should "only remove the user's downvotes if the don't have private votes enabled" do
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.post_votes.active.negative.count)
assert_equal(1, @user.post_votes.active.positive.count)
end
should "remove both the user's upvotes and downvotes if they have private votes enabled" do
@user.update!(enable_private_favorites: true)
perform_enqueued_jobs { @deletion.delete! }
assert_equal(0, @user.post_votes.active.negative.count)
assert_equal(0, @user.post_votes.active.positive.count)
end end
end end