FEATURE: Anonymize User. A way to remove a user but keep their topics and posts.
This commit is contained in:
parent
a68512bebf
commit
608647d02f
|
@ -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"),
|
||||
|
||||
deleteExplanation: function() {
|
||||
|
|
|
@ -450,6 +450,11 @@
|
|||
|
||||
<section>
|
||||
<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"}}>
|
||||
<i class="fa fa-exclamation-triangle"></i>
|
||||
{{i18n 'admin.user.delete'}}
|
||||
|
|
|
@ -22,7 +22,8 @@ class Admin::UsersController < Admin::AdminController
|
|||
:remove_group,
|
||||
:primary_group,
|
||||
:generate_api_key,
|
||||
:revoke_api_key]
|
||||
:revoke_api_key,
|
||||
:anonymize]
|
||||
|
||||
def index
|
||||
users = ::AdminUserIndexQuery.new(params).find_users
|
||||
|
@ -333,6 +334,15 @@ class Admin::UsersController < Admin::AdminController
|
|||
|
||||
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
|
||||
|
||||
def fetch_user
|
||||
|
|
|
@ -93,7 +93,7 @@ class UsersController < ApplicationController
|
|||
guardian.ensure_can_edit_username!(user)
|
||||
|
||||
# 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
|
||||
|
||||
render json: {
|
||||
|
|
|
@ -187,12 +187,7 @@ class User < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def change_username(new_username, actor=nil)
|
||||
if actor && actor != self
|
||||
StaffActionLogger.new(actor).log_username_change(self, self.username, new_username)
|
||||
end
|
||||
|
||||
self.username = new_username
|
||||
save
|
||||
UsernameChanger.change(self, new_username, actor)
|
||||
end
|
||||
|
||||
def created_topic_count
|
||||
|
@ -716,6 +711,10 @@ class User < ActiveRecord::Base
|
|||
UserHistory.for(self, :suspend_user).count
|
||||
end
|
||||
|
||||
def create_user_profile
|
||||
UserProfile.create(user_id: id)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def badge_grant
|
||||
|
@ -734,10 +733,6 @@ class User < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
def create_user_profile
|
||||
UserProfile.create(user_id: id)
|
||||
end
|
||||
|
||||
def ensure_in_trust_level_group
|
||||
Group.user_trust_level_change!(id, trust_level)
|
||||
end
|
||||
|
|
|
@ -37,7 +37,8 @@ class UserHistory < ActiveRecord::Base
|
|||
:roll_up,
|
||||
:change_username,
|
||||
:custom,
|
||||
:custom_staff)
|
||||
:custom_staff,
|
||||
:anonymize_user)
|
||||
end
|
||||
|
||||
# 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,
|
||||
:roll_up,
|
||||
:change_username,
|
||||
:custom_staff]
|
||||
:custom_staff,
|
||||
:anonymize_user]
|
||||
end
|
||||
|
||||
def self.staff_action_ids
|
||||
|
|
|
@ -15,6 +15,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
|||
:private_topics_count,
|
||||
:can_delete_all_posts,
|
||||
:can_be_deleted,
|
||||
:can_be_anonymized,
|
||||
:suspend_reason,
|
||||
:primary_group_id,
|
||||
:badge_count,
|
||||
|
@ -51,6 +52,10 @@ class AdminDetailedUserSerializer < AdminUserSerializer
|
|||
scope.can_delete_user?(object)
|
||||
end
|
||||
|
||||
def can_be_anonymized
|
||||
scope.can_anonymize_user?(object)
|
||||
end
|
||||
|
||||
def moderator
|
||||
object.moderator
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -1912,6 +1912,7 @@ en:
|
|||
delete_topic: "delete topic"
|
||||
delete_post: "delete post"
|
||||
impersonate: "impersonate"
|
||||
anonymize_user: "anonymize user"
|
||||
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."
|
||||
|
@ -2048,6 +2049,11 @@ en:
|
|||
approve_success: "User approved and email sent with activation instructions."
|
||||
approve_bulk_success: "Success! All selected users have been approved and notified."
|
||||
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_forbidden_because_staff: "Admins and moderators can't be deleted."
|
||||
delete_posts_forbidden_because_staff: "Can't delete all posts of admins and moderators."
|
||||
|
|
|
@ -96,6 +96,7 @@ Discourse::Application.routes.draw do
|
|||
get "badges"
|
||||
get "leader_requirements" => "users#tl3_requirements"
|
||||
get "tl3_requirements"
|
||||
put "anonymize"
|
||||
end
|
||||
|
||||
|
||||
|
|
|
@ -47,6 +47,10 @@ module UserGuardian
|
|||
end
|
||||
end
|
||||
|
||||
def can_anonymize_user?(user)
|
||||
is_staff? && !user.nil? && !user.staff?
|
||||
end
|
||||
|
||||
def can_check_emails?(user)
|
||||
is_admin? || (is_staff? && SiteSetting.show_email_on_profile)
|
||||
end
|
||||
|
|
|
@ -1585,6 +1585,48 @@ describe Guardian do
|
|||
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
|
||||
it 'is false without a logged in user' do
|
||||
expect(Guardian.new(nil).can_grant_title?(user)).to be_falsey
|
||||
|
|
|
@ -97,92 +97,6 @@ describe User do
|
|||
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
|
||||
before do
|
||||
@post1 = Fabricate(:post)
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue