0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-reactions/app/controllers/discourse_reactions/custom_reactions_controller.rb
Isaac Janzen 76f0734c7a
SECURITY: anonymous users can read hidden real names via reaction-user endpoints (#41931)
## Summary

Fix anonymous disclosure of hidden full names of reactors and likers via
reaction-user APIs when `enable_names` setting is disabled. The
`CustomReactionsController` now conditionally includes the `name` field
only when `enable_names` is enabled, ensuring the invariant that hidden
full names are not exposed to unauthenticated users.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/1451

Co-authored-by: discourse-patch-triage
<272280883+discourse-patch-triage[bot]@users.noreply.github.com>
2026-07-22 12:18:18 -05:00

418 lines
14 KiB
Ruby
Vendored

# frozen_string_literal: true
class DiscourseReactions::CustomReactionsController < ApplicationController
MAX_USERS_COUNT = 26
PAGE_SIZE = 20
requires_plugin DiscourseReactions::PLUGIN_NAME
before_action :ensure_logged_in, except: %i[reactions_users_list post_reactions_users]
def toggle
post = fetch_post_from_params
reaction = params[:reaction]
return render_json_error(post) unless DiscourseReactions::Reaction.valid?(reaction)
begin
manager =
DiscourseReactions::ReactionManager.new(
reaction_value: params[:reaction],
user: current_user,
post: post,
)
manager.toggle!
rescue ActiveRecord::RecordNotUnique
# If the user already performed this action, it's probably due to a different browser tab
# or non-debounced clicking. We can ignore.
end
post.publish_change_to_clients!(:acted)
publish_change_to_clients!(
post,
reaction: manager.reaction_value,
previous_reaction: manager.previous_reaction_value,
)
render_json_dump(post_serializer(post).as_json)
end
def reactions_given
params.require(:username)
user =
fetch_user_from_params(
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
)
raise Discourse::NotFound unless guardian.can_see_profile?(user)
reaction_users =
DiscourseReactions::ReactionUser
.joins(
"INNER JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id",
)
.joins(
"INNER JOIN posts p ON p.id = discourse_reactions_reaction_users.post_id AND p.deleted_at IS NULL",
)
.joins("INNER JOIN topics t ON t.id = p.topic_id AND t.deleted_at IS NULL")
.joins(
"INNER JOIN posts p2 ON p2.topic_id = t.id AND p2.post_number = 1 AND p.deleted_at IS NULL",
)
.joins("LEFT JOIN categories c ON c.id = t.category_id")
.includes(:user, :post, :reaction)
.where(user_id: user.id)
.where.not(discourse_reactions_reactions: { reaction_users_count: nil })
reaction_users = secure_reaction_users!(reaction_users)
if params[:before_reaction_user_id]
reaction_users =
reaction_users.where(
"discourse_reactions_reaction_users.id < ?",
params[:before_reaction_user_id].to_i,
)
end
reaction_users = reaction_users.order(created_at: :desc).limit(PAGE_SIZE)
render_serialized(reaction_users.to_a, UserReactionSerializer)
end
def reactions_received
params.require(:username)
user =
fetch_user_from_params(
include_inactive:
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
)
raise Discourse::InvalidAccess unless guardian.can_see_notifications?(user)
posts = Post.joins(:topic).where(user_id: user.id)
posts = visible_posts_for_reactions_received(posts)
post_ids = posts.select(:id)
reaction_users =
DiscourseReactions::ReactionUser
.joins(:reaction)
.where(post_id: post_ids)
.where.not(discourse_reactions_reactions: { reaction_users_count: nil })
reaction_users =
DiscourseReactions::PostReactionsQuery.apply_ignored_users_filter(
reaction_users,
user_column: "discourse_reactions_reaction_users.user_id",
current_user_id: current_user.id,
)
# Guarantee backwards compatibility if someone was calling this endpoint with the old param.
# TODO(roman): Remove after the 2.9 release.
before_reaction_id = params[:before_reaction_user_id]
if before_reaction_id.blank? && params[:before_post_id]
before_reaction_id = params[:before_post_id]
end
if before_reaction_id
reaction_users =
reaction_users.where("discourse_reactions_reaction_users.id < ?", before_reaction_id.to_i)
end
if params[:acting_username]
reaction_users =
reaction_users.joins(:user).where(users: { username: params[:acting_username] })
end
reaction_users = reaction_users.order(created_at: :desc).limit(PAGE_SIZE).to_a
if params[:include_likes]
# We do not want to include likes that also count as
# a reaction, otherwise it is confusing in the UI. We
# do the same on the likes-received endpoint.
likes =
PostAction
.where(
post_id: post_ids,
deleted_at: nil,
post_action_type_id: PostActionType::LIKE_POST_ACTION_ID,
)
.joins(<<~SQL)
LEFT JOIN discourse_reactions_reaction_users ON
discourse_reactions_reaction_users.post_id = post_actions.post_id
AND discourse_reactions_reaction_users.user_id = post_actions.user_id
SQL
.where("discourse_reactions_reaction_users.id IS NULL")
.order(created_at: :desc)
.limit(PAGE_SIZE)
likes =
DiscourseReactions::PostReactionsQuery.apply_ignored_users_filter(
likes,
user_column: "post_actions.user_id",
current_user_id: current_user.id,
)
if params[:before_like_id]
likes = likes.where("post_actions.id < ?", params[:before_like_id].to_i)
end
if params[:acting_username]
likes = likes.joins(:user).where(users: { username: params[:acting_username] })
end
reaction_users = reaction_users.concat(translate_to_reactions(likes))
reaction_users = reaction_users.sort { |a, b| b.created_at <=> a.created_at }
end
render_serialized reaction_users.first(PAGE_SIZE), UserReactionSerializer
end
def reactions_users_list
post = fetch_post_from_params
page = params[:page].to_i.clamp(0..)
limit = params[:limit].present? ? params[:limit].to_i.clamp(1, 50) : 30
rows, total =
DiscourseReactions::PostReactionsQuery.call(
post: post,
reaction_filter: params[:reaction_value],
limit: limit,
offset: page * limit,
current_user_id: current_user&.id,
)
users =
rows.map do |row|
format_user(
row,
id: row.id,
avatar_template: User.avatar_template(row.username, row.uploaded_avatar_id),
reaction: row.reaction,
)
end
render_json_dump(users: users, total_rows: total)
end
def post_reactions_users
params.require(:id).to_i
reaction_value = params[:reaction_value]
post = fetch_post_from_params
raise Discourse::InvalidParameters if !post
reaction_users = []
main_reaction_id = DiscourseReactions::Reaction.main_reaction_id
main_reaction = nil
if !reaction_value || reaction_value == main_reaction_id
# We only want to get likes that don't have an associated ReactionUser
# record, which count as a like or there will be double ups for main_reaction_id.
likes =
post.post_actions.where(
DiscourseReactions::PostActionExtension.filter_reaction_likes_sql,
like: PostActionType::LIKE_POST_ACTION_ID,
valid_reactions: DiscourseReactions::Reaction.valid_reactions.to_a,
)
# Filter out likes for reactions that are not longer enabled,
# which match up to a ReactionUser in historical data.
historical_reaction_likes =
likes
.joins(
"LEFT JOIN discourse_reactions_reaction_users ON discourse_reactions_reaction_users.user_id = post_actions.user_id AND discourse_reactions_reaction_users.post_id = post_actions.post_id",
)
.joins(
"LEFT JOIN discourse_reactions_reactions ON discourse_reactions_reactions.id = discourse_reactions_reaction_users.reaction_id",
)
.where.not(
discourse_reactions_reactions: {
reaction_value: DiscourseReactions::Reaction.valid_reactions.to_a,
},
)
likes = likes.where.not(id: historical_reaction_likes.select(:id))
likes =
DiscourseReactions::PostReactionsQuery.apply_ignored_users_filter(
likes,
user_column: "post_actions.user_id",
current_user_id: current_user&.id,
)
main_reaction =
DiscourseReactions::Reaction.find_by(reaction_value: main_reaction_id, post_id: post.id)
end
reactions =
if !reaction_value
post.reactions.select do |reaction|
reaction[:reaction_users_count] && reaction[:reaction_value] != main_reaction_id
end
elsif reaction_value != main_reaction_id
post
.reactions
.where(reaction_value: reaction_value)
.select { |reaction| reaction[:reaction_users_count] }
else
[]
end
reactions_for_counts = reactions.dup
reactions_for_counts << main_reaction if main_reaction&.[](:reaction_users_count)
reaction_user_counts = filtered_reaction_users_counts(reactions_for_counts)
if likes.present?
count = likes.length
users = format_likes_users(likes)
# Also include ReactionUser records for main_reaction_id
# if they have been created in the past; new records created
# using main_reaction_id will only make a PostAction.
if main_reaction && main_reaction[:reaction_users_count]
(users << get_users(main_reaction)).flatten!
users.sort_by! { |user| user[:created_at] }
count += reaction_user_counts[main_reaction.id].to_i
end
reaction_users << {
id: main_reaction_id,
count: count,
users: users.reverse.slice(0, MAX_USERS_COUNT + 1),
}
end
reactions.each do |reaction|
reaction_users << format_reaction_user(reaction, count: reaction_user_counts[reaction.id])
end
render_json_dump(reaction_users: reaction_users)
end
private
def visible_posts_for_reactions_received(posts)
visible_topic_ids = guardian.can_see_topic_ids(topic_ids: posts.distinct.pluck(:topic_id))
posts =
posts.where(topic_id: visible_topic_ids, post_type: Topic.visible_post_types(current_user))
guardian.filter_hidden_posts(posts)
end
def format_user(user, avatar_template:, **extra_attributes)
attributes = { username: user.username }
attributes[:name] = user.name if SiteSetting.enable_names?
attributes[:avatar_template] = avatar_template
attributes.merge!(extra_attributes)
end
def get_users(reaction)
DiscourseReactions::PostReactionsQuery
.apply_ignored_users_filter(
reaction.reaction_users,
user_column: "discourse_reactions_reaction_users.user_id",
current_user_id: current_user&.id,
)
.includes(:user)
.order("discourse_reactions_reaction_users.created_at desc")
.limit(MAX_USERS_COUNT + 1)
.map do |reaction_user|
format_user(
reaction_user.user,
avatar_template: reaction_user.user.avatar_template,
can_undo: reaction_user.can_undo?,
created_at: reaction_user.created_at.to_s,
)
end
end
def format_reaction_user(reaction, count:)
{ id: reaction.reaction_value, count: count.to_i, users: get_users(reaction) }
end
def filtered_reaction_users_counts(reactions)
reactions = reactions.compact
return {} if reactions.blank?
if current_user.blank? || current_user.ignored_user_ids.empty?
return(
reactions.each_with_object({}) do |reaction, counts|
counts[reaction.id] = reaction.reaction_users_count.to_i
end
)
end
DiscourseReactions::PostReactionsQuery
.apply_ignored_users_filter(
DiscourseReactions::ReactionUser.where(reaction_id: reactions.map(&:id)),
user_column: "discourse_reactions_reaction_users.user_id",
current_user_id: current_user.id,
)
.group(:reaction_id)
.count
end
def format_like_user(like)
format_user(
like.user,
avatar_template: like.user.avatar_template,
can_undo: guardian.can_delete_post_action?(like),
created_at: like.created_at.to_s,
)
end
def format_likes_users(likes)
likes.includes([:user]).limit(MAX_USERS_COUNT + 1).map { |like| format_like_user(like) }
end
def post_serializer(post)
PostSerializer.new(post, scope: guardian, root: false)
end
def fetch_post_from_params
post_id = params[:post_id] || params[:id]
post = Post.find(post_id)
guardian.ensure_can_see!(post)
post
end
def publish_change_to_clients!(post, reaction: nil, previous_reaction: nil)
return unless (topic = post.topic)
message = { post_id: post.id, reactions: [reaction, previous_reaction].compact.uniq }
opts = {}
secure_audience = topic.secure_audience_publish_messages
opts = secure_audience if secure_audience[:user_ids] != [] && secure_audience[:group_ids] != []
MessageBus.publish("/topic/#{topic.id}/reactions", message, opts)
end
def secure_reaction_users!(reaction_users)
builder = DB.build("/*where*/")
UserAction.apply_common_filters(builder, current_user.id, guardian)
sql =
builder
.to_sql
.delete_prefix("/*where*/")
.delete_prefix("WHERE")
.gsub("a.acting_user_id", "discourse_reactions_reaction_users.user_id")
reaction_users.where(sql)
end
def translate_to_reactions(likes)
likes.map do |like|
DiscourseReactions::ReactionUser.new(
id: like.id,
post: like.post,
user: like.user,
created_at: like.created_at,
reaction:
DiscourseReactions::Reaction.new(
id: like.id,
reaction_type: "emoji",
post_id: like.post_id,
reaction_value: DiscourseReactions::Reaction.main_reaction_id,
created_at: like.created_at,
reaction_users_count: 1,
),
)
end
end
end