mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 09:42:07 +08:00
5e4c0e2caa
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.
430 lines
18 KiB
Ruby
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
|