0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-07 13:19:19 +08:00
discourse/app/controllers/composer_controller.rb
Natalie Tay 4dcdf09317
SECURITY: Respect group member visibility for counts [backport 2026.1] (#41420)
Backport of #41403 to release/2026.1.

---

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:47:49 +08:00

194 lines
6.1 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
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?
raise Discourse.InvalidParameters(:allowed_names) if !params[:allowed_names].is_a?(Array)
params[:allowed_names] << current_user.username
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) && 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
.reduce({}) do |hash, group|
members_visible = members_visible_group_ids.include?(group.id)
serialized_group = members_visible ? { user_count: group.user_count } : {}
if group_reasons[group.name] == :not_allowed && members_visible &&
(@topic&.private_message? || @allowed_names.present?)
# Find users that are notified already because they have been invited
# directly or via a group
notified_count =
GroupUser
# invited directly
.where(user_id: topic_allowed_user_ids)
.or(
# invited via a group
GroupUser.where(
user_id: GroupUser.where(group_id: topic_allowed_group_ids).select(:user_id),
),
)
.where(group_id: group.id)
.select(:user_id)
.distinct
.count
if notified_count > 0
if notified_count == group.user_count
group_reasons.delete(group.name)
else
group_reasons[group.name] = :some_not_allowed
serialized_group[:notified_count] = notified_count
end
end
end
hash[group.name] = serialized_group
hash
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
reason
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 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(name: @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.map(&:downcase)).index_by(&:username_lower)
end
def groups
@groups ||=
Group
.visible_groups(current_user)
.where("lower(name) IN (?)", @names.map(&:downcase))
.index_by(&:name)
end
def mentionable_group_ids
@mentionable_group_ids ||=
Group.mentionable(current_user, include_public: false).where(name: @names).pluck(:id).to_set
end
def members_visible_group_ids
@members_visible_group_ids ||=
Group.members_visible_groups(current_user).where(name: @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.map(&:downcase)).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(name: @allowed_names).pluck(:id).to_set
elsif @topic&.private_message?
TopicAllowedGroup.where(topic: @topic).pluck(:group_id).to_set
end
end
end