mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 01:16:38 -06:00
FEATURE: Rename user in mentions and quotes
Co-authored-by: Robin Ward <robin.ward@gmail.com>
This commit is contained in:
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_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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user