0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 17:25:34 +08:00
discourse/app/controllers/composer_controller.rb
Natalie Tay 80e42e21ee
SECURITY: Respect group member visibility for counts (#41403)
When a group is visible but its members are hidden, some group payloads
still included the exact member count.

This PR serializes group member counts when the requester can see group
members, and we update the public API schemas to match the conditional
field.
2026-07-06 12:48:06 +08:00

211 lines
6.3 KiB
Ruby
Vendored

# frozen_string_literal: true
class ComposerController < ApplicationController
requires_login
def mentions
names = params.require(:names)
raise Discourse::InvalidParameters.new(:names) if !names.kind_of?(Array) || names.size > 20
@names = names.map { |n| n.to_s.downcase }
if params[:topic_id].present?
@topic = Topic.find_by(id: params[:topic_id])
guardian.ensure_can_see!(@topic)
end
# allowed_names is necessary just for new private messages.
@allowed_names =
if params[:allowed_names].present?
if !params[:allowed_names].is_a?(Array)
raise Discourse::InvalidParameters.new(:allowed_names)
end
(params[:allowed_names] + [current_user.username]).map(&:downcase)
else
[]
end
user_reasons = {}
group_reasons = {}
@names.each do |name|
if user = users[name]
reason = user_reason(user)
user_reasons[name] = reason if reason.present?
elsif group = groups[name]
reason = group_reason(group)
group_reasons[name] = reason if reason.present?
end
end
if @topic && @names.include?(SiteSetting.here_mention.downcase) && guardian.can_mention_here?
here_count =
PostAlerter.new.expand_here_mention(@topic.first_post, exclude_ids: [current_user.id]).size
end
serialized_groups =
groups
.values
.each_with_object({}) do |group, hash|
name = group.name.downcase
members_visible = members_visible_group_ids.include?(group.id)
serialized_group = members_visible ? { user_count: group.user_count } : {}
if group_reasons[name] == :not_allowed && members_visible &&
(@topic&.private_message? || @allowed_names.present?)
notified_count = already_notified_member_count(group)
if notified_count > 0
if notified_count == group.user_count
group_reasons.delete(name)
else
group_reasons[name] = :some_not_allowed
serialized_group[:notified_count] = notified_count
end
end
end
hash[name] = serialized_group
end
render json: {
users: users.keys,
user_reasons: user_reasons,
groups: serialized_groups,
group_reasons: group_reasons,
here_count: here_count,
max_users_notified_per_group_mention: SiteSetting.max_users_notified_per_group_mention,
}
end
private
def user_reason(user)
reason =
if @topic && !user.guardian.can_see?(@topic)
@topic.private_message? ? :private : :category
elsif @allowed_names.present? &&
!is_user_allowed?(user, topic_allowed_user_ids, topic_allowed_group_ids)
# This would normally be handled by the previous if, but that does not work for new private messages.
:private
elsif topic_muted_by.include?(user.id)
:muted_topic
elsif @topic&.private_message? &&
!is_user_allowed?(user, topic_allowed_user_ids, topic_allowed_group_ids)
# Admins can see the topic, but they will not be mentioned if they were not invited.
:not_allowed
end
# Non-staff users can see only basic information why the users cannot see the topic.
reason = nil if !guardian.is_staff? && reason != :private && reason != :category
DiscoursePluginRegistry.apply_modifier(:composer_mention_user_reason, reason, user)
end
def group_reason(group)
if !mentionable_group_ids.include?(group.id)
:not_mentionable
elsif (@topic&.private_message? || @allowed_names.present?) &&
!topic_allowed_group_ids.include?(group.id)
:not_allowed
end
end
def already_notified_member_count(group)
GroupUser
.where(user_id: topic_allowed_user_ids)
.or(
GroupUser.where(
user_id: GroupUser.where(group_id: topic_allowed_group_ids).select(:user_id),
),
)
.where(group:)
.select(:user_id)
.distinct
.count
end
def is_user_allowed?(user, user_ids, group_ids)
user_ids.include?(user.id) ||
user.group_ids.any? do |group_id|
group_ids.include?(group_id) && visible_group_ids_for_allowed_check.include?(group_id)
end
end
def visible_group_ids_for_allowed_check
@visible_group_ids_for_allowed_check ||=
if @allowed_names.present?
Group
.members_visible_groups(current_user)
.where("LOWER(name) IN (?)", @allowed_names)
.pluck(:id)
.to_set
else
topic_allowed_group_ids || Set.new
end
end
def users
@users ||= User.not_staged.where(username_lower: @names).index_by(&:username_lower)
end
def groups
@groups ||=
Group
.visible_groups(current_user)
.where("LOWER(name) IN (?)", @names)
.index_by { |g| g.name.downcase }
end
def mentionable_group_ids
@mentionable_group_ids ||=
Group
.mentionable(current_user, include_public: false)
.where("LOWER(name) IN (?)", @names)
.pluck(:id)
.to_set
end
def members_visible_group_ids
@members_visible_group_ids ||=
Group
.members_visible_groups(current_user)
.where("LOWER(name) IN (?)", @names)
.pluck(:id)
.to_set
end
def topic_muted_by
@topic_muted_by ||=
if @topic.present?
TopicUser
.where(topic: @topic)
.where(user_id: users.values.map(&:id))
.where(notification_level: TopicUser.notification_levels[:muted])
.pluck(:user_id)
.to_set
else
Set.new
end
end
def topic_allowed_user_ids
@topic_allowed_user_ids ||=
if @allowed_names.present?
User.where(username_lower: @allowed_names).pluck(:id).to_set
elsif @topic&.private_message?
TopicAllowedUser.where(topic: @topic).pluck(:user_id).to_set
end
end
def topic_allowed_group_ids
@topic_allowed_group_ids ||=
if @allowed_names.present?
Group
.messageable(current_user)
.where("LOWER(name) IN (?)", @allowed_names)
.pluck(:id)
.to_set
elsif @topic&.private_message?
TopicAllowedGroup.where(topic: @topic).pluck(:group_id).to_set
end
end
end