FEATURE: Rename user in mentions and quotes

FEATURE: Rename user in mentions and quotes

Co-authored-by: Robin Ward robin.ward@gmail.com

diff --git a/app/jobs/regular/update_username.rb b/app/jobs/regular/update_username.rb
new file mode 100644
index 0000000..ebc5f1a
--- /dev/null
+++ b/app/jobs/regular/update_username.rb
@@ -0,0 +1,94 @@
+module Jobs
+  class UpdateUsername < Jobs::Base
+
+    def execute(args)
+      @user_id = args[:user_id]
+
+      username = args[:old_username]
+      @raw_mention_regex = /(?:(?<![\w`_])|(?<=_))@#{username}(?:(?![\w\-\.])|(?=[\-\.](?:\s|$)))/i
+      @raw_quote_regex = /(\[quote\s*=\s*["'']?)#{username}(\,?[^\]]*\])/i
+      @cooked_mention_username_regex = /^@#{username}$/i
+      @cooked_mention_user_path_regex = /^\/u(?:sers)?\/#{username}$/i
+      @cooked_quote_username_regex = /(?<=\s)#{username}(?=:)/i
+      @new_username = args[:new_username]
+
+      update_posts
+      update_revisions
+    end
+
+    def update_posts
+      Post.where(post_conditions("posts.id"), post_condition_args).find_each do |post|
+        if update_raw!(post.raw)
+          post.update_columns(raw: post.raw, cooked: update_cooked(post.cooked))
+        end
+      end
+    end
+
+    def update_revisions
+      PostRevision.where(post_conditions("post_revisions.post_id"), post_condition_args).find_each do |revision|
+        changed = false
+
+        revision.modifications["raw"]&.each do |raw|
+          changed |= update_raw!(raw)
+        end
+
+        if changed
+          revision.modifications["cooked"].map! { |cooked| update_cooked(cooked) }
+          revision.save!
+        end
+      end
+    end
+
+  protected
+
+    def post_conditions(post_id_column)
+      <<~SQL
+        EXISTS(
+            SELECT 1
+            FROM user_actions AS a
+            WHERE a.target_post_id = #{post_id_column} AND
+                  a.action_type = :mentioned AND
+                  a.user_id = :user_id
+        ) OR EXISTS(
+            SELECT 1
+            FROM quoted_posts AS q
+              JOIN posts AS p ON (q.quoted_post_id = p.id)
+            WHERE q.post_id = #{post_id_column} AND
+              p.user_id = :user_id
+        )
+      SQL
+    end
+
+    def post_condition_args
+      { mentioned: UserAction::MENTION, user_id: @user_id }
+    end
+
+    def update_raw!(raw)
+      changed = false
+      changed |= raw.gsub!(@raw_mention_regex, "@#{@new_username}")
+      changed |= raw.gsub!(@raw_quote_regex, "\\1#{@new_username}\\2")
+      changed
+    end
+
+    # Uses Nokogiri instead of rebake, because it works for posts and revisions
+    # and there is no reason to invalidate oneboxes, run the post analyzer etc.
+    # when only the username changes.
+    def update_cooked(cooked)
+      doc = Nokogiri::HTML.fragment(cooked)
+
+      doc.css("a.mention").each do |a|
+        a.content = a.content.gsub(@cooked_mention_username_regex, "@#{@new_username}")
+        a["href"] = a["href"].gsub(@cooked_mention_user_path_regex, "/u/#{@new_username}")
+      end
+
+      doc.css("aside.quote > div.title").each do |div|
+        # TODO Update avatar URL
+        div.children.each do |child|
+          child.content = child.content.gsub(@cooked_quote_username_regex, @new_username) if child.text?
+        end
+      end
+
+      doc.to_html
+    end
+  end
+end
diff --git a/app/services/user_anonymizer.rb b/app/services/user_anonymizer.rb
index f4c11a7..526a629 100644
--- a/app/services/user_anonymizer.rb
+++ b/app/services/user_anonymizer.rb
@@ -20,9 +20,7 @@ class UserAnonymizer
       @prev_email = @user.email
       @prev_username = @user.username
 
-      if !UsernameChanger.change(@user, make_anon_username)
-        raise "Failed to change username"
-      end
+      raise "Failed to change username" unless UsernameChanger.change(@user, make_anon_username)
 
       @user.reload
       @user.password = SecureRandom.hex
@@ -32,9 +30,7 @@ class UserAnonymizer
       @user.title = nil
       @user.uploaded_avatar_id = nil
 
-      if @opts.has_key?(:anonymize_ip)
-        anonymize_ips(@opts[:anonymize_ip])
-      end
+      anonymize_ips(@opts[:anonymize_ip]) if @opts.has_key?(:anonymize_ip)
 
       @user.save
 
diff --git a/app/services/username_changer.rb b/app/services/username_changer.rb
index a81f089..bb2cb1f 100644
--- a/app/services/username_changer.rb
+++ b/app/services/username_changer.rb
@@ -1,7 +1,10 @@
+require_dependency 'jobs/regular/update_username'
+
 class UsernameChanger
 
   def initialize(user, new_username, actor = nil)
     @user = user
+    @old_username = user.username
     @new_username = new_username
     @actor = actor
   end
@@ -10,14 +13,30 @@ class UsernameChanger
     self.new(user, new_username, actor).change
   end
 
-  def change
-    if @actor && @user.username != @new_username
-      StaffActionLogger.new(@actor).log_username_change(@user, @user.username, @new_username)
+  def change(asynchronous: true)
+    if @actor && @old_username != @new_username
+      StaffActionLogger.new(@actor).log_username_change(@user, @old_username, @new_username)
     end
 
-    # future work: update mentions and quotes
-
     @user.username = @new_username
-    @user.save
+    if @user.save
+
+      args = {
+        user_id: @user.id,
+        old_username: @old_username,
+        new_username: @new_username
+      }
+
+      if asynchronous
+        Jobs.enqueue(:update_username, args)
+      else
+        Jobs::UpdateUsername.new.execute(args)
+      end
+
+      return true
+    end
+
+    false
   end
+
 end
diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake
index 3776ca7..40dce95 100644
--- a/lib/tasks/users.rake
+++ b/lib/tasks/users.rake
@@ -48,6 +48,20 @@ task "users:merge", [:source_username, :target_username] => [:environment] do |_
   puts "", "Users merged!", ""
 end
 
+task "users:rename", [:old_username, :new_username] => [:environment] do |_, args|
+  old_username = args[:old_username]
+  new_username = args[:new_username]
+
+  if !old_username || !new_username
+    puts "ERROR: Expecting rake posts:rename[old_username,new_username]"
+    exit 1
+  end
+
+  changer = UsernameChanger.new(find_user(old_username), new_username)
+  changer.change(asynchronous: false)
+  puts "", "User renamed!", ""
+end
+
 def find_user(username)
   user = User.find_by_username(username)
 
diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb
index 79e1809..dad6e4d 100644
--- a/spec/services/username_changer_spec.rb
+++ b/spec/services/username_changer_spec.rb
@@ -90,6 +90,225 @@ describe UsernameChanger do
         expect(result).to eq(false)
       end
     end
+
+    context 'posts and revisions' do
+      let(:user) { Fabricate(:user, username: 'foo') }
+      let(:topic) { Fabricate(:topic, user: user) }
+
+      before { UserActionCreator.enable }
+      after { UserActionCreator.disable }
+
+      def create_post_and_change_username(args = {})
+        post = create_post(args.merge(topic_id: topic.id))
+
+        args.delete(:revisions)&.each do |revision|
+          post.revise(post.user, revision, force_new_version: true)
+        end
+
+        UsernameChanger.change(user, 'bar')
+        post.reload
+      end
+
+      context 'mentions' do
+        it 'rewrites cooked correctly' do
+          post = create_post_and_change_username(raw: "Hello @foo")
+          expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a></p>))
+
+          post.rebake!
+          expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a></p>))
+        end
+
+        it 'ignores case when replacing mentions' do
+          post = create_post_and_change_username(raw: "There's no difference between @foo and @Foo")
+
+          expect(post.raw).to eq("There's no difference between @bar and @bar")
+          expect(post.cooked).to eq(%Q(<p>There’s no difference between <a class="mention" href="/u/bar">@bar</a> and <a class="mention" href="/u/bar">@bar</a></p>))
+        end
+
+        it 'replaces mentions when there are leading symbols' do

[... diff too long, it was truncated ...]

GitHub sha: 3be3c50c