FEATURE: Anonymize User. A way to remove a user but keep their topics and posts.

This commit is contained in:
Neil Lalonde 2015-03-06 16:44:54 -05:00
parent a68512bebf
commit 608647d02f
16 changed files with 401 additions and 100 deletions

View File

@ -328,6 +328,48 @@ Discourse.AdminUser = Discourse.User.extend({
}); });
}, },
anonymizeForbidden: Em.computed.not("can_be_anonymized"),
anonymize: function() {
var user = this;
var performAnonymize = function() {
Discourse.ajax("/admin/users/" + user.get('id') + '/anonymize.json', {type: 'PUT'}).then(function(data) {
if (data.success) {
bootbox.alert(I18n.t("admin.user.anonymize_successful"));
if (data.username) {
document.location = "/admin/users/" + data.username;
} else {
document.location = "/admin/users/list/active";
}
} else {
bootbox.alert(I18n.t("admin.user.anonymize_failed"));
if (data.user) {
user.setProperties(data.user);
}
}
}, function() {
bootbox.alert(I18n.t("admin.user.anonymize_failed"));
});
};
var message = I18n.t("admin.user.anonymize_confirm");
var buttons = [{
"label": I18n.t("composer.cancel"),
"class": "cancel",
"link": true
}, {
"label": '<i class="fa fa-exclamation-triangle"></i>' + I18n.t('admin.user.anonymize_yes'),
"class": "btn btn-danger",
"callback": function(){
performAnonymize();
}
}];
bootbox.dialog(message, buttons, {"classes": "delete-user-modal"});
},
deleteForbidden: Em.computed.not("canBeDeleted"), deleteForbidden: Em.computed.not("canBeDeleted"),
deleteExplanation: function() { deleteExplanation: function() {

View File

@ -450,6 +450,11 @@
<section> <section>
<hr/> <hr/>
<button {{bind-attr class=":btn :btn-danger :pull-right anonymizeForbidden:hidden"}} {{action "anonymize" target="content"}} {{bind-attr disabled="anonymizeForbidden"}}>
<i class="fa fa-exclamation-triangle"></i>
{{i18n 'admin.user.anonymize'}}
</button>
<button {{bind-attr class=":btn :btn-danger :pull-right deleteForbidden:hidden"}} {{action "destroy" target="content"}} {{bind-attr disabled="deleteForbidden"}}> <button {{bind-attr class=":btn :btn-danger :pull-right deleteForbidden:hidden"}} {{action "destroy" target="content"}} {{bind-attr disabled="deleteForbidden"}}>
<i class="fa fa-exclamation-triangle"></i> <i class="fa fa-exclamation-triangle"></i>
{{i18n 'admin.user.delete'}} {{i18n 'admin.user.delete'}}

View File

@ -22,7 +22,8 @@ class Admin::UsersController < Admin::AdminController
:remove_group, :remove_group,
:primary_group, :primary_group,
:generate_api_key, :generate_api_key,
:revoke_api_key] :revoke_api_key,
:anonymize]
def index def index
users = ::AdminUserIndexQuery.new(params).find_users users = ::AdminUserIndexQuery.new(params).find_users
@ -333,6 +334,15 @@ class Admin::UsersController < Admin::AdminController
end end
def anonymize
guardian.ensure_can_anonymize_user!(@user)
if user = UserAnonymizer.new(@user, current_user).make_anonymous
render json: success_json.merge(username: user.username)
else
render json: failed_json.merge(user: AdminDetailedUserSerializer.new(user, root: false).as_json)
end
end
private private
def fetch_user def fetch_user

View File

@ -93,7 +93,7 @@ class UsersController < ApplicationController
guardian.ensure_can_edit_username!(user) guardian.ensure_can_edit_username!(user)
# TODO proper error surfacing (result is a Model#save call) # TODO proper error surfacing (result is a Model#save call)
result = user.change_username(params[:new_username], current_user) result = UsernameChanger.change(user, params[:new_username], current_user)
raise Discourse::InvalidParameters.new(:new_username) unless result raise Discourse::InvalidParameters.new(:new_username) unless result
render json: { render json: {

View File

@ -187,12 +187,7 @@ class User < ActiveRecord::Base
end end
def change_username(new_username, actor=nil) def change_username(new_username, actor=nil)
if actor && actor != self UsernameChanger.change(self, new_username, actor)
StaffActionLogger.new(actor).log_username_change(self, self.username, new_username)
end
self.username = new_username
save
end end
def created_topic_count def created_topic_count
@ -716,6 +711,10 @@ class User < ActiveRecord::Base
UserHistory.for(self, :suspend_user).count UserHistory.for(self, :suspend_user).count
end end
def create_user_profile
UserProfile.create(user_id: id)
end
protected protected
def badge_grant def badge_grant
@ -734,10 +733,6 @@ class User < ActiveRecord::Base
end end
end end
def create_user_profile
UserProfile.create(user_id: id)
end
def ensure_in_trust_level_group def ensure_in_trust_level_group
Group.user_trust_level_change!(id, trust_level) Group.user_trust_level_change!(id, trust_level)
end end

View File

@ -37,7 +37,8 @@ class UserHistory < ActiveRecord::Base
:roll_up, :roll_up,
:change_username, :change_username,
:custom, :custom,
:custom_staff) :custom_staff,
:anonymize_user)
end end
# Staff actions is a subset of all actions, used to audit actions taken by staff users. # Staff actions is a subset of all actions, used to audit actions taken by staff users.
@ -57,7 +58,8 @@ class UserHistory < ActiveRecord::Base
:impersonate, :impersonate,
:roll_up, :roll_up,
:change_username, :change_username,
:custom_staff] :custom_staff,
:anonymize_user]
end end
def self.staff_action_ids def self.staff_action_ids

View File

@ -15,6 +15,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
:private_topics_count, :private_topics_count,
:can_delete_all_posts, :can_delete_all_posts,
:can_be_deleted, :can_be_deleted,
:can_be_anonymized,
:suspend_reason, :suspend_reason,
:primary_group_id, :primary_group_id,
:badge_count, :badge_count,
@ -51,6 +52,10 @@ class AdminDetailedUserSerializer < AdminUserSerializer
scope.can_delete_user?(object) scope.can_delete_user?(object)
end end
def can_be_anonymized
scope.can_anonymize_user?(object)
end
def moderator def moderator
object.moderator object.moderator
end end

View File

@ -0,0 +1,63 @@
class UserAnonymizer
def initialize(user, actor=nil)
@user = user
@actor = actor
end
def self.make_anonymous(user, actor=nil)
self.new(user, actor).make_anonymous
end
def make_anonymous
User.transaction do
prev_email = @user.email
prev_username = @user.username
if !UsernameChanger.change(@user, make_anon_username)
raise "Failed to change username"
end
@user.reload
@user.password = SecureRandom.hex
@user.email = "#{@user.username}@example.com"
@user.name = nil
@user.date_of_birth = nil
@user.title = nil
@user.email_digests = false
@user.email_private_messages = false
@user.email_direct = false
@user.email_always = false
@user.mailing_list_mode = false
@user.save
profile = @user.user_profile
profile.destroy if profile
@user.create_user_profile
@user.user_avatar.try(:destroy)
@user.twitter_user_info.try(:destroy)
@user.google_user_info.try(:destroy)
@user.github_user_info.try(:destroy)
@user.facebook_user_info.try(:destroy)
@user.single_sign_on_record.try(:destroy)
@user.oauth2_user_info.try(:destroy)
@user.user_open_ids.find_each { |x| x.destroy }
@user.api_key.try(:destroy)
UserHistory.create( action: UserHistory.actions[:anonymize_user],
target_user_id: @user.id,
acting_user_id: @actor ? @actor.id : @user.id,
email: prev_email,
details: "username: #{prev_username}" )
end
@user
end
def make_anon_username
100.times do
new_username = "anon#{(SecureRandom.random_number * 100000000).to_i}"
return new_username unless User.where(username_lower: new_username).exists?
end
raise "Failed to generate an anon username"
end
end

View File

@ -0,0 +1,23 @@
class UsernameChanger
def initialize(user, new_username, actor=nil)
@user = user
@new_username = new_username
@actor = actor
end
def self.change(user, new_username, actor=nil)
self.new(user, new_username, actor).change
end
def change
if @actor && @actor != @user
StaffActionLogger.new(@actor).log_username_change(@user, @user.username, @new_username)
end
# future work: update mentions and quotes
@user.username = @new_username
@user.save
end
end

View File

@ -1912,6 +1912,7 @@ en:
delete_topic: "delete topic" delete_topic: "delete topic"
delete_post: "delete post" delete_post: "delete post"
impersonate: "impersonate" impersonate: "impersonate"
anonymize_user: "anonymize user"
screened_emails: screened_emails:
title: "Screened Emails" title: "Screened Emails"
description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed."
@ -2048,6 +2049,11 @@ en:
approve_success: "User approved and email sent with activation instructions." approve_success: "User approved and email sent with activation instructions."
approve_bulk_success: "Success! All selected users have been approved and notified." approve_bulk_success: "Success! All selected users have been approved and notified."
time_read: "Read Time" time_read: "Read Time"
anonymize: "Anonymize User"
anonymize_confirm: "Are you SURE you want to anonymize this account? This will change the username and email, and reset all profile information."
anonymize_yes: "Yes, anonymize this account"
anonymize_successful: "The user was anonymized successfully."
anonymize_failed: "There was a problem anonymizing the account."
delete: "Delete User" delete: "Delete User"
delete_forbidden_because_staff: "Admins and moderators can't be deleted." delete_forbidden_because_staff: "Admins and moderators can't be deleted."
delete_posts_forbidden_because_staff: "Can't delete all posts of admins and moderators." delete_posts_forbidden_because_staff: "Can't delete all posts of admins and moderators."

View File

@ -96,6 +96,7 @@ Discourse::Application.routes.draw do
get "badges" get "badges"
get "leader_requirements" => "users#tl3_requirements" get "leader_requirements" => "users#tl3_requirements"
get "tl3_requirements" get "tl3_requirements"
put "anonymize"
end end

View File

@ -47,6 +47,10 @@ module UserGuardian
end end
end end
def can_anonymize_user?(user)
is_staff? && !user.nil? && !user.staff?
end
def can_check_emails?(user) def can_check_emails?(user)
is_admin? || (is_staff? && SiteSetting.show_email_on_profile) is_admin? || (is_staff? && SiteSetting.show_email_on_profile)
end end

View File

@ -1585,6 +1585,48 @@ describe Guardian do
end end
end end
describe "can_anonymize_user?" do
it "is false without a logged in user" do
expect(Guardian.new(nil).can_anonymize_user?(user)).to be_falsey
end
it "is false without a user to look at" do
expect(Guardian.new(admin).can_anonymize_user?(nil)).to be_falsey
end
it "is false for a regular user" do
expect(Guardian.new(user).can_anonymize_user?(coding_horror)).to be_falsey
end
it "is false for myself" do
expect(Guardian.new(user).can_anonymize_user?(user)).to be_falsey
end
it "is true for admin anonymizing a regular user" do
Guardian.new(admin).can_anonymize_user?(user).should == true
end
it "is true for moderator anonymizing a regular user" do
Guardian.new(moderator).can_anonymize_user?(user).should == true
end
it "is false for admin anonymizing an admin" do
expect(Guardian.new(admin).can_anonymize_user?(Fabricate(:admin))).to be_falsey
end
it "is false for admin anonymizing a moderator" do
expect(Guardian.new(admin).can_anonymize_user?(Fabricate(:moderator))).to be_falsey
end
it "is false for moderator anonymizing an admin" do
expect(Guardian.new(moderator).can_anonymize_user?(admin)).to be_falsey
end
it "is false for moderator anonymizing a moderator" do
expect(Guardian.new(moderator).can_anonymize_user?(Fabricate(:moderator))).to be_falsey
end
end
describe 'can_grant_title?' do describe 'can_grant_title?' do
it 'is false without a logged in user' do it 'is false without a logged in user' do
expect(Guardian.new(nil).can_grant_title?(user)).to be_falsey expect(Guardian.new(nil).can_grant_title?(user)).to be_falsey

View File

@ -97,92 +97,6 @@ describe User do
end end
end end
describe 'change_username' do
let(:user) { Fabricate(:user) }
context 'success' do
let(:new_username) { "#{user.username}1234" }
before do
@result = user.change_username(new_username)
end
it 'returns true' do
expect(@result).to eq(true)
end
it 'should change the username' do
user.reload
expect(user.username).to eq(new_username)
end
it 'should change the username_lower' do
user.reload
expect(user.username_lower).to eq(new_username.downcase)
end
end
context 'failure' do
let(:wrong_username) { "" }
let(:username_before_change) { user.username }
let(:username_lower_before_change) { user.username_lower }
before do
@result = user.change_username(wrong_username)
end
it 'returns false' do
expect(@result).to eq(false)
end
it 'should not change the username' do
user.reload
expect(user.username).to eq(username_before_change)
end
it 'should not change the username_lower' do
user.reload
expect(user.username_lower).to eq(username_lower_before_change)
end
end
describe 'change the case of my username' do
let!(:myself) { Fabricate(:user, username: 'hansolo') }
it 'should return true' do
expect(myself.change_username('HanSolo')).to eq(true)
end
it 'should change the username' do
myself.change_username('HanSolo')
expect(myself.reload.username).to eq('HanSolo')
end
end
describe 'allow custom minimum username length from site settings' do
before do
@custom_min = 2
SiteSetting.min_username_length = @custom_min
end
it 'should allow a shorter username than default' do
result = user.change_username('a' * @custom_min)
expect(result).not_to eq(false)
end
it 'should not allow a shorter username than limit' do
result = user.change_username('a' * (@custom_min - 1))
expect(result).to eq(false)
end
it 'should not allow a longer username than limit' do
result = user.change_username('a' * (User.username_length.end + 1))
expect(result).to eq(false)
end
end
end
describe 'delete posts' do describe 'delete posts' do
before do before do
@post1 = Fabricate(:post) @post1 = Fabricate(:post)

View File

@ -0,0 +1,99 @@
require "spec_helper"
describe UserAnonymizer do
describe "make_anonymous" do
let(:admin) { Fabricate(:admin) }
let(:user) { Fabricate(:user, username: "edward") }
subject(:make_anonymous) { described_class.make_anonymous(user, admin) }
it "changes username" do
make_anonymous
user.reload.username.should =~ /^anon\d{3,}$/
end
it "changes email address" do
make_anonymous
user.reload.email.should == "#{user.username}@example.com"
end
it "turns off all notifications" do
make_anonymous
user.reload
user.email_digests.should == false
user.email_private_messages.should == false
user.email_direct.should == false
user.email_always.should == false
user.mailing_list_mode.should == false
end
it "resets profile to default values" do
user.update_attributes( name: "Bibi", date_of_birth: 19.years.ago, title: "Super Star" )
profile = user.user_profile(true)
profile.update_attributes( location: "Moose Jaw",
website: "www.bim.com",
bio_raw: "I'm Bibi from Moosejaw. I sing and dance.",
bio_cooked: "I'm Bibi from Moosejaw. I sing and dance.",
profile_background: "http://example.com/bg.jpg",
bio_cooked_version: 2,
card_background: "http://example.com/cb.jpg")
make_anonymous
user.reload
user.name.should_not be_present
user.date_of_birth.should == nil
user.title.should_not be_present
profile = user.user_profile(true)
profile.location.should == nil
profile.website.should == nil
profile.bio_cooked.should == nil
profile.profile_background.should == nil
profile.bio_cooked_version.should == nil
profile.card_background.should == nil
end
it "removes the avatar" do
upload = Fabricate(:upload, user: user)
user.user_avatar = UserAvatar.new(user_id: user.id, custom_upload_id: upload.id)
user.save!
expect { make_anonymous }.to change { Upload.count }.by(-1)
user.reload
user.user_avatar.should == nil
end
it "logs the action" do
expect { make_anonymous }.to change { UserHistory.count }.by(1)
end
it "removes external auth assocations" do
user.twitter_user_info = TwitterUserInfo.create(user_id: user.id, screen_name: "example", twitter_user_id: "examplel123123")
user.google_user_info = GoogleUserInfo.create(user_id: user.id, google_user_id: "google@gmail.com")
user.github_user_info = GithubUserInfo.create(user_id: user.id, screen_name: "example", github_user_id: "examplel123123")
user.facebook_user_info = FacebookUserInfo.create(user_id: user.id, facebook_user_id: "example")
user.single_sign_on_record = SingleSignOnRecord.create(user_id: user.id, external_id: "example", last_payload: "looks good")
user.oauth2_user_info = Oauth2UserInfo.create(user_id: user.id, uid: "example", provider: "example")
UserOpenId.create(user_id: user.id, email: user.email, url: "http://example.com/openid", active: true)
make_anonymous
user.reload
user.twitter_user_info.should == nil
user.google_user_info.should == nil
user.github_user_info.should == nil
user.facebook_user_info.should == nil
user.single_sign_on_record.should == nil
user.oauth2_user_info.should == nil
user.user_open_ids.count.should == 0
end
it "removes api key" do
ApiKey.create(user_id: user.id, key: "123123123")
expect { make_anonymous }.to change { ApiKey.count }.by(-1)
user.reload
user.api_key.should == nil
end
end
end

View File

@ -0,0 +1,90 @@
require 'spec_helper'
describe UsernameChanger do
describe '#change' do
let(:user) { Fabricate(:user) }
context 'success' do
let(:new_username) { "#{user.username}1234" }
before do
@result = described_class.change(user, new_username)
end
it 'returns true' do
expect(@result).to eq(true)
end
it 'should change the username' do
user.reload
expect(user.username).to eq(new_username)
end
it 'should change the username_lower' do
user.reload
expect(user.username_lower).to eq(new_username.downcase)
end
end
context 'failure' do
let(:wrong_username) { "" }
let(:username_before_change) { user.username }
let(:username_lower_before_change) { user.username_lower }
before do
@result = described_class.change(user, wrong_username)
end
it 'returns false' do
expect(@result).to eq(false)
end
it 'should not change the username' do
user.reload
expect(user.username).to eq(username_before_change)
end
it 'should not change the username_lower' do
user.reload
expect(user.username_lower).to eq(username_lower_before_change)
end
end
describe 'change the case of my username' do
let!(:myself) { Fabricate(:user, username: 'hansolo') }
it 'should return true' do
expect(described_class.change(myself, "HanSolo")).to eq(true)
end
it 'should change the username' do
described_class.change(myself, "HanSolo")
expect(myself.reload.username).to eq('HanSolo')
end
end
describe 'allow custom minimum username length from site settings' do
before do
@custom_min = 2
SiteSetting.min_username_length = @custom_min
end
it 'should allow a shorter username than default' do
result = described_class.change(user, 'a' * @custom_min)
expect(result).not_to eq(false)
end
it 'should not allow a shorter username than limit' do
result = described_class.change(user, 'a' * (@custom_min - 1))
expect(result).to eq(false)
end
it 'should not allow a longer username than limit' do
result = described_class.change(user, 'a' * (User.username_length.end + 1))
expect(result).to eq(false)
end
end
end
end