2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/app/models/user_summary.rb
Régis Hanol 06975167e5
FIX: Respect permissions when counting bookmarks in user summary (#36856)
Previously, `UserSummary#bookmark_count` used a simple database count
that included all bookmarks regardless of whether the user could still
access the bookmarked content. This led to inflated counts when:

- Topics were moved to private categories the user can't access
- Topics or posts were soft-deleted
- Access was revoked for any other reason

The fix delegates counting to `BookmarkQuery#count_all`, which applies
the same permission checks used when listing bookmarks. This ensures
the count shown on user profiles matches what users can actually see.

To avoid duplicating query logic, the existing `list_all` method was
refactored to extract `build_list_queries` as a shared helper. The
`unread_notifications` method was also cleaned up by breaking out
several smaller private methods for better readability.

Ref - https://meta.discourse.org/t/391822
2026-01-21 16:34:55 +01:00

241 lines
5.8 KiB
Ruby

# frozen_string_literal: true
# ViewModel used on Summary tab on User page
class UserSummary
MAX_SUMMARY_RESULTS = 6
MAX_BADGES = 6
alias read_attribute_for_serialization send
def initialize(user, guardian)
@user = user
@guardian = guardian
end
def topics
Topic
.secured(@guardian)
.listable_topics
.visible
.where(user: @user)
.order("like_count DESC, created_at DESC")
.limit(MAX_SUMMARY_RESULTS)
end
def replies
post_query
.where("post_number > 1")
.order("posts.like_count DESC, posts.created_at DESC")
.limit(MAX_SUMMARY_RESULTS)
end
def links
TopicLink
.joins(:topic, :post)
.where(posts: { user_id: @user.id, hidden: false })
.includes(:topic, :post)
.where("posts.post_type IN (?)", Topic.visible_post_types(@guardian && @guardian.user))
.merge(Topic.listable_topics.visible.secured(@guardian))
.where(user: @user)
.where(internal: false, reflection: false, quote: false)
.order("clicks DESC, topic_links.created_at DESC")
.limit(MAX_SUMMARY_RESULTS)
end
class UserWithCount < OpenStruct
include ActiveModel::SerializerSupport
end
def most_liked_by_users
likers = {}
UserAction
.joins(:target_topic, :target_post)
.merge(Topic.listable_topics.visible.secured(@guardian))
.where(user: @user)
.where(action_type: UserAction::WAS_LIKED)
.group(:acting_user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("acting_user_id, COUNT(*)")
.each { |l| likers[l[0]] = l[1] }
user_counts(likers)
end
def most_liked_users
liked_users = {}
UserAction
.joins(:target_topic, :target_post)
.merge(Topic.listable_topics.visible.secured(@guardian))
.where(action_type: UserAction::WAS_LIKED)
.where(acting_user_id: @user.id)
.group(:user_id)
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("user_actions.user_id, COUNT(*)")
.each { |l| liked_users[l[0]] = l[1] }
user_counts(liked_users)
end
REPLY_ACTIONS = [UserAction::RESPONSE, UserAction::QUOTE, UserAction::MENTION]
def most_replied_to_users
replied_users = {}
post_query
.joins(
"JOIN posts replies ON posts.topic_id = replies.topic_id AND posts.reply_to_post_number = replies.post_number",
)
.joins(
"JOIN topics ON replies.topic_id = topics.id AND topics.archetype <> 'private_message'",
)
.joins(
"AND replies.post_type IN (#{Topic.visible_post_types(@user, include_moderator_actions: false).join(",")})",
)
.where("replies.user_id <> posts.user_id")
.group("replies.user_id")
.order("COUNT(*) DESC")
.limit(MAX_SUMMARY_RESULTS)
.pluck("replies.user_id, COUNT(*)")
.each { |r| replied_users[r[0]] = r[1] }
user_counts(replied_users)
end
def badges
@user.featured_user_badges(MAX_BADGES)
end
def user_id
@user.id
end
def user
@user
end
def user_stat
@user.user_stat
end
def bookmark_count
BookmarkQuery.new(user: @user, guardian: @guardian).count_all
end
def recent_time_read
@user.recent_time_read
end
class CategoryWithCounts < OpenStruct
include ActiveModel::SerializerSupport
KEYS = %i[
id
name
color
text_color
style_type
icon
emoji
slug
read_restricted
parent_category_id
]
end
def top_categories
post_count_query = post_query.group("topics.category_id")
top_categories = {}
Category
.where(
id: post_count_query.order("count(*) DESC").limit(MAX_SUMMARY_RESULTS).pluck("category_id"),
)
.pluck(
:id,
:name,
:color,
:text_color,
:style_type,
:icon,
:emoji,
:slug,
:read_restricted,
:parent_category_id,
)
.each do |c|
top_categories[c[0].to_i] = CategoryWithCounts.new(
Hash[CategoryWithCounts::KEYS.zip(c)].merge(topic_count: 0, post_count: 0),
)
end
post_count_query
.where("post_number > 1")
.where("topics.category_id in (?)", top_categories.keys)
.pluck("category_id, COUNT(*)")
.each { |r| top_categories[r[0].to_i].post_count = r[1] }
Topic
.listable_topics
.visible
.secured(@guardian)
.where("topics.category_id in (?)", top_categories.keys)
.where(user: @user)
.group("topics.category_id")
.pluck("category_id, COUNT(*)")
.each { |r| top_categories[r[0].to_i].topic_count = r[1] }
top_categories.values.sort_by { |r| -(r[:post_count] + r[:topic_count]) }
end
delegate :likes_given,
:likes_received,
:days_visited,
:topics_entered,
:posts_read_count,
:topic_count,
:post_count,
:time_read,
to: :user_stat
protected
def user_counts(user_hash)
user_ids = user_hash.keys
lookup = UserLookup.new(user_ids)
user_ids
.map do |user_id|
lookup_hash = lookup[user_id]
if lookup_hash.present?
primary_group = lookup.primary_groups[user_id]
flair_group = lookup.flair_groups[user_id]
UserWithCount.new(
lookup_hash.attributes.merge(
count: user_hash[user_id],
primary_group: primary_group,
flair_group: flair_group,
),
)
end
end
.compact
.sort_by { |u| -u[:count] }
end
def post_query
Post
.joins(:topic)
.includes(:topic)
.where(
"posts.post_type IN (?)",
Topic.visible_post_types(@guardian&.user, include_moderator_actions: false),
)
.merge(Topic.listable_topics.visible.secured(@guardian))
.where(user: @user)
end
end