mirror of
https://github.com/discourse/discourse.git
synced 2024-12-19 16:16:08 +08:00
5b19e2ca0f
The hierarchical search for categories is composed of several complex nested queries. This change ensures that the secured categories are filtered out as soon as possible to ensure that the default limit of 5 categories is reached. Without this fix, the search can return less than 5 categories if any of the first 5 categories cannot be displayed due to permissions.
682 lines
21 KiB
Ruby
682 lines
21 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class CategoriesController < ApplicationController
|
|
include TopicQueryParams
|
|
|
|
requires_login except: %i[
|
|
index
|
|
categories_and_latest
|
|
categories_and_top
|
|
categories_and_hot
|
|
show
|
|
redirect
|
|
find_by_slug
|
|
visible_groups
|
|
find
|
|
search
|
|
]
|
|
|
|
before_action :fetch_category, only: %i[show update destroy visible_groups]
|
|
before_action :initialize_staff_action_logger, only: %i[create update destroy]
|
|
skip_before_action :check_xhr,
|
|
only: %i[
|
|
index
|
|
categories_and_latest
|
|
categories_and_top
|
|
categories_and_hot
|
|
redirect
|
|
]
|
|
skip_before_action :verify_authenticity_token, only: %i[search]
|
|
|
|
SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR = 1.5
|
|
MIN_CATEGORIES_TOPICS = 5
|
|
MAX_CATEGORIES_LIMIT = 25
|
|
|
|
def redirect
|
|
return if handle_permalink("/category/#{params[:path]}")
|
|
redirect_to path("/c/#{params[:path]}")
|
|
end
|
|
|
|
def index
|
|
discourse_expires_in 1.minute
|
|
|
|
@category_list = fetch_category_list
|
|
|
|
respond_to do |format|
|
|
format.html do
|
|
@title =
|
|
if current_homepage == "categories" && SiteSetting.short_site_description.present?
|
|
"#{SiteSetting.title} - #{SiteSetting.short_site_description}"
|
|
elsif current_homepage != "categories"
|
|
"#{I18n.t("js.filters.categories.title")} - #{SiteSetting.title}"
|
|
end
|
|
|
|
@description = SiteSetting.site_description
|
|
|
|
store_preloaded(
|
|
@category_list.preload_key,
|
|
MultiJson.dump(CategoryListSerializer.new(@category_list, scope: guardian)),
|
|
)
|
|
|
|
@topic_list = fetch_topic_list
|
|
|
|
if @topic_list.present? && @topic_list.topics.present?
|
|
store_preloaded(
|
|
@topic_list.preload_key,
|
|
MultiJson.dump(TopicListSerializer.new(@topic_list, scope: guardian)),
|
|
)
|
|
end
|
|
|
|
render
|
|
end
|
|
|
|
format.json { render_serialized(@category_list, CategoryListSerializer) }
|
|
end
|
|
end
|
|
|
|
def categories_and_latest
|
|
categories_and_topics(:latest)
|
|
end
|
|
|
|
def categories_and_top
|
|
categories_and_topics(:top)
|
|
end
|
|
|
|
def categories_and_hot
|
|
categories_and_topics(:hot)
|
|
end
|
|
|
|
def move
|
|
guardian.ensure_can_create_category!
|
|
|
|
params.require("category_id")
|
|
params.require("position")
|
|
|
|
if category = Category.find(params["category_id"])
|
|
category.move_to(params["position"].to_i)
|
|
render json: success_json
|
|
else
|
|
render status: 500, json: failed_json
|
|
end
|
|
end
|
|
|
|
def reorder
|
|
guardian.ensure_can_create_category!
|
|
|
|
params.require(:mapping)
|
|
change_requests = MultiJson.load(params[:mapping])
|
|
by_category = Hash[change_requests.map { |cat, pos| [Category.find(cat.to_i), pos] }]
|
|
|
|
unless guardian.is_admin?
|
|
unless by_category.keys.all? { |c| guardian.can_see_category? c }
|
|
raise Discourse::InvalidAccess
|
|
end
|
|
end
|
|
|
|
by_category.each do |cat, pos|
|
|
cat.position = pos
|
|
cat.save! if cat.will_save_change_to_position?
|
|
end
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def show
|
|
guardian.ensure_can_see!(@category)
|
|
|
|
if Category.topic_create_allowed(guardian).where(id: @category.id).exists?
|
|
@category.permission = CategoryGroup.permission_types[:full]
|
|
end
|
|
|
|
render_serialized(@category, CategorySerializer)
|
|
end
|
|
|
|
def create
|
|
guardian.ensure_can_create!(Category)
|
|
position = category_params.delete(:position)
|
|
|
|
@category =
|
|
begin
|
|
Category.new(required_create_params.merge(user: current_user))
|
|
rescue ArgumentError => e
|
|
return render json: { errors: [e.message] }, status: 422
|
|
end
|
|
|
|
if @category.save
|
|
@category.move_to(position.to_i) if position
|
|
|
|
Scheduler::Defer.later "Log staff action create category" do
|
|
@staff_action_logger.log_category_creation(@category)
|
|
end
|
|
|
|
render_serialized(@category, CategorySerializer)
|
|
else
|
|
render_json_error(@category)
|
|
end
|
|
end
|
|
|
|
def update
|
|
guardian.ensure_can_edit!(@category)
|
|
|
|
json_result(@category, serializer: CategorySerializer) do |cat|
|
|
old_category_params = category_params.dup
|
|
|
|
cat.move_to(category_params[:position].to_i) if category_params[:position]
|
|
category_params.delete(:position)
|
|
|
|
old_custom_fields = cat.custom_fields.dup
|
|
if category_params[:custom_fields]
|
|
category_params[:custom_fields].each do |key, value|
|
|
if value.present?
|
|
cat.custom_fields[key] = value
|
|
else
|
|
cat.custom_fields.delete(key)
|
|
end
|
|
end
|
|
end
|
|
category_params.delete(:custom_fields)
|
|
|
|
# properly null the value so the database constraint doesn't catch us
|
|
category_params[:email_in] = nil if category_params[:email_in].blank?
|
|
category_params[:minimum_required_tags] = 0 if category_params[:minimum_required_tags].blank?
|
|
|
|
old_permissions = cat.permissions_params
|
|
old_permissions = { "everyone" => 1 } if old_permissions.empty?
|
|
|
|
if result = cat.update(category_params)
|
|
Scheduler::Defer.later "Log staff action change category settings" do
|
|
@staff_action_logger.log_category_settings_change(
|
|
@category,
|
|
old_category_params,
|
|
old_permissions: old_permissions,
|
|
old_custom_fields: old_custom_fields,
|
|
)
|
|
end
|
|
end
|
|
|
|
DiscourseEvent.trigger(:category_updated, cat) if result
|
|
|
|
result
|
|
end
|
|
end
|
|
|
|
def update_slug
|
|
@category = Category.find(params[:category_id].to_i)
|
|
guardian.ensure_can_edit!(@category)
|
|
|
|
custom_slug = params[:slug].to_s
|
|
|
|
if custom_slug.blank?
|
|
error = @category.errors.full_message(:slug, I18n.t("errors.messages.blank"))
|
|
render_json_error(error)
|
|
elsif @category.update(slug: custom_slug)
|
|
render json: success_json
|
|
else
|
|
render_json_error(@category)
|
|
end
|
|
end
|
|
|
|
def set_notifications
|
|
category_id = params[:category_id].to_i
|
|
notification_level = params[:notification_level].to_i
|
|
|
|
CategoryUser.set_notification_level_for_category(current_user, notification_level, category_id)
|
|
render json:
|
|
success_json.merge(
|
|
{
|
|
indirectly_muted_category_ids:
|
|
CategoryUser.indirectly_muted_category_ids(current_user),
|
|
},
|
|
)
|
|
end
|
|
|
|
def destroy
|
|
guardian.ensure_can_delete!(@category)
|
|
@category.destroy
|
|
|
|
Scheduler::Defer.later "Log staff action delete category" do
|
|
@staff_action_logger.log_category_deletion(@category)
|
|
end
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def find_by_slug
|
|
params.require(:category_slug)
|
|
@category =
|
|
Category.includes(:category_setting).find_by_slug_path(params[:category_slug].split("/"))
|
|
|
|
raise Discourse::NotFound if @category.blank?
|
|
|
|
if !guardian.can_see?(@category)
|
|
if SiteSetting.detailed_404 && group = @category.access_category_via_group
|
|
raise Discourse::InvalidAccess.new(
|
|
"not in group",
|
|
@category,
|
|
custom_message: "not_in_group.title_category",
|
|
custom_message_params: {
|
|
group: group.name,
|
|
},
|
|
group: group,
|
|
)
|
|
else
|
|
raise Discourse::NotFound
|
|
end
|
|
end
|
|
|
|
@category.permission = CategoryGroup.permission_types[:full] if Category
|
|
.topic_create_allowed(guardian)
|
|
.where(id: @category.id)
|
|
.exists?
|
|
render_serialized(@category, CategorySerializer)
|
|
end
|
|
|
|
def visible_groups
|
|
@guardian.ensure_can_see!(@category)
|
|
|
|
groups =
|
|
if !@category.groups.exists?(id: Group::AUTO_GROUPS[:everyone])
|
|
@category.groups.merge(Group.visible_groups(current_user)).pluck("name")
|
|
end
|
|
|
|
render json: success_json.merge(groups: groups || [])
|
|
end
|
|
|
|
def find
|
|
categories = []
|
|
serializer = params[:include_permissions] ? CategorySerializer : SiteCategorySerializer
|
|
|
|
if params[:ids].present?
|
|
categories = Category.secured(guardian).where(id: params[:ids])
|
|
elsif params[:slug_path].present?
|
|
category = Category.find_by_slug_path(params[:slug_path].split("/"))
|
|
raise Discourse::NotFound if category.blank?
|
|
guardian.ensure_can_see!(category)
|
|
|
|
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
|
|
categories = [*ancestors, category]
|
|
elsif params[:slug_path_with_id].present?
|
|
category = Category.find_by_slug_path_with_id(params[:slug_path_with_id])
|
|
raise Discourse::NotFound if category.blank?
|
|
guardian.ensure_can_see!(category)
|
|
|
|
ancestors = Category.secured(guardian).with_ancestors(category.id).where.not(id: category.id)
|
|
categories = [*ancestors, category]
|
|
end
|
|
|
|
raise Discourse::NotFound if categories.blank?
|
|
|
|
Category.preload_user_fields!(guardian, categories)
|
|
|
|
render_serialized(categories, serializer, root: :categories, scope: guardian)
|
|
end
|
|
|
|
def hierarchical_search
|
|
term = params[:term].to_s.strip
|
|
page = [1, params[:page].to_i].max
|
|
offset = params[:offset].to_i
|
|
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
|
|
only =
|
|
if params[:only].present?
|
|
Category.secured(guardian).where(id: params[:only].to_a.map(&:to_i))
|
|
else
|
|
Category.secured(guardian)
|
|
end
|
|
except_ids = params[:except].to_a.map(&:to_i)
|
|
include_uncategorized =
|
|
(
|
|
if params[:include_uncategorized].present?
|
|
ActiveModel::Type::Boolean.new.cast(params[:include_uncategorized])
|
|
else
|
|
true
|
|
end
|
|
)
|
|
|
|
except_ids << SiteSetting.uncategorized_category_id unless include_uncategorized
|
|
|
|
except = Category.where(id: except_ids) if except_ids.present?
|
|
|
|
limit =
|
|
(
|
|
if params[:limit].present?
|
|
params[:limit].to_i.clamp(1, MAX_CATEGORIES_LIMIT)
|
|
else
|
|
MAX_CATEGORIES_LIMIT
|
|
end
|
|
)
|
|
|
|
categories =
|
|
Category
|
|
.secured(guardian)
|
|
.limited_categories_matching(only, except, parent_category_id, term)
|
|
.preload(
|
|
:uploaded_logo,
|
|
:uploaded_logo_dark,
|
|
:uploaded_background,
|
|
:uploaded_background_dark,
|
|
:tags,
|
|
:tag_groups,
|
|
:form_templates,
|
|
category_required_tag_groups: :tag_group,
|
|
)
|
|
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
|
|
.select("categories.*, t.slug topic_slug")
|
|
.limit(limit)
|
|
.offset((page - 1) * limit + offset)
|
|
.to_a
|
|
|
|
if Site.preloaded_category_custom_fields.present?
|
|
Category.preload_custom_fields(categories, Site.preloaded_category_custom_fields)
|
|
end
|
|
|
|
Category.preload_user_fields!(guardian, categories)
|
|
|
|
response = { categories: serialize_data(categories, SiteCategorySerializer, scope: guardian) }
|
|
|
|
render_json_dump(response)
|
|
end
|
|
|
|
def search
|
|
term = params[:term].to_s.strip
|
|
parent_category_id = params[:parent_category_id].to_i if params[:parent_category_id].present?
|
|
include_uncategorized =
|
|
(
|
|
if params[:include_uncategorized].present?
|
|
ActiveModel::Type::Boolean.new.cast(params[:include_uncategorized])
|
|
else
|
|
true
|
|
end
|
|
)
|
|
if params[:select_category_ids].is_a?(Array)
|
|
select_category_ids = params[:select_category_ids].map(&:presence)
|
|
end
|
|
if params[:reject_category_ids].is_a?(Array)
|
|
reject_category_ids = params[:reject_category_ids].map(&:presence)
|
|
end
|
|
include_subcategories =
|
|
if params[:include_subcategories].present?
|
|
ActiveModel::Type::Boolean.new.cast(params[:include_subcategories])
|
|
else
|
|
true
|
|
end
|
|
include_ancestors =
|
|
if params[:include_ancestors].present?
|
|
ActiveModel::Type::Boolean.new.cast(params[:include_ancestors])
|
|
else
|
|
false
|
|
end
|
|
prioritized_category_id = params[:prioritized_category_id].to_i if params[
|
|
:prioritized_category_id
|
|
].present?
|
|
limit =
|
|
(
|
|
if params[:limit].present?
|
|
params[:limit].to_i.clamp(1, MAX_CATEGORIES_LIMIT)
|
|
else
|
|
MAX_CATEGORIES_LIMIT
|
|
end
|
|
)
|
|
page = [1, params[:page].to_i].max
|
|
|
|
categories = Category.secured(guardian)
|
|
|
|
if term.present? && words = term.split
|
|
words.each { |word| categories = categories.where("name ILIKE ?", "%#{word}%") }
|
|
end
|
|
|
|
categories =
|
|
(
|
|
if parent_category_id != -1
|
|
categories.where(parent_category_id: parent_category_id)
|
|
else
|
|
categories.where(parent_category_id: nil)
|
|
end
|
|
) if parent_category_id.present?
|
|
|
|
categories =
|
|
categories.where.not(id: SiteSetting.uncategorized_category_id) if !include_uncategorized
|
|
|
|
categories = categories.where(id: select_category_ids) if select_category_ids
|
|
|
|
categories = categories.where.not(id: reject_category_ids) if reject_category_ids
|
|
|
|
categories = categories.where(parent_category_id: nil) if !include_subcategories
|
|
|
|
categories_count = categories.count
|
|
|
|
categories =
|
|
categories
|
|
.includes(
|
|
:uploaded_logo,
|
|
:uploaded_logo_dark,
|
|
:uploaded_background,
|
|
:uploaded_background_dark,
|
|
:tags,
|
|
:tag_groups,
|
|
:form_templates,
|
|
category_required_tag_groups: :tag_group,
|
|
)
|
|
.joins("LEFT JOIN topics t on t.id = categories.topic_id")
|
|
.select("categories.*, t.slug topic_slug")
|
|
.order(
|
|
"starts_with(lower(categories.name), #{ActiveRecord::Base.connection.quote(term)}) DESC",
|
|
"categories.parent_category_id IS NULL DESC",
|
|
"categories.id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
|
|
"categories.parent_category_id IS NOT DISTINCT FROM #{ActiveRecord::Base.connection.quote(prioritized_category_id)} DESC",
|
|
"categories.id ASC",
|
|
)
|
|
.limit(limit)
|
|
.offset((page - 1) * limit)
|
|
|
|
if Site.preloaded_category_custom_fields.present?
|
|
Category.preload_custom_fields(categories, Site.preloaded_category_custom_fields)
|
|
end
|
|
|
|
Category.preload_user_fields!(guardian, categories)
|
|
|
|
response = {
|
|
categories_count: categories_count,
|
|
categories: serialize_data(categories, SiteCategorySerializer, scope: guardian),
|
|
}
|
|
|
|
if include_ancestors
|
|
ancestors = Category.secured(guardian).ancestors_of(categories.map(&:id))
|
|
Category.preload_user_fields!(guardian, ancestors)
|
|
response[:ancestors] = serialize_data(ancestors, SiteCategorySerializer, scope: guardian)
|
|
end
|
|
|
|
render_json_dump(response)
|
|
end
|
|
|
|
private
|
|
|
|
def self.topics_per_page
|
|
return SiteSetting.categories_topics if SiteSetting.categories_topics > 0
|
|
|
|
count = Category.where(parent_category: nil).count
|
|
count = (SYMMETRICAL_CATEGORIES_TO_TOPICS_FACTOR * count).to_i
|
|
count > MIN_CATEGORIES_TOPICS ? count : MIN_CATEGORIES_TOPICS
|
|
end
|
|
|
|
def categories_and_topics(topics_filter)
|
|
discourse_expires_in 1.minute
|
|
|
|
result = CategoryAndTopicLists.new
|
|
result.category_list = fetch_category_list
|
|
result.topic_list = fetch_topic_list(topics_filter:)
|
|
|
|
render_serialized(result, CategoryAndTopicListsSerializer, root: false)
|
|
end
|
|
|
|
def required_param_keys
|
|
[:name]
|
|
end
|
|
|
|
def required_create_params
|
|
required_param_keys.each { |key| params.require(key) }
|
|
category_params
|
|
end
|
|
|
|
def category_params
|
|
@category_params ||=
|
|
begin
|
|
if p = params[:permissions]
|
|
p.each { |k, v| p[k] = v.to_i }
|
|
end
|
|
|
|
if SiteSetting.tagging_enabled
|
|
params[:allowed_tags] = params[:allowed_tags].presence || [] if params[:allowed_tags]
|
|
params[:allowed_tag_groups] = params[:allowed_tag_groups].presence || [] if params[
|
|
:allowed_tag_groups
|
|
]
|
|
params[:required_tag_groups] = params[:required_tag_groups].presence || [] if params[
|
|
:required_tag_groups
|
|
]
|
|
end
|
|
|
|
conditional_param_keys = []
|
|
if SiteSetting.enable_category_group_moderation?
|
|
conditional_param_keys << { moderating_group_ids: [] }
|
|
end
|
|
|
|
result =
|
|
params.permit(
|
|
*required_param_keys,
|
|
:position,
|
|
:name,
|
|
:color,
|
|
:text_color,
|
|
:email_in,
|
|
:email_in_allow_strangers,
|
|
:mailinglist_mirror,
|
|
:all_topics_wiki,
|
|
:allow_unlimited_owner_edits_on_first_post,
|
|
:default_slow_mode_seconds,
|
|
:parent_category_id,
|
|
:auto_close_hours,
|
|
:auto_close_based_on_last_post,
|
|
:uploaded_logo_id,
|
|
:uploaded_logo_dark_id,
|
|
:uploaded_background_id,
|
|
:uploaded_background_dark_id,
|
|
:slug,
|
|
:allow_badges,
|
|
:topic_template,
|
|
:sort_order,
|
|
:sort_ascending,
|
|
:topic_featured_link_allowed,
|
|
:show_subcategory_list,
|
|
:num_featured_topics,
|
|
:default_view,
|
|
:subcategory_list_style,
|
|
:default_top_period,
|
|
:minimum_required_tags,
|
|
:navigate_to_first_post_after_read,
|
|
:search_priority,
|
|
:allow_global_tags,
|
|
:read_only_banner,
|
|
:default_list_filter,
|
|
*conditional_param_keys,
|
|
category_setting_attributes: %i[
|
|
auto_bump_cooldown_days
|
|
num_auto_bump_daily
|
|
require_reply_approval
|
|
require_topic_approval
|
|
],
|
|
custom_fields: [custom_field_params],
|
|
permissions: [*p.try(:keys)],
|
|
allowed_tags: [],
|
|
allowed_tag_groups: [],
|
|
required_tag_groups: %i[name min_count],
|
|
form_template_ids: [],
|
|
)
|
|
|
|
if result[:required_tag_groups] && !result[:required_tag_groups].is_a?(Array)
|
|
raise Discourse::InvalidParameters.new(:required_tag_groups)
|
|
end
|
|
|
|
result
|
|
end
|
|
end
|
|
|
|
def custom_field_params
|
|
keys = params[:custom_fields].try(:keys)
|
|
return if keys.blank?
|
|
|
|
keys.map { |key| params[:custom_fields][key].is_a?(Array) ? { key => [] } : key }
|
|
end
|
|
|
|
def fetch_category
|
|
@category = Category.find_by_slug(params[:id]) || Category.find_by(id: params[:id].to_i)
|
|
raise Discourse::NotFound if @category.blank?
|
|
end
|
|
|
|
def fetch_category_list
|
|
parent_category =
|
|
if params[:parent_category_id].present?
|
|
Category.find_by_slug(params[:parent_category_id]) ||
|
|
Category.find_by(id: params[:parent_category_id].to_i)
|
|
elsif params[:category_slug_path_with_id].present?
|
|
Category.find_by_slug_path_with_id(params[:category_slug_path_with_id])
|
|
end
|
|
|
|
include_topics =
|
|
view_context.mobile_view? || params[:include_topics] ||
|
|
(parent_category && parent_category.subcategory_list_includes_topics?) ||
|
|
SiteSetting.desktop_category_page_style == "categories_with_featured_topics" ||
|
|
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
|
|
SiteSetting.desktop_category_page_style == "categories_boxes_with_topics" ||
|
|
SiteSetting.desktop_category_page_style == "categories_with_top_topics"
|
|
|
|
include_subcategories =
|
|
SiteSetting.desktop_category_page_style == "subcategories_with_featured_topics" ||
|
|
params[:include_subcategories] == "true"
|
|
|
|
category_options = {
|
|
is_homepage: current_homepage == "categories",
|
|
parent_category_id: parent_category&.id,
|
|
include_topics: include_topics,
|
|
include_subcategories: include_subcategories,
|
|
tag: params[:tag],
|
|
page: params[:page].try(:to_i) || 1,
|
|
}
|
|
|
|
@category_list = CategoryList.new(guardian, category_options)
|
|
end
|
|
|
|
def fetch_topic_list(topics_filter: nil)
|
|
style =
|
|
if topics_filter
|
|
"categories_and_#{topics_filter}_topics"
|
|
else
|
|
SiteSetting.desktop_category_page_style
|
|
end
|
|
|
|
topic_options = { per_page: CategoriesController.topics_per_page, no_definitions: true }
|
|
topic_options.merge!(build_topic_list_options)
|
|
topic_options[:order] = "created" if SiteSetting.desktop_category_page_style ==
|
|
"categories_and_latest_topics_created_date"
|
|
|
|
case style
|
|
when "categories_and_latest_topics", "categories_and_latest_topics_created_date"
|
|
@topic_list = TopicQuery.new(current_user, topic_options).list_latest
|
|
@topic_list.more_topics_url = url_for(latest_path(sort: topic_options[:order]))
|
|
when "categories_and_top_topics"
|
|
@topic_list =
|
|
TopicQuery.new(current_user, topic_options).list_top_for(
|
|
SiteSetting.top_page_default_timeframe.to_sym,
|
|
)
|
|
@topic_list.more_topics_url = url_for(top_path)
|
|
when "categories_and_hot_topics"
|
|
@topic_list = TopicQuery.new(current_user, topic_options).list_hot
|
|
@topic_list.more_topics_url = url_for(hot_path)
|
|
end
|
|
|
|
@topic_list
|
|
end
|
|
|
|
def initialize_staff_action_logger
|
|
@staff_action_logger = StaffActionLogger.new(current_user)
|
|
end
|
|
end
|