discourse/app/services/user_merger.rb
Roman Rizzi 5e4c0e2caa
FEATURE: Treat site settings as plain text and add a new HTML type. (#12618)
To add an extra layer of security, we sanitize settings before shipping them to the client. We don't sanitize those that have the "html" type.

The CookedPostProcessor already uses Loofah for sanitization, so I chose to also use it for this. I added it to our gemfile since we installed it as a transitive dependency.
2021-04-07 12:51:19 -03:00

430 lines
18 KiB
Ruby

# frozen_string_literal: true
class UserMerger
def initialize(source_user, target_user, acting_user = nil)
@source_user = source_user
@target_user = target_user
@acting_user = acting_user
@user_id = source_user.id
@source_primary_email = source_user.email
end
def merge!
update_username
move_posts
update_user_ids
merge_given_daily_likes
merge_post_timings
merge_user_visits
update_site_settings
merge_user_attributes
DiscourseEvent.trigger(:merging_users, @source_user, @target_user)
update_user_stats
delete_source_user
log_merge
@target_user.reload
end
protected
def update_username
return if @source_user.username == @target_user.username
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.updating_username") }, user_ids: [@acting_user.id] if @acting_user
UsernameChanger.update_username(user_id: @source_user.id,
old_username: @source_user.username,
new_username: @target_user.username,
avatar_template: @target_user.avatar_template,
asynchronous: false)
end
def move_posts
posts = Post.with_deleted
.where(user_id: @source_user.id)
.order(:topic_id, :post_number)
.pluck(:topic_id, :id)
return if posts.count == 0
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.changing_post_ownership") }, user_ids: [@acting_user.id] if @acting_user
last_topic_id = nil
post_ids = []
posts.each do |current_topic_id, current_post_id|
if last_topic_id != current_topic_id && post_ids.any?
change_post_owner(last_topic_id, post_ids)
post_ids = []
end
last_topic_id = current_topic_id
post_ids << current_post_id
end
change_post_owner(last_topic_id, post_ids) if post_ids.any?
end
def change_post_owner(topic_id, post_ids)
PostOwnerChanger.new(
topic_id: topic_id,
post_ids: post_ids,
new_owner: @target_user,
acting_user: Discourse.system_user,
skip_revision: true
).change_owner!
end
def merge_given_daily_likes
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.merging_given_daily_likes") }, user_ids: [@acting_user.id] if @acting_user
sql = <<~SQL
INSERT INTO given_daily_likes AS g (user_id, likes_given, given_date, limit_reached)
SELECT
:target_user_id AS user_id,
COUNT(1) AS likes_given,
a.created_at::DATE AS given_date,
COUNT(1) >= :max_likes_per_day AS limit_reached
FROM post_actions AS a
WHERE a.user_id = :target_user_id
AND a.deleted_at IS NULL
AND EXISTS(
SELECT 1
FROM given_daily_likes AS g
WHERE g.user_id = :source_user_id AND a.created_at::DATE = g.given_date
)
GROUP BY given_date
ON CONFLICT (user_id, given_date)
DO UPDATE
SET likes_given = EXCLUDED.likes_given,
limit_reached = EXCLUDED.limit_reached
SQL
DB.exec(
sql,
source_user_id: @source_user.id,
target_user_id: @target_user.id,
max_likes_per_day: SiteSetting.max_likes_per_day,
action_type_id: PostActionType.types[:like]
)
end
def merge_post_timings
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.merging_post_timings") }, user_ids: [@acting_user.id] if @acting_user
update_user_id(:post_timings, conditions: ["x.topic_id = y.topic_id",
"x.post_number = y.post_number"])
sql = <<~SQL
UPDATE post_timings AS t
SET msecs = t.msecs + s.msecs
FROM post_timings AS s
WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id
AND t.topic_id = s.topic_id AND t.post_number = s.post_number
SQL
DB.exec(sql, source_user_id: @source_user.id, target_user_id: @target_user.id)
end
def merge_user_visits
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.merging_user_visits") }, user_ids: [@acting_user.id] if @acting_user
update_user_id(:user_visits, conditions: "x.visited_at = y.visited_at")
sql = <<~SQL
UPDATE user_visits AS t
SET posts_read = t.posts_read + s.posts_read,
mobile = t.mobile OR s.mobile,
time_read = t.time_read + s.time_read
FROM user_visits AS s
WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id
AND t.visited_at = s.visited_at
SQL
DB.exec(sql, source_user_id: @source_user.id, target_user_id: @target_user.id)
end
def update_site_settings
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.updating_site_settings") }, user_ids: [@acting_user.id] if @acting_user
SiteSetting.all_settings(include_hidden: true).each do |setting|
if setting[:type] == "username" && setting[:value] == @source_user.username
SiteSetting.set_and_log(setting[:setting], @target_user.username)
end
end
end
def update_user_stats
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.updating_user_stats") }, user_ids: [@acting_user.id] if @acting_user
# topics_entered
DB.exec(<<~SQL, target_user_id: @target_user.id)
UPDATE user_stats
SET topics_entered = (
SELECT COUNT(topic_id)
FROM topic_views
WHERE user_id = :target_user_id
)
WHERE user_id = :target_user_id
SQL
# time_read and days_visited
DB.exec(<<~SQL, target_user_id: @target_user.id)
UPDATE user_stats
SET time_read = COALESCE(x.time_read, 0),
days_visited = COALESCE(x.days_visited, 0)
FROM (
SELECT
SUM(time_read) AS time_read,
COUNT(1) AS days_visited
FROM user_visits
WHERE user_id = :target_user_id
) AS x
WHERE user_id = :target_user_id
SQL
# posts_read_count
DB.exec(<<~SQL, target_user_id: @target_user.id)
UPDATE user_stats
SET posts_read_count = (
SELECT COUNT(1)
FROM post_timings AS pt
WHERE pt.user_id = :target_user_id AND EXISTS(
SELECT 1
FROM topics AS t
WHERE t.archetype = 'regular' AND t.deleted_at IS NULL
))
WHERE user_id = :target_user_id
SQL
# likes_given, likes_received, new_since, read_faq, first_post_created_at
DB.exec(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id)
UPDATE user_stats AS t
SET likes_given = t.likes_given + s.likes_given,
likes_received = t.likes_received + s.likes_received,
new_since = LEAST(t.new_since, s.new_since),
read_faq = LEAST(t.read_faq, s.read_faq),
first_post_created_at = LEAST(t.first_post_created_at, s.first_post_created_at)
FROM user_stats AS s
WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id
SQL
end
def merge_user_attributes
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.merging_user_attributes") }, user_ids: [@acting_user.id] if @acting_user
DB.exec(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id)
UPDATE users AS t
SET created_at = LEAST(t.created_at, s.created_at),
updated_at = LEAST(t.updated_at, s.updated_at),
seen_notification_id = GREATEST(t.seen_notification_id, s.seen_notification_id),
last_posted_at = GREATEST(t.last_seen_at, s.last_seen_at),
last_seen_at = GREATEST(t.last_seen_at, s.last_seen_at),
admin = t.admin OR s.admin,
last_emailed_at = GREATEST(t.last_emailed_at, s.last_emailed_at),
trust_level = GREATEST(t.trust_level, s.trust_level),
previous_visit_at = GREATEST(t.previous_visit_at, s.previous_visit_at),
date_of_birth = COALESCE(t.date_of_birth, s.date_of_birth),
ip_address = COALESCE(t.ip_address, s.ip_address),
moderator = t.moderator OR s.moderator,
title = COALESCE(t.title, s.title),
primary_group_id = COALESCE(t.primary_group_id, s.primary_group_id),
registration_ip_address = COALESCE(t.registration_ip_address, s.registration_ip_address),
first_seen_at = LEAST(t.first_seen_at, s.first_seen_at),
manual_locked_trust_level = GREATEST(t.manual_locked_trust_level, s.manual_locked_trust_level)
FROM users AS s
WHERE t.id = :target_user_id AND s.id = :source_user_id
SQL
DB.exec(<<~SQL, source_user_id: @source_user.id, target_user_id: @target_user.id)
UPDATE user_profiles AS t
SET location = COALESCE(t.location, s.location),
website = COALESCE(t.website, s.website),
bio_raw = COALESCE(t.bio_raw, s.bio_raw),
bio_cooked = COALESCE(t.bio_cooked, s.bio_cooked),
bio_cooked_version = COALESCE(t.bio_cooked_version, s.bio_cooked_version),
profile_background_upload_id = COALESCE(t.profile_background_upload_id, s.profile_background_upload_id),
dismissed_banner_key = COALESCE(t.dismissed_banner_key, s.dismissed_banner_key),
badge_granted_title = t.badge_granted_title OR s.badge_granted_title,
card_background_upload_id = COALESCE(t.card_background_upload_id, s.card_background_upload_id),
views = t.views + s.views
FROM user_profiles AS s
WHERE t.user_id = :target_user_id AND s.user_id = :source_user_id
SQL
end
def update_user_ids
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.updating_user_ids") }, user_ids: [@acting_user.id] if @acting_user
Category.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
update_user_id(:category_users, conditions: ["x.category_id = y.category_id"])
update_user_id(:developers)
update_user_id(:draft_sequences, conditions: "x.draft_key = y.draft_key")
update_user_id(:drafts, conditions: "x.draft_key = y.draft_key")
update_user_id(:dismissed_topic_users, conditions: "x.topic_id = y.topic_id")
EmailLog.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
GroupHistory.where(acting_user_id: @source_user.id).update_all(acting_user_id: @target_user.id)
GroupHistory.where(target_user_id: @source_user.id).update_all(target_user_id: @target_user.id)
update_user_id(:group_users, conditions: "x.group_id = y.group_id")
IncomingEmail.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
IncomingLink.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
IncomingLink.where(current_user_id: @source_user.id).update_all(current_user_id: @target_user.id)
InvitedUser.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
Invite.with_deleted.where(invited_by_id: @source_user.id).update_all(invited_by_id: @target_user.id)
Invite.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
update_user_id(:muted_users, conditions: "x.muted_user_id = y.muted_user_id")
update_user_id(:muted_users, user_id_column_name: "muted_user_id", conditions: "x.user_id = y.user_id")
update_user_id(:ignored_users, conditions: "x.ignored_user_id = y.ignored_user_id")
update_user_id(:ignored_users, user_id_column_name: "ignored_user_id", conditions: "x.user_id = y.user_id")
Notification.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
update_user_id(:post_actions, conditions: ["x.post_id = y.post_id",
"x.post_action_type_id = y.post_action_type_id",
"x.targets_topic = y.targets_topic"])
PostAction.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
PostAction.where(deferred_by_id: @source_user.id).update_all(deferred_by_id: @target_user.id)
PostAction.where(agreed_by_id: @source_user.id).update_all(agreed_by_id: @target_user.id)
PostAction.where(disagreed_by_id: @source_user.id).update_all(disagreed_by_id: @target_user.id)
PostRevision.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
Post.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
Post.with_deleted.where(last_editor_id: @source_user.id).update_all(last_editor_id: @target_user.id)
Post.with_deleted.where(locked_by_id: @source_user.id).update_all(locked_by_id: @target_user.id)
Post.with_deleted.where(reply_to_user_id: @source_user.id).update_all(reply_to_user_id: @target_user.id)
Reviewable.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id)
ReviewableHistory.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id)
SearchLog.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
update_user_id(:tag_users, conditions: "x.tag_id = y.tag_id")
Theme.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
update_user_id(:topic_allowed_users, conditions: "x.topic_id = y.topic_id")
TopicEmbed.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
TopicLink.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
TopicLinkClick.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
TopicTimer.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
update_user_id(:topic_timers, conditions: ["x.status_type = y.status_type",
"x.topic_id = y.topic_id",
"y.deleted_at IS NULL"])
update_user_id(:topic_users, conditions: "x.topic_id = y.topic_id")
update_user_id(:topic_views, conditions: "x.topic_id = y.topic_id")
Topic.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
UnsubscribeKey.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
Upload.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
update_user_id(:user_archived_messages, conditions: "x.topic_id = y.topic_id")
update_user_id(:user_actions,
user_id_column_name: "user_id",
conditions: ["x.action_type = y.action_type",
"x.target_topic_id IS NOT DISTINCT FROM y.target_topic_id",
"x.target_post_id IS NOT DISTINCT FROM y.target_post_id",
"(x.acting_user_id IN (:source_user_id, :target_user_id) OR x.acting_user_id IS NOT DISTINCT FROM y.acting_user_id)"])
update_user_id(:user_actions,
user_id_column_name: "acting_user_id",
conditions: ["x.action_type = y.action_type",
"x.user_id = y.user_id",
"x.target_topic_id IS NOT DISTINCT FROM y.target_topic_id",
"x.target_post_id IS NOT DISTINCT FROM y.target_post_id"])
update_user_id(:user_badges, conditions: ["x.badge_id = y.badge_id",
"x.seq = y.seq",
"x.post_id IS NOT DISTINCT FROM y.post_id"])
UserBadge.where(granted_by_id: @source_user.id).update_all(granted_by_id: @target_user.id)
update_user_id(:user_custom_fields, conditions: "x.name = y.name")
update_user_id(:user_emails, conditions: "x.email = y.email OR y.primary = false", updates: '"primary" = false')
UserExport.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
UserHistory.where(target_user_id: @source_user.id).update_all(target_user_id: @target_user.id)
UserHistory.where(acting_user_id: @source_user.id).update_all(acting_user_id: @target_user.id)
UserProfileView.where(user_profile_id: @source_user.id).update_all(user_profile_id: @target_user.id)
UserProfileView.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
UserWarning.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
UserWarning.where(created_by_id: @source_user.id).update_all(created_by_id: @target_user.id)
User.where(approved_by_id: @source_user.id).update_all(approved_by_id: @target_user.id)
end
def delete_source_user
::MessageBus.publish '/merge_user', { message: I18n.t("admin.user.merge_user.deleting_source_user") }, user_ids: [@acting_user.id] if @acting_user
@source_user.reload
@source_user.skip_email_validation = true
@source_user.update(
admin: false,
email: "#{@source_user.username}_#{SecureRandom.hex}@no-email.invalid"
)
UserDestroyer.new(Discourse.system_user).destroy(@source_user, quiet: true)
end
def log_merge
logger = StaffActionLogger.new(@acting_user || Discourse.system_user)
logger.log_user_merge(@target_user, @source_user.username, @source_primary_email || "")
end
def update_user_id(table_name, opts = {})
builder = update_user_id_sql_builder(table_name, opts)
builder.exec(source_user_id: @source_user.id, target_user_id: @target_user.id)
end
def update_user_id_sql_builder(table_name, opts = {})
user_id_column_name = opts[:user_id_column_name] || :user_id
conditions = Array.wrap(opts[:conditions])
updates = Array.wrap(opts[:updates])
builder = DB.build(<<~SQL)
UPDATE #{table_name} AS x
/*set*/
WHERE x.#{user_id_column_name} = :source_user_id AND NOT EXISTS(
SELECT 1
FROM #{table_name} AS y
/*where*/
)
SQL
builder.set("#{user_id_column_name} = :target_user_id")
updates.each { |u| builder.set(u) }
builder.where("y.#{user_id_column_name} = :target_user_id")
conditions.each { |c| builder.where(c) }
builder
end
end