mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: Rename user in mentions and quotes
Co-authored-by: Robin Ward <robin.ward@gmail.com>
This commit is contained in:
committed by
Robin Ward
parent
1eee517ae2
commit
3be3c50c7e
94
app/jobs/regular/update_username.rb
Normal file
94
app/jobs/regular/update_username.rb
Normal file
@@ -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
|
||||||
@@ -20,9 +20,7 @@ class UserAnonymizer
|
|||||||
@prev_email = @user.email
|
@prev_email = @user.email
|
||||||
@prev_username = @user.username
|
@prev_username = @user.username
|
||||||
|
|
||||||
if !UsernameChanger.change(@user, make_anon_username)
|
raise "Failed to change username" unless UsernameChanger.change(@user, make_anon_username)
|
||||||
raise "Failed to change username"
|
|
||||||
end
|
|
||||||
|
|
||||||
@user.reload
|
@user.reload
|
||||||
@user.password = SecureRandom.hex
|
@user.password = SecureRandom.hex
|
||||||
@@ -32,9 +30,7 @@ class UserAnonymizer
|
|||||||
@user.title = nil
|
@user.title = nil
|
||||||
@user.uploaded_avatar_id = nil
|
@user.uploaded_avatar_id = nil
|
||||||
|
|
||||||
if @opts.has_key?(:anonymize_ip)
|
anonymize_ips(@opts[:anonymize_ip]) if @opts.has_key?(:anonymize_ip)
|
||||||
anonymize_ips(@opts[:anonymize_ip])
|
|
||||||
end
|
|
||||||
|
|
||||||
@user.save
|
@user.save
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
require_dependency 'jobs/regular/update_username'
|
||||||
|
|
||||||
class UsernameChanger
|
class UsernameChanger
|
||||||
|
|
||||||
def initialize(user, new_username, actor = nil)
|
def initialize(user, new_username, actor = nil)
|
||||||
@user = user
|
@user = user
|
||||||
|
@old_username = user.username
|
||||||
@new_username = new_username
|
@new_username = new_username
|
||||||
@actor = actor
|
@actor = actor
|
||||||
end
|
end
|
||||||
@@ -10,14 +13,30 @@ class UsernameChanger
|
|||||||
self.new(user, new_username, actor).change
|
self.new(user, new_username, actor).change
|
||||||
end
|
end
|
||||||
|
|
||||||
def change
|
def change(asynchronous: true)
|
||||||
if @actor && @user.username != @new_username
|
if @actor && @old_username != @new_username
|
||||||
StaffActionLogger.new(@actor).log_username_change(@user, @user.username, @new_username)
|
StaffActionLogger.new(@actor).log_username_change(@user, @old_username, @new_username)
|
||||||
end
|
end
|
||||||
|
|
||||||
# future work: update mentions and quotes
|
|
||||||
|
|
||||||
@user.username = @new_username
|
@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
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ task "users:merge", [:source_username, :target_username] => [:environment] do |_
|
|||||||
puts "", "Users merged!", ""
|
puts "", "Users merged!", ""
|
||||||
end
|
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)
|
def find_user(username)
|
||||||
user = User.find_by_username(username)
|
user = User.find_by_username(username)
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,225 @@ describe UsernameChanger do
|
|||||||
expect(result).to eq(false)
|
expect(result).to eq(false)
|
||||||
end
|
end
|
||||||
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
|
||||||
|
post = create_post_and_change_username(raw: ".@foo -@foo %@foo _@foo ,@foo ;@foo @@foo")
|
||||||
|
|
||||||
|
expect(post.raw).to eq(".@bar -@bar %@bar _@bar ,@bar ;@bar @@bar")
|
||||||
|
expect(post.cooked).to match_html(<<~HTML)
|
||||||
|
<p>.<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
-<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
%<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
_<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
,<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
;<a class="mention" href="/u/bar">@bar</a>
|
||||||
|
@<a class="mention" href="/u/bar">@bar</a></p>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'replaces mentions within double and single quotes' do
|
||||||
|
post = create_post_and_change_username(raw: %Q("@foo" '@foo'))
|
||||||
|
|
||||||
|
expect(post.raw).to eq(%Q("@bar" '@bar'))
|
||||||
|
expect(post.cooked).to eq(%Q(<p>“<a class="mention" href="/u/bar">@bar</a>” ‘<a class="mention" href="/u/bar">@bar</a>’</p>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'replaces mentions when there are trailing symbols' do
|
||||||
|
post = create_post_and_change_username(raw: "@foo. @foo, @foo: @foo; @foo-")
|
||||||
|
|
||||||
|
expect(post.raw).to eq("@bar. @bar, @bar: @bar; @bar-")
|
||||||
|
expect(post.cooked).to match_html(<<~HTML)
|
||||||
|
<p><a class="mention" href="/u/bar">@bar</a>.
|
||||||
|
<a class="mention" href="/u/bar">@bar</a>,
|
||||||
|
<a class="mention" href="/u/bar">@bar</a>:
|
||||||
|
<a class="mention" href="/u/bar">@bar</a>;
|
||||||
|
<a class="mention" href="/u/bar">@bar</a>-</p>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not replace mention when followed by an underscore' do
|
||||||
|
post = create_post_and_change_username(raw: "@foo_")
|
||||||
|
|
||||||
|
expect(post.raw).to eq("@foo_")
|
||||||
|
expect(post.cooked).to eq(%Q(<p><span class="mention">@foo_</span></p>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not replace mentions when there are leading alphanumeric chars' do
|
||||||
|
post = create_post_and_change_username(raw: "a@foo 2@foo")
|
||||||
|
|
||||||
|
expect(post.raw).to eq("a@foo 2@foo")
|
||||||
|
expect(post.cooked).to eq(%Q(<p>a@foo 2@foo</p>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not replace username within email address' do
|
||||||
|
post = create_post_and_change_username(raw: "mail@foo.com")
|
||||||
|
|
||||||
|
expect(post.raw).to eq("mail@foo.com")
|
||||||
|
expect(post.cooked).to eq(%Q(<p><a href="mailto:mail@foo.com">mail@foo.com</a></p>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not replace username in a mention of a similar username' do
|
||||||
|
Fabricate(:user, username: 'foobar')
|
||||||
|
Fabricate(:user, username: 'foo-bar')
|
||||||
|
Fabricate(:user, username: 'foo_bar')
|
||||||
|
Fabricate(:user, username: 'foo1')
|
||||||
|
|
||||||
|
post = create_post_and_change_username(raw: "@foo @foobar @foo-bar @foo_bar @foo1")
|
||||||
|
|
||||||
|
expect(post.raw).to eq("@bar @foobar @foo-bar @foo_bar @foo1")
|
||||||
|
expect(post.cooked).to match_html(<<~HTML)
|
||||||
|
<p><a class="mention" href="/u/bar">@bar</a>
|
||||||
|
<a class="mention" href="/u/foobar">@foobar</a>
|
||||||
|
<a class="mention" href="/u/foo-bar">@foo-bar</a>
|
||||||
|
<a class="mention" href="/u/foo_bar">@foo_bar</a>
|
||||||
|
<a class="mention" href="/u/foo1">@foo1</a></p>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the path to the user even when it links to /user instead of /u' do
|
||||||
|
post = create_post_and_change_username(raw: "Hello @foo")
|
||||||
|
post.update_column(:cooked, post.cooked.gsub("/u/foo", "/users/foo"))
|
||||||
|
|
||||||
|
expect(post.raw).to eq("Hello @bar")
|
||||||
|
expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a></p>))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'replaces mentions within revisions' do
|
||||||
|
revisions = [{ raw: "Hello Foo" }, { raw: "Hello @foo!" }, { raw: "Hello @foo!!" }]
|
||||||
|
post = create_post_and_change_username(raw: "Hello @foo", revisions: revisions)
|
||||||
|
|
||||||
|
expect(post.raw).to eq("Hello @bar!!")
|
||||||
|
expect(post.cooked).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a>!!</p>))
|
||||||
|
|
||||||
|
expect(post.revisions.count).to eq(3)
|
||||||
|
|
||||||
|
expect(post.revisions[0].modifications["raw"][0]).to eq("Hello @bar")
|
||||||
|
expect(post.revisions[0].modifications["raw"][1]).to eq("Hello Foo")
|
||||||
|
expect(post.revisions[0].modifications["cooked"][0]).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a></p>))
|
||||||
|
expect(post.revisions[0].modifications["cooked"][1]).to eq(%Q(<p>Hello Foo</p>))
|
||||||
|
|
||||||
|
expect(post.revisions[1].modifications["raw"][0]).to eq("Hello Foo")
|
||||||
|
expect(post.revisions[1].modifications["raw"][1]).to eq("Hello @bar!")
|
||||||
|
expect(post.revisions[1].modifications["cooked"][0]).to eq(%Q(<p>Hello Foo</p>))
|
||||||
|
expect(post.revisions[1].modifications["cooked"][1]).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a>!</p>))
|
||||||
|
|
||||||
|
expect(post.revisions[2].modifications["raw"][0]).to eq("Hello @bar!")
|
||||||
|
expect(post.revisions[2].modifications["raw"][1]).to eq("Hello @bar!!")
|
||||||
|
expect(post.revisions[2].modifications["cooked"][0]).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a>!</p>))
|
||||||
|
expect(post.revisions[2].modifications["cooked"][1]).to eq(%Q(<p>Hello <a class="mention" href="/u/bar">@bar</a>!!</p>))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'quotes' do
|
||||||
|
let(:quoted_post) { create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") }
|
||||||
|
|
||||||
|
it 'replaces the username in quote tags' do
|
||||||
|
avatar_url = user.avatar_template_url.gsub("{size}", "40")
|
||||||
|
|
||||||
|
post = create_post_and_change_username(raw: <<~RAW)
|
||||||
|
Lorem ipsum
|
||||||
|
|
||||||
|
[quote="foo, post:1, topic:#{quoted_post.topic.id}"]
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
[quote='foo']
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
[quote=foo, post:1, topic:#{quoted_post.topic.id}]
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
dolor sit amet
|
||||||
|
RAW
|
||||||
|
|
||||||
|
expect(post.raw).to eq(<<~RAW.strip)
|
||||||
|
Lorem ipsum
|
||||||
|
|
||||||
|
[quote="bar, post:1, topic:#{quoted_post.topic.id}"]
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
[quote='bar']
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
[quote=bar, post:1, topic:#{quoted_post.topic.id}]
|
||||||
|
quoted post
|
||||||
|
[/quote]
|
||||||
|
|
||||||
|
dolor sit amet
|
||||||
|
RAW
|
||||||
|
|
||||||
|
expect(post.cooked).to match_html(<<~HTML)
|
||||||
|
<p>Lorem ipsum</p>
|
||||||
|
<aside class="quote no-group" data-post="1" data-topic="#{quoted_post.topic.id}">
|
||||||
|
<div class="title">
|
||||||
|
<div class="quote-controls"></div>
|
||||||
|
<img alt width="20" height="20" src="#{avatar_url}" class="avatar"> bar:</div>
|
||||||
|
<blockquote>
|
||||||
|
<p>quoted post</p>
|
||||||
|
</blockquote>
|
||||||
|
</aside>
|
||||||
|
<aside class="quote no-group">
|
||||||
|
<div class="title">
|
||||||
|
<div class="quote-controls"></div>
|
||||||
|
<img alt width="20" height="20" src="#{avatar_url}" class="avatar"> bar:</div>
|
||||||
|
<blockquote>
|
||||||
|
<p>quoted post</p>
|
||||||
|
</blockquote>
|
||||||
|
</aside>
|
||||||
|
<aside class="quote no-group" data-post="1" data-topic="#{quoted_post.topic.id}">
|
||||||
|
<div class="title">
|
||||||
|
<div class="quote-controls"></div>
|
||||||
|
<img alt width="20" height="20" src="#{avatar_url}" class="avatar"> bar:</div>
|
||||||
|
<blockquote>
|
||||||
|
<p>quoted post</p>
|
||||||
|
</blockquote>
|
||||||
|
</aside>
|
||||||
|
<p>dolor sit amet</p>
|
||||||
|
HTML
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO spec for quotes in revisions
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user