mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 02:40:53 -06:00
30990006a9
This reduces chances of errors where consumers of strings mutate inputs and reduces memory usage of the app. Test suite passes now, but there may be some stuff left, so we will run a few sites on a branch prior to merging
306 lines
11 KiB
Ruby
306 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscourseTagging
|
|
|
|
TAGS_FIELD_NAME = "tags"
|
|
TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<>
|
|
TAGS_STAFF_CACHE_KEY = "staff_tag_names"
|
|
|
|
TAG_GROUP_TAG_IDS_SQL = <<-SQL
|
|
SELECT tag_id
|
|
FROM tag_group_memberships tgm
|
|
INNER JOIN tag_groups tg
|
|
ON tgm.tag_group_id = tg.id
|
|
SQL
|
|
|
|
def self.tag_topic_by_names(topic, guardian, tag_names_arg, append: false)
|
|
if guardian.can_tag?(topic)
|
|
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || []
|
|
|
|
old_tag_names = topic.tags.pluck(:name) || []
|
|
new_tag_names = tag_names - old_tag_names
|
|
removed_tag_names = old_tag_names - tag_names
|
|
|
|
# Protect staff-only tags
|
|
unless guardian.is_staff?
|
|
all_staff_tags = DiscourseTagging.staff_tag_names
|
|
hidden_tags = DiscourseTagging.hidden_tag_names
|
|
|
|
staff_tags = new_tag_names & all_staff_tags
|
|
staff_tags += new_tag_names & hidden_tags
|
|
if staff_tags.present?
|
|
topic.errors.add(:base, I18n.t("tags.staff_tag_disallowed", tag: staff_tags.join(" ")))
|
|
return false
|
|
end
|
|
|
|
staff_tags = removed_tag_names & all_staff_tags
|
|
if staff_tags.present?
|
|
topic.errors.add(:base, I18n.t("tags.staff_tag_remove_disallowed", tag: staff_tags.join(" ")))
|
|
return false
|
|
end
|
|
|
|
tag_names += removed_tag_names & hidden_tags
|
|
end
|
|
|
|
category = topic.category
|
|
tag_names = tag_names + old_tag_names if append
|
|
|
|
if tag_names.present?
|
|
# guardian is explicitly nil cause we don't want to strip all
|
|
# staff tags that already passed validation
|
|
tags = filter_allowed_tags(
|
|
Tag.where_name(tag_names),
|
|
nil, # guardian
|
|
for_topic: true,
|
|
category: category,
|
|
selected_tags: tag_names
|
|
).to_a
|
|
|
|
if tags.size < tag_names.size && (category.nil? || (category.tags.count == 0 && category.tag_groups.count == 0))
|
|
tag_names.each do |name|
|
|
unless Tag.where_name(name).exists?
|
|
tags << Tag.create(name: name)
|
|
end
|
|
end
|
|
end
|
|
|
|
# add missing mandatory parent tags
|
|
parent_tags = TagGroup.includes(:parent_tag).where("tag_groups.id IN (
|
|
SELECT tg.id
|
|
FROM tag_groups tg
|
|
INNER JOIN tag_group_memberships tgm
|
|
ON tgm.tag_group_id = tg.id
|
|
WHERE tg.parent_tag_id IS NOT NULL
|
|
AND tgm.tag_id IN (?))", tags.map(&:id)).map(&:parent_tag)
|
|
|
|
parent_tags.reject { |t| tag_names.include?(t.name) }.each do |tag|
|
|
tags << tag
|
|
end
|
|
|
|
# validate minimum required tags for a category
|
|
if !guardian.is_staff? && category && category.minimum_required_tags > 0 && tags.length < category.minimum_required_tags
|
|
topic.errors.add(:base, I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags))
|
|
return false
|
|
end
|
|
|
|
topic.tags = tags
|
|
else
|
|
# validate minimum required tags for a category
|
|
if !guardian.is_staff? && category && category.minimum_required_tags > 0
|
|
topic.errors.add(:base, I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags))
|
|
return false
|
|
end
|
|
|
|
topic.tags = []
|
|
end
|
|
topic.tags_changed = true
|
|
end
|
|
true
|
|
end
|
|
|
|
# Options:
|
|
# term: a search term to filter tags by name
|
|
# category: a Category to which the object being tagged belongs
|
|
# for_input: result is for an input field, so only show permitted tags
|
|
# for_topic: results are for tagging a topic
|
|
# selected_tags: an array of tag names that are in the current selection
|
|
def self.filter_allowed_tags(query, guardian, opts = {})
|
|
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
|
|
|
|
if !opts[:for_topic] && !selected_tag_ids.empty?
|
|
query = query.where('tags.id NOT IN (?)', selected_tag_ids)
|
|
end
|
|
|
|
term = opts[:term]
|
|
if term.present?
|
|
term = term.gsub("_", "\\_")
|
|
clean_tag(term)
|
|
term.downcase!
|
|
query = query.where('lower(tags.name) like ?', "%#{term}%")
|
|
end
|
|
|
|
# Filters for category-specific tags:
|
|
category = opts[:category]
|
|
|
|
if category && (category.tags.count > 0 || category.tag_groups.count > 0)
|
|
if category.allow_global_tags
|
|
# Select tags that:
|
|
# * are restricted to the given category
|
|
# * belong to no tag groups and aren't restricted to other categories
|
|
# * belong to tag groups that are not restricted to any categories
|
|
query = query.where(<<~SQL, category.tag_groups.pluck(:id), category.id)
|
|
tags.id IN (
|
|
SELECT t.id FROM tags t
|
|
LEFT JOIN category_tags ct ON t.id = ct.tag_id
|
|
LEFT JOIN (
|
|
SELECT xtgm.tag_id, xtgm.tag_group_id
|
|
FROM tag_group_memberships xtgm
|
|
INNER JOIN category_tag_groups ctg
|
|
ON xtgm.tag_group_id = ctg.tag_group_id
|
|
) AS tgm ON t.id = tgm.tag_id
|
|
WHERE (tgm.tag_group_id IS NULL AND ct.category_id IS NULL)
|
|
OR tgm.tag_group_id IN (?)
|
|
OR ct.category_id = ?
|
|
)
|
|
SQL
|
|
else
|
|
# Select only tags that are restricted to the given category
|
|
query = query.where(<<~SQL, category.id, category.tag_groups.pluck(:id))
|
|
tags.id IN (
|
|
SELECT tag_id FROM category_tags WHERE category_id = ?
|
|
UNION
|
|
SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?)
|
|
)
|
|
SQL
|
|
end
|
|
elsif opts[:for_input] || opts[:for_topic] || category
|
|
# exclude tags that are restricted to other categories
|
|
if CategoryTag.exists?
|
|
query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)")
|
|
end
|
|
|
|
if CategoryTagGroup.exists?
|
|
tag_group_ids = CategoryTagGroup.pluck(:tag_group_id).uniq
|
|
query = query.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))", tag_group_ids)
|
|
end
|
|
end
|
|
|
|
if opts[:for_input] || opts[:for_topic]
|
|
unless guardian.nil? || guardian.is_staff?
|
|
all_staff_tags = DiscourseTagging.staff_tag_names
|
|
query = query.where('tags.name NOT IN (?)', all_staff_tags) if all_staff_tags.present?
|
|
end
|
|
end
|
|
|
|
if opts[:for_input]
|
|
# exclude tag groups that have a parent tag which is missing from selected_tags
|
|
|
|
if selected_tag_ids.empty?
|
|
sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id IS NOT NULL)"
|
|
query = query.where(sql)
|
|
else
|
|
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
|
|
|
|
if exclude_group_ids.empty?
|
|
sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id NOT IN (?))"
|
|
query = query.where(sql, selected_tag_ids)
|
|
else
|
|
# It's possible that the selected tags violate some one-tag-per-group restrictions,
|
|
# so filter them out by picking one from each group.
|
|
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
|
|
.where(tag_id: selected_tag_ids)
|
|
.where(tag_group_id: exclude_group_ids)
|
|
.map(&:tag_id)
|
|
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.parent_tag_id NOT IN (?) OR tg.id in (?))) OR tags.id IN (?))"
|
|
query = query.where(sql, selected_tag_ids, exclude_group_ids, limit_tag_ids)
|
|
end
|
|
end
|
|
elsif opts[:for_topic] && !selected_tag_ids.empty?
|
|
# One tag per group restriction
|
|
exclude_group_ids = one_per_topic_group_ids(selected_tag_ids)
|
|
|
|
unless exclude_group_ids.empty?
|
|
limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id')
|
|
.where(tag_id: selected_tag_ids)
|
|
.where(tag_group_id: exclude_group_ids)
|
|
.map(&:tag_id)
|
|
sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.id in (?))) OR tags.id IN (?))"
|
|
query = query.where(sql, exclude_group_ids, limit_tag_ids)
|
|
end
|
|
end
|
|
|
|
if guardian.nil? || guardian.is_staff?
|
|
query
|
|
else
|
|
filter_visible(query, guardian)
|
|
end
|
|
end
|
|
|
|
def self.one_per_topic_group_ids(selected_tag_ids)
|
|
TagGroup.where(one_per_topic: true)
|
|
.joins(:tag_group_memberships)
|
|
.where('tag_group_memberships.tag_id in (?)', selected_tag_ids)
|
|
.pluck(:id)
|
|
end
|
|
|
|
def self.filter_visible(query, guardian = nil)
|
|
guardian&.is_staff? ? query : query.where("tags.id NOT IN (#{hidden_tags_query.select(:id).to_sql})")
|
|
end
|
|
|
|
def self.hidden_tag_names(guardian = nil)
|
|
guardian&.is_staff? ? [] : hidden_tags_query.pluck(:name)
|
|
end
|
|
|
|
def self.hidden_tags_query
|
|
Tag.joins(:tag_groups)
|
|
.where('tag_groups.id NOT IN (
|
|
SELECT tag_group_id
|
|
FROM tag_group_permissions
|
|
WHERE group_id = ?)',
|
|
Group::AUTO_GROUPS[:everyone]
|
|
)
|
|
end
|
|
|
|
def self.staff_tag_names
|
|
tag_names = Discourse.cache.read(TAGS_STAFF_CACHE_KEY, tag_names)
|
|
|
|
if !tag_names
|
|
tag_names = Tag.joins(tag_groups: :tag_group_permissions)
|
|
.where('tag_group_permissions.group_id = ? AND tag_group_permissions.permission_type = ?',
|
|
Group::AUTO_GROUPS[:everyone],
|
|
TagGroupPermission.permission_types[:readonly]
|
|
).pluck(:name)
|
|
Discourse.cache.write(TAGS_STAFF_CACHE_KEY, tag_names, expires_in: 1.hour)
|
|
end
|
|
|
|
tag_names
|
|
end
|
|
|
|
def self.clear_cache!
|
|
Discourse.cache.delete(TAGS_STAFF_CACHE_KEY)
|
|
end
|
|
|
|
def self.clean_tag(tag)
|
|
tag = tag.dup
|
|
tag.downcase! if SiteSetting.force_lowercase_tags
|
|
tag.strip!
|
|
tag.gsub!(/\s+/, '-')
|
|
tag.squeeze!('-')
|
|
tag.gsub!(TAGS_FILTER_REGEXP, '')
|
|
tag[0...SiteSetting.max_tag_length]
|
|
end
|
|
|
|
def self.tags_for_saving(tags_arg, guardian, opts = {})
|
|
|
|
return [] unless guardian.can_tag_topics? && tags_arg.present?
|
|
|
|
tag_names = Tag.where_name(tags_arg).pluck(:name)
|
|
|
|
if guardian.can_create_tag?
|
|
tag_names += (tags_arg - tag_names).map { |t| clean_tag(t) }
|
|
tag_names.delete_if { |t| t.blank? }
|
|
tag_names.uniq!
|
|
end
|
|
|
|
return opts[:unlimited] ? tag_names : tag_names[0...SiteSetting.max_tags_per_topic]
|
|
end
|
|
|
|
def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {})
|
|
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || []
|
|
if taggable.tags.pluck(:name).sort != tag_names.sort
|
|
taggable.tags = Tag.where_name(tag_names).all
|
|
if taggable.tags.size < tag_names.size
|
|
new_tag_names = tag_names - taggable.tags.map(&:name)
|
|
new_tag_names.each do |name|
|
|
taggable.tags << Tag.create(name: name)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.muted_tags(user)
|
|
return [] unless user
|
|
TagUser.lookup(user, :muted).joins(:tag).pluck('tags.name')
|
|
end
|
|
end
|