discourse/app/jobs/regular/update_username.rb
Ted Johansson 25a226279a
DEV: Replace #pluck_first freedom patch with AR #pick in core (#19893)
The #pluck_first freedom patch, first introduced by @danielwaterworth has served us well, and is used widely throughout both core and plugins. It seems to have been a common enough use case that Rails 6 introduced it's own method #pick with the exact same implementation. This allows us to retire the freedom patch and switch over to the built-in ActiveRecord method.

There is no replacement for #pluck_first!, but a quick search shows we are using this in a very limited capacity, and in some cases incorrectly (by assuming a nil return rather than an exception), which can quite easily be replaced with #pick plus some extra handling.
2023-02-13 12:39:45 +08:00

208 lines
6.9 KiB
Ruby

# frozen_string_literal: true
module Jobs
class UpdateUsername < ::Jobs::Base
sidekiq_options queue: "low"
def execute(args)
@user_id = args[:user_id]
user = User.find_by(id: @user_id)
return unless user
@old_username = args[:old_username].unicode_normalize
@new_username = args[:new_username].unicode_normalize
@avatar_img = PrettyText.avatar_img(args[:avatar_template], "tiny")
@raw_mention_regex =
/
(?:
(?<![\p{Alnum}\p{M}`]) # make sure there is no preceding letter, number or backtick
)
@#{@old_username}
(?:
(?![\p{Alnum}\p{M}_\-.`]) # make sure there is no trailing letter, number, underscore, dash, dot or backtick
| # or
(?=[-_.](?:\s|$)) # there is an underscore, dash or dot followed by a whitespace or end of line
)
/ix
@raw_quote_regex = /(\[quote\s*=\s*["'']?)#{@old_username}(\,?[^\]]*\])/i
cooked_username = PrettyText::Helpers.format_username(@old_username)
@cooked_mention_username_regex = /\A@#{cooked_username}\z/i
@cooked_mention_user_path_regex =
%r{\A/u(?:sers)?/#{UrlHelper.encode_component(cooked_username)}\z}i
@cooked_quote_username_regex = /(?<=\s)#{cooked_username}(?=:)/i
update_posts
update_revisions
update_notifications
update_post_custom_fields
DiscourseEvent.trigger(:username_changed, @old_username, @new_username)
DiscourseEvent.trigger(:user_updated, user)
end
def update_posts
updated_post_ids = Set.new
Post
.with_deleted
.where("raw ILIKE ?", "%@#{@old_username}%")
.find_each do |post|
update_post(post)
updated_post_ids << post.id
end
Post
.with_deleted
.joins(quoted("posts.id"))
.where("p.user_id = :user_id", user_id: @user_id)
.find_each { |post| update_post(post) unless updated_post_ids.include?(post.id) }
end
def update_revisions
PostRevision
.where("modifications SIMILAR TO ?", "%(raw|cooked)%@#{@old_username}%")
.find_each { |revision| update_revision(revision) }
PostRevision
.joins(quoted("post_revisions.post_id"))
.where("p.user_id = :user_id", user_id: @user_id)
.find_each { |revision| update_revision(revision) }
end
def update_notifications
params = { user_id: @user_id, old_username: @old_username, new_username: @new_username }
DB.exec(<<~SQL, params)
UPDATE notifications
SET data = (data :: JSONB ||
jsonb_strip_nulls(
jsonb_build_object(
'original_username', CASE data :: JSONB ->> 'original_username'
WHEN :old_username
THEN :new_username
ELSE NULL END,
'display_username', CASE data :: JSONB ->> 'display_username'
WHEN :old_username
THEN :new_username
ELSE NULL END,
'username', CASE data :: JSONB ->> 'username'
WHEN :old_username
THEN :new_username
ELSE NULL END,
'username2', CASE data :: JSONB ->> 'username2'
WHEN :old_username
THEN :new_username
ELSE NULL END
)
)) :: JSON
WHERE data ILIKE '%' || :old_username || '%'
SQL
end
def update_post_custom_fields
DB.exec(<<~SQL, old_username: @old_username, new_username: @new_username)
UPDATE post_custom_fields
SET value = :new_username
WHERE name = 'action_code_who' AND value = :old_username
SQL
end
protected
def update_post(post)
post.raw = update_raw(post.raw)
post.cooked = update_cooked(post.cooked)
post.update_columns(raw: post.raw, cooked: post.cooked)
SearchIndexer.index(post, force: true) if post.topic
rescue => e
Discourse.warn_exception(e, message: "Failed to update post with id #{post.id}")
end
def update_revision(revision)
if revision.modifications.key?("raw") || revision.modifications.key?("cooked")
revision.modifications["raw"]&.map! { |raw| update_raw(raw) }
revision.modifications["cooked"]&.map! { |cooked| update_cooked(cooked) }
revision.save!
end
rescue => e
Discourse.warn_exception(e, message: "Failed to update post revision with id #{revision.id}")
end
def mentioned(post_id_column)
<<~SQL
JOIN user_actions AS a ON (a.target_post_id = #{post_id_column} AND
a.action_type = #{UserAction::MENTION})
SQL
end
def quoted(post_id_column)
<<~SQL
JOIN quoted_posts AS q ON (q.post_id = #{post_id_column})
JOIN posts AS p ON (q.quoted_post_id = p.id)
SQL
end
def update_raw(raw)
raw.gsub(@raw_mention_regex, "@#{@new_username}").gsub(
@raw_quote_regex,
"\\1#{@new_username}\\2",
)
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::HTML5.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/#{UrlHelper.encode_component(@new_username)}",
) if a["href"]
end
doc
.css("aside.quote")
.each do |aside|
next unless div = aside.at_css("div.title")
username_replaced = false
aside["data-username"] = @new_username if aside["data-username"] == @old_username
div.children.each do |child|
if child.text?
content = child.content
username_replaced =
content.gsub!(@cooked_quote_username_regex, @new_username).present?
child.content = content if username_replaced
end
end
if username_replaced || quotes_correct_user?(aside)
div.at_css("img.avatar")&.replace(@avatar_img)
end
end
doc.to_html
end
def quotes_correct_user?(aside)
Post.exists?(
topic_id: aside["data-topic"],
post_number: aside["data-post"],
user_id: @user_id,
)
end
end
end