mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 12:13:58 -06:00
f122f24b35
Currently, `Tag#topic_count` is a count of all regular topics regardless of whether the topic is in a read restricted category or not. As a result, any users can technically poll a sensitive tag to determine if a new topic is created in a category which the user has not excess to. We classify this as a minor leak in sensitive information. The following changes are introduced in this commit: 1. Introduce `Tag#public_topic_count` which only count topics which have been tagged with a given tag in public categories. 2. Rename `Tag#topic_count` to `Tag#staff_topic_count` which counts the same way as `Tag#topic_count`. In other words, it counts all topics tagged with a given tag regardless of the category the topic is in. The rename is also done so that we indicate that this column contains sensitive information. 3. Change all previous spots which relied on `Topic#topic_count` to rely on `Tag.topic_column_count(guardian)` which will return the right "topic count" column to use based on the current scope. 4. Introduce `SiteSetting.include_secure_categories_in_tag_counts` site setting to allow site administrators to always display the tag topics count using `Tag#staff_topic_count` instead.
270 lines
8.3 KiB
Ruby
270 lines
8.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Tag < ActiveRecord::Base
|
|
include Searchable
|
|
include HasDestroyedWebHook
|
|
|
|
self.ignored_columns = [
|
|
"topic_count", # TODO(tgxworld): Remove on 1 July 2023
|
|
]
|
|
|
|
RESERVED_TAGS = [
|
|
"none",
|
|
"constructor", # prevents issues with javascript's constructor of objects
|
|
]
|
|
|
|
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
|
|
|
validate :target_tag_validator,
|
|
if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? }
|
|
validate :name_validator
|
|
validates :description, length: { maximum: 280 }
|
|
|
|
scope :where_name,
|
|
->(name) {
|
|
name = Array(name).map(&:downcase)
|
|
where("lower(tags.name) IN (?)", name)
|
|
}
|
|
|
|
# tags that have never been used and don't belong to a tag group
|
|
scope :unused,
|
|
-> {
|
|
where(staff_topic_count: 0, pm_topic_count: 0).joins(
|
|
"LEFT JOIN tag_group_memberships tgm ON tags.id = tgm.tag_id",
|
|
).where("tgm.tag_id IS NULL")
|
|
}
|
|
|
|
scope :used_tags_in_regular_topics,
|
|
->(guardian) { where("tags.#{Tag.topic_count_column(guardian)} > 0") }
|
|
|
|
scope :base_tags, -> { where(target_tag_id: nil) }
|
|
|
|
has_many :tag_users, dependent: :destroy # notification settings
|
|
|
|
has_many :topic_tags, dependent: :destroy
|
|
has_many :topics, through: :topic_tags
|
|
|
|
has_many :category_tag_stats, dependent: :destroy
|
|
has_many :category_tags, dependent: :destroy
|
|
has_many :categories, through: :category_tags
|
|
|
|
has_many :tag_group_memberships, dependent: :destroy
|
|
has_many :tag_groups, through: :tag_group_memberships
|
|
|
|
belongs_to :target_tag, class_name: "Tag", optional: true
|
|
has_many :synonyms, class_name: "Tag", foreign_key: "target_tag_id", dependent: :destroy
|
|
has_many :sidebar_section_links, as: :linkable, dependent: :delete_all
|
|
|
|
after_save :index_search
|
|
after_save :update_synonym_associations
|
|
|
|
after_commit :trigger_tag_created_event, on: :create
|
|
after_commit :trigger_tag_updated_event, on: :update
|
|
after_commit :trigger_tag_destroyed_event, on: :destroy
|
|
|
|
def self.ensure_consistency!
|
|
update_topic_counts
|
|
end
|
|
|
|
def self.update_topic_counts
|
|
DB.exec <<~SQL
|
|
UPDATE tags t
|
|
SET staff_topic_count = x.topic_count
|
|
FROM (
|
|
SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id
|
|
FROM tags
|
|
LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id
|
|
LEFT JOIN topics ON topics.id = topic_tags.topic_id
|
|
AND topics.deleted_at IS NULL
|
|
AND topics.archetype != 'private_message'
|
|
GROUP BY tags.id
|
|
) x
|
|
WHERE x.tag_id = t.id
|
|
AND x.topic_count <> t.staff_topic_count
|
|
SQL
|
|
|
|
DB.exec <<~SQL
|
|
UPDATE tags t
|
|
SET public_topic_count = x.topic_count
|
|
FROM (
|
|
WITH tags_with_public_topics AS (
|
|
SELECT
|
|
COUNT(topics.id) AS topic_count,
|
|
tags.id AS tag_id
|
|
FROM tags
|
|
INNER JOIN topic_tags ON tags.id = topic_tags.tag_id
|
|
INNER JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message'
|
|
INNER JOIN categories ON categories.id = topics.category_id AND NOT categories.read_restricted
|
|
GROUP BY tags.id
|
|
)
|
|
SELECT
|
|
COALESCE(tags_with_public_topics.topic_count, 0 ) AS topic_count,
|
|
tags.id AS tag_id
|
|
FROM tags
|
|
LEFT JOIN tags_with_public_topics ON tags_with_public_topics.tag_id = tags.id
|
|
) x
|
|
WHERE x.tag_id = t.id
|
|
AND x.topic_count <> t.public_topic_count;
|
|
SQL
|
|
|
|
DB.exec <<~SQL
|
|
UPDATE tags t
|
|
SET pm_topic_count = x.pm_topic_count
|
|
FROM (
|
|
SELECT COUNT(topics.id) AS pm_topic_count, tags.id AS tag_id
|
|
FROM tags
|
|
LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id
|
|
LEFT JOIN topics ON topics.id = topic_tags.topic_id
|
|
AND topics.deleted_at IS NULL
|
|
AND topics.archetype = 'private_message'
|
|
GROUP BY tags.id
|
|
) x
|
|
WHERE x.tag_id = t.id
|
|
AND x.pm_topic_count <> t.pm_topic_count
|
|
SQL
|
|
end
|
|
|
|
def self.find_by_name(name)
|
|
self.find_by("lower(name) = ?", name.downcase)
|
|
end
|
|
|
|
def self.top_tags(limit_arg: nil, category: nil, guardian: Guardian.new)
|
|
# we add 1 to max_tags_in_filter_list to efficiently know we have more tags
|
|
# than the limit. Frontend is responsible to enforce limit.
|
|
limit = limit_arg || (SiteSetting.max_tags_in_filter_list + 1)
|
|
scope_category_ids = guardian.allowed_category_ids
|
|
scope_category_ids &= ([category.id] + category.subcategories.pluck(:id)) if category
|
|
|
|
return [] if scope_category_ids.empty?
|
|
|
|
filter_sql =
|
|
(
|
|
if guardian.is_staff?
|
|
""
|
|
else
|
|
" AND tags.id IN (#{DiscourseTagging.visible_tags(guardian).select(:id).to_sql})"
|
|
end
|
|
)
|
|
|
|
tag_names_with_counts = DB.query <<~SQL
|
|
SELECT tags.name as tag_name, SUM(stats.topic_count) AS sum_topic_count
|
|
FROM category_tag_stats stats
|
|
JOIN tags ON stats.tag_id = tags.id AND stats.topic_count > 0
|
|
WHERE stats.category_id in (#{scope_category_ids.join(",")})
|
|
#{filter_sql}
|
|
GROUP BY tags.name
|
|
ORDER BY sum_topic_count DESC, tag_name ASC
|
|
LIMIT #{limit}
|
|
SQL
|
|
|
|
tag_names_with_counts.map { |row| row.tag_name }
|
|
end
|
|
|
|
def self.topic_count_column(guardian)
|
|
if guardian&.is_staff? || SiteSetting.include_secure_categories_in_tag_counts
|
|
"staff_topic_count"
|
|
else
|
|
"public_topic_count"
|
|
end
|
|
end
|
|
|
|
def self.pm_tags(limit: 1000, guardian: nil, allowed_user: nil)
|
|
return [] if allowed_user.blank? || !(guardian || Guardian.new).can_tag_pms?
|
|
user_id = allowed_user.id
|
|
|
|
DB.query_hash(<<~SQL).map!(&:symbolize_keys!)
|
|
SELECT tags.name as id, tags.name as text, COUNT(topics.id) AS count
|
|
FROM tags
|
|
JOIN topic_tags ON tags.id = topic_tags.tag_id
|
|
JOIN topics ON topics.id = topic_tags.topic_id
|
|
AND topics.deleted_at IS NULL
|
|
AND topics.archetype = 'private_message'
|
|
WHERE topic_tags.topic_id IN (
|
|
SELECT topic_id
|
|
FROM topic_allowed_users
|
|
WHERE user_id = #{user_id.to_i}
|
|
UNION
|
|
SELECT tg.topic_id
|
|
FROM topic_allowed_groups tg
|
|
JOIN group_users gu ON gu.user_id = #{user_id.to_i}
|
|
AND gu.group_id = tg.group_id
|
|
)
|
|
GROUP BY tags.name
|
|
ORDER BY count DESC
|
|
LIMIT #{limit.to_i}
|
|
SQL
|
|
end
|
|
|
|
def self.include_tags?
|
|
SiteSetting.tagging_enabled
|
|
end
|
|
|
|
def url
|
|
"#{Discourse.base_path}/tag/#{UrlHelper.encode_component(self.name)}"
|
|
end
|
|
|
|
def full_url
|
|
"#{Discourse.base_url}/tag/#{UrlHelper.encode_component(self.name)}"
|
|
end
|
|
|
|
def index_search
|
|
SearchIndexer.index(self)
|
|
end
|
|
|
|
def synonym?
|
|
!self.target_tag_id.nil?
|
|
end
|
|
|
|
def target_tag_validator
|
|
if synonyms.exists?
|
|
errors.add(:target_tag_id, I18n.t("tags.synonyms_exist"))
|
|
elsif target_tag&.synonym?
|
|
errors.add(:target_tag_id, I18n.t("tags.invalid_target_tag"))
|
|
end
|
|
end
|
|
|
|
def update_synonym_associations
|
|
if target_tag_id && saved_change_to_target_tag_id?
|
|
target_tag.tag_groups.each do |tag_group|
|
|
tag_group.tags << self unless tag_group.tags.include?(self)
|
|
end
|
|
target_tag.categories.each do |category|
|
|
category.tags << self unless category.tags.include?(self)
|
|
end
|
|
end
|
|
end
|
|
|
|
%i[tag_created tag_updated tag_destroyed].each do |event|
|
|
define_method("trigger_#{event}_event") do
|
|
DiscourseEvent.trigger(event, self)
|
|
true
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def name_validator
|
|
errors.add(:name, :invalid) if name.present? && RESERVED_TAGS.include?(self.name.strip.downcase)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: tags
|
|
#
|
|
# id :integer not null, primary key
|
|
# name :string not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# pm_topic_count :integer default(0), not null
|
|
# target_tag_id :integer
|
|
# description :string
|
|
# public_topic_count :integer default(0), not null
|
|
# staff_topic_count :integer default(0), not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_tags_on_lower_name (lower((name)::text)) UNIQUE
|
|
# index_tags_on_name (name) UNIQUE
|
|
#
|