mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Backport of #41718 to release/2026.1. --- A category's `default_top_period` was interpolated raw into the top-topic score predicate in `ListController` before the period was validated against the allowlist. For anonymous requests to a category whose default view is `top`, an attacker-planted value reached the SQL predicate directly. The response never returns the query's data, but it changes depending on whether an injected condition holds, so an attacker can read data one yes/no answer at a time. This is hardening rather than a critical fix because the column is `varchar(20)`: PostgreSQL rejects an over-long value, leaving no room for a subquery or a second statement. An attacker could only probe one value at a time, for example reading the PostgreSQL session role character by character, not perform arbitrary reads or writes. Harden the path in depth: - Sanitise the category-derived period in `best_period_for`, falling back to the site default when it is not a known period, so the untrusted value never reaches SQL. - Build the score column in `best_period_with_topics_for` through the validating `TopTopic.score_column_for_period` helper instead of raw string interpolation. - Clear `Category#default_top_period` on validation when it is not a known period, so an invalid value is never stored and the category falls back to the site default at read time.
533 lines
18 KiB
Ruby
Vendored
533 lines
18 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class ListController < ApplicationController
|
|
include TopicListResponder
|
|
include TopicQueryParams
|
|
|
|
skip_before_action :check_xhr
|
|
|
|
before_action :set_category,
|
|
only: [
|
|
:category_default,
|
|
# filtered topics lists
|
|
Discourse.filters.map { |f| :"category_#{f}" },
|
|
Discourse.filters.map { |f| :"category_none_#{f}" },
|
|
# top summaries
|
|
:category_top,
|
|
:category_none_top,
|
|
# top pages (ie. with a period)
|
|
TopTopic.periods.map { |p| :"category_top_#{p}" },
|
|
TopTopic.periods.map { |p| :"category_none_top_#{p}" },
|
|
# category feeds
|
|
:category_feed,
|
|
].flatten
|
|
|
|
before_action :ensure_logged_in,
|
|
except: [
|
|
:topics_by,
|
|
# anonymous filters
|
|
Discourse.anonymous_filters,
|
|
Discourse.anonymous_filters.map { |f| "#{f}_feed" },
|
|
# anonymous categorized filters
|
|
:category_default,
|
|
Discourse.anonymous_filters.map { |f| :"category_#{f}" },
|
|
Discourse.anonymous_filters.map { |f| :"category_none_#{f}" },
|
|
# category feeds
|
|
:category_feed,
|
|
# user topics feed
|
|
:user_topics_feed,
|
|
# top summaries
|
|
:top,
|
|
:category_top,
|
|
:category_none_top,
|
|
# top pages (ie. with a period)
|
|
TopTopic.periods.map { |p| :"top_#{p}" },
|
|
TopTopic.periods.map { |p| :"top_#{p}_feed" },
|
|
TopTopic.periods.map { |p| :"category_top_#{p}" },
|
|
TopTopic.periods.map { |p| :"category_none_top_#{p}" },
|
|
:group_topics,
|
|
:filter,
|
|
].flatten
|
|
|
|
rescue_from ActionController::Redirecting::UnsafeRedirectError do
|
|
rescue_discourse_actions(:not_found, 404)
|
|
end
|
|
|
|
# Create our filters
|
|
Discourse.filters.each do |filter|
|
|
define_method(filter) do |options = nil|
|
|
list_opts = build_topic_list_options
|
|
list_opts.merge!(options) if options
|
|
user = current_user
|
|
if params[:category].blank? && filter == :latest &&
|
|
!SiteSetting.show_category_definitions_in_topic_lists
|
|
list_opts[:no_definitions] = true
|
|
end
|
|
|
|
list = TopicQuery.new(user, list_opts).public_send("list_#{filter}")
|
|
|
|
if guardian.can_create_shared_draft? && @category.present?
|
|
if @category.id == SiteSetting.shared_drafts_category.to_i
|
|
# On shared drafts, show the destination category
|
|
list.topics.each { |t| t.includes_destination_category = t.shared_draft.present? }
|
|
else
|
|
# When viewing a non-shared draft category, find topics whose
|
|
# destination are this category
|
|
shared_drafts =
|
|
TopicQuery.new(
|
|
user,
|
|
category: SiteSetting.shared_drafts_category,
|
|
destination_category_id: list_opts[:category],
|
|
).list_latest
|
|
|
|
if shared_drafts.present? && shared_drafts.topics.present?
|
|
list.shared_drafts = shared_drafts.topics
|
|
end
|
|
end
|
|
end
|
|
|
|
list.more_topics_url = construct_url_with(:next, list_opts)
|
|
list.prev_topics_url = construct_url_with(:prev, list_opts)
|
|
|
|
if Discourse.anonymous_filters.include?(filter)
|
|
@description = SiteSetting.site_description
|
|
@rss = filter
|
|
@rss_description = filter
|
|
|
|
# Note the first is the default and we don't add a title
|
|
if (filter.to_s != current_homepage) && use_crawler_layout?
|
|
filter_title = I18n.t("js.filters.#{filter}.title", count: 0)
|
|
|
|
if list_opts[:category] && @category
|
|
@title =
|
|
I18n.t("js.filters.with_category", filter: filter_title, category: @category.name)
|
|
else
|
|
@title = I18n.t("js.filters.with_topics", filter: filter_title)
|
|
end
|
|
|
|
@title << " - #{SiteSetting.title}"
|
|
elsif @category.blank? && (filter.to_s == current_homepage) &&
|
|
SiteSetting.short_site_description.present?
|
|
@title = "#{SiteSetting.title} - #{SiteSetting.short_site_description}"
|
|
end
|
|
end
|
|
|
|
respond_with_list(list)
|
|
end
|
|
|
|
define_method("category_#{filter}") do
|
|
canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}"
|
|
self.public_send(filter, category: @category.id)
|
|
end
|
|
|
|
define_method("category_none_#{filter}") do
|
|
self.public_send(filter, category: @category.id, no_subcategories: true)
|
|
end
|
|
end
|
|
|
|
def filter
|
|
topic_query_opts = { no_definitions: !SiteSetting.show_category_definitions_in_topic_lists }
|
|
|
|
%i[page q].each do |key|
|
|
if params.key?(key.to_s)
|
|
value = params[key]
|
|
raise Discourse::InvalidParameters.new(key) if !TopicQuery.validate?(key, value)
|
|
topic_query_opts[key] = value
|
|
end
|
|
end
|
|
|
|
user = current_user
|
|
list = TopicQuery.new(user, topic_query_opts).list_filter
|
|
list.more_topics_url = construct_url_with(:next, topic_query_opts)
|
|
list.prev_topics_url = construct_url_with(:prev, topic_query_opts)
|
|
|
|
respond_with_list(list)
|
|
end
|
|
|
|
def category_default
|
|
canonical_url "#{Discourse.base_url_no_prefix}#{@category.url}"
|
|
view_method = @category.default_view
|
|
view_method = "latest" if %w[hot latest top].exclude?(view_method)
|
|
|
|
self.public_send(view_method, category: @category.id)
|
|
end
|
|
|
|
def topics_by
|
|
list_opts = build_topic_list_options
|
|
target_user =
|
|
fetch_user_from_params(
|
|
{
|
|
include_inactive:
|
|
current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts),
|
|
},
|
|
%i[user_stat user_option],
|
|
)
|
|
ensure_can_see_profile!(target_user)
|
|
|
|
list = generate_list_for("topics_by", target_user, list_opts)
|
|
list.more_topics_url = construct_url_with(:next, list_opts)
|
|
list.prev_topics_url = construct_url_with(:prev, list_opts)
|
|
respond_with_list(list)
|
|
end
|
|
|
|
def group_topics
|
|
group = Group.find_by(name: params[:group_name])
|
|
raise Discourse::NotFound unless group
|
|
guardian.ensure_can_see_group!(group)
|
|
guardian.ensure_can_see_group_members!(group)
|
|
|
|
list_opts = build_topic_list_options
|
|
list = generate_list_for("group_topics", group, list_opts)
|
|
list.more_topics_url = construct_url_with(:next, list_opts)
|
|
list.prev_topics_url = construct_url_with(:prev, list_opts)
|
|
respond_with_list(list)
|
|
end
|
|
|
|
def self.generate_message_route(action)
|
|
define_method action do
|
|
message_route(action)
|
|
end
|
|
end
|
|
|
|
def message_route(action)
|
|
target_user =
|
|
fetch_user_from_params(
|
|
{ include_inactive: current_user.try(:staff?) },
|
|
%i[user_stat user_option],
|
|
)
|
|
|
|
case action
|
|
when :private_messages_unread, :private_messages_new, :private_messages_group_new,
|
|
:private_messages_group_unread
|
|
raise Discourse::NotFound if target_user.id != current_user.id
|
|
when :private_messages_tag
|
|
raise Discourse::NotFound if target_user.id != current_user.id
|
|
raise Discourse::NotFound if !guardian.can_tag_pms?
|
|
when :private_messages_warnings
|
|
guardian.ensure_can_see_warnings!(target_user)
|
|
when :private_messages_group, :private_messages_group_archive
|
|
group = Group.find_by("LOWER(name) = ?", params[:group_name].downcase)
|
|
raise Discourse::NotFound if !group
|
|
raise Discourse::NotFound unless guardian.can_see_group_messages?(group)
|
|
else
|
|
guardian.ensure_can_see_private_messages!(target_user.id)
|
|
end
|
|
|
|
list_opts = build_topic_list_options
|
|
list = generate_list_for(action.to_s, target_user, list_opts)
|
|
url_prefix = "topics"
|
|
list.more_topics_url = construct_url_with(:next, list_opts, url_prefix)
|
|
list.prev_topics_url = construct_url_with(:prev, list_opts, url_prefix)
|
|
respond_with_list(list)
|
|
end
|
|
|
|
%i[
|
|
private_messages
|
|
private_messages_sent
|
|
private_messages_unread
|
|
private_messages_new
|
|
private_messages_archive
|
|
private_messages_group
|
|
private_messages_group_new
|
|
private_messages_group_unread
|
|
private_messages_group_archive
|
|
private_messages_warnings
|
|
private_messages_tag
|
|
].each { |action| generate_message_route(action) }
|
|
|
|
def latest_feed
|
|
discourse_expires_in 1.minute
|
|
|
|
options = { order: "created" }.merge(build_topic_list_options)
|
|
|
|
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.latest")}"
|
|
@link = "#{Discourse.base_url}/latest"
|
|
@atom_link = "#{Discourse.base_url}/latest.rss"
|
|
@description = I18n.t("rss_description.latest")
|
|
@topic_list = TopicQuery.new(nil, options).list_latest
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
|
|
def top_feed
|
|
discourse_expires_in 1.minute
|
|
|
|
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.top")}"
|
|
@link = "#{Discourse.base_url}/top"
|
|
@atom_link = "#{Discourse.base_url}/top.rss"
|
|
@description = I18n.t("rss_description.top")
|
|
period = params[:period] || SiteSetting.top_page_default_timeframe.to_sym
|
|
TopTopic.validate_period(period)
|
|
|
|
@topic_list = TopicQuery.new(nil).list_top_for(period)
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
|
|
def hot_feed
|
|
discourse_expires_in 1.minute
|
|
|
|
@title = "#{SiteSetting.title} - #{I18n.t("rss_description.hot")}"
|
|
@link = "#{Discourse.base_url}/hot"
|
|
@atom_link = "#{Discourse.base_url}/hot.rss"
|
|
@description = I18n.t("rss_description.hot")
|
|
|
|
@topic_list = TopicQuery.new(nil).list_hot
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
|
|
def category_feed
|
|
guardian.ensure_can_see!(@category)
|
|
discourse_expires_in 1.minute
|
|
|
|
@title = "#{@category.name} - #{SiteSetting.title}"
|
|
@link = "#{Discourse.base_url_no_prefix}#{@category.url}"
|
|
@atom_link = "#{Discourse.base_url_no_prefix}#{@category.url}.rss"
|
|
@description =
|
|
"#{I18n.t("topics_in_category", category: @category.name)} #{@category.description}"
|
|
@topic_list = TopicQuery.new(current_user).list_new_in_category(@category)
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
|
|
def user_topics_feed
|
|
discourse_expires_in 1.minute
|
|
target_user = fetch_user_from_params
|
|
ensure_can_see_profile!(target_user)
|
|
|
|
@title =
|
|
"#{SiteSetting.title} - #{I18n.t("rss_description.user_topics", username: target_user.username)}"
|
|
@link = "#{target_user.full_url}/activity/topics"
|
|
@atom_link = "#{target_user.full_url}/activity/topics.rss"
|
|
@description = I18n.t("rss_description.user_topics", username: target_user.username)
|
|
|
|
@topic_list = TopicQuery.new(nil, order: "created").public_send("list_topics_by", target_user)
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
|
|
def top(options = nil)
|
|
options ||= {}
|
|
period = params[:period]
|
|
period ||=
|
|
ListController.best_period_for(current_user.try(:previous_visit_at), options[:category])
|
|
TopTopic.validate_period(period)
|
|
public_send("top_#{period}", options)
|
|
end
|
|
|
|
def category_top
|
|
top(category: @category.id)
|
|
end
|
|
|
|
def category_none_top
|
|
top(category: @category.id, no_subcategories: true)
|
|
end
|
|
|
|
TopTopic.periods.each do |period|
|
|
define_method("top_#{period}") do |options = nil|
|
|
top_options = build_topic_list_options
|
|
top_options.merge!(options) if options
|
|
top_options[:per_page] = top_options[:per_page].presence ||
|
|
SiteSetting.topics_per_period_in_top_page
|
|
|
|
user = current_user
|
|
list = TopicQuery.new(user, top_options).list_top_for(period)
|
|
list.for_period = period
|
|
list.more_topics_url = construct_url_with(:next, top_options)
|
|
list.prev_topics_url = construct_url_with(:prev, top_options)
|
|
@rss = "top"
|
|
@params = { period: period }
|
|
@rss_description = "top_#{period}"
|
|
|
|
respond_with_list(list)
|
|
end
|
|
|
|
define_method("category_top_#{period}") do
|
|
self.public_send("top_#{period}", category: @category.id)
|
|
end
|
|
|
|
define_method("category_none_top_#{period}") do
|
|
self.public_send("top_#{period}", category: @category.id, no_subcategories: true)
|
|
end
|
|
|
|
# rss feed
|
|
define_method("top_#{period}_feed") do |options = nil|
|
|
discourse_expires_in 1.minute
|
|
|
|
@description = I18n.t("rss_description.top_#{period}")
|
|
@title = "#{SiteSetting.title} - #{@description}"
|
|
@link = "#{Discourse.base_url}/top?period=#{period}"
|
|
@atom_link = "#{Discourse.base_url}/top.rss?period=#{period}"
|
|
@topic_list = TopicQuery.new(nil).list_top_for(period)
|
|
|
|
render "list", formats: [:rss]
|
|
end
|
|
end
|
|
|
|
protected
|
|
|
|
def next_page_params
|
|
page_params.merge(page: params[:page].to_i + 1)
|
|
end
|
|
|
|
def prev_page_params
|
|
pg = params[:page].to_i
|
|
if pg > 1
|
|
page_params.merge(page: pg - 1)
|
|
else
|
|
page_params.merge(page: nil)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def page_params
|
|
route_params = { format: "json" }
|
|
|
|
if @category.present?
|
|
slug_path = @category.slug_path
|
|
|
|
route_params[:category_slug_path_with_id] = (slug_path + [@category.id.to_s]).join("/")
|
|
end
|
|
|
|
route_params[:username] = UrlHelper.encode_component(params[:username]) if params[
|
|
:username
|
|
].present?
|
|
route_params[:period] = params[:period] if params[:period].present?
|
|
route_params
|
|
end
|
|
|
|
def set_category
|
|
category_slug_path_with_id = params.require(:category_slug_path_with_id)
|
|
|
|
@category = Category.find_by_slug_path_with_id(category_slug_path_with_id)
|
|
raise Discourse::NotFound.new("category not found", check_permalinks: true) if @category.nil?
|
|
|
|
params[:category] = @category.id.to_s
|
|
|
|
if !guardian.can_see?(@category)
|
|
if SiteSetting.detailed_404
|
|
raise Discourse::InvalidAccess
|
|
else
|
|
raise Discourse::NotFound
|
|
end
|
|
end
|
|
|
|
# Check if the category slug is incorrect and redirect to a link containing
|
|
# the correct one.
|
|
current_slug = category_slug_path_with_id
|
|
if SiteSetting.slug_generation_method == "encoded"
|
|
current_slug = current_slug.split("/").map { |slug| CGI.escape(slug) }.join("/")
|
|
end
|
|
real_slug = @category.full_slug("/")
|
|
if CGI.unescape(current_slug) != CGI.unescape(real_slug)
|
|
path = CGI.unescape(request.path)
|
|
query = request.query_string
|
|
new_path = path.gsub(current_slug, real_slug)
|
|
url = query.present? ? "#{new_path}?#{query}" : new_path
|
|
if ActionController::Base.config.relative_url_root
|
|
url = url.sub(ActionController::Base.config.relative_url_root, "")
|
|
end
|
|
|
|
return redirect_to path(url), status: :moved_permanently
|
|
end
|
|
|
|
@description_meta =
|
|
if @category.uncategorized?
|
|
I18n.t("category.uncategorized_description", locale: SiteSetting.default_locale)
|
|
elsif @category.description_text.present?
|
|
@category.description_text
|
|
else
|
|
SiteSetting.site_description
|
|
end
|
|
|
|
if use_crawler_layout?
|
|
@subcategories = @category.subcategories.select { |c| guardian.can_see?(c) }
|
|
end
|
|
end
|
|
|
|
def generate_list_for(action, target_user, opts)
|
|
TopicQuery.new(current_user, opts).public_send("list_#{action}", target_user)
|
|
end
|
|
|
|
def construct_url_with(action, opts, url_prefix = nil)
|
|
method = url_prefix.blank? ? "#{action_name}_path" : "#{url_prefix}_#{action_name}_path"
|
|
|
|
page_params =
|
|
case action
|
|
when :prev
|
|
prev_page_params
|
|
when :next
|
|
next_page_params
|
|
else
|
|
raise "unreachable"
|
|
end
|
|
|
|
opts = opts.dup
|
|
|
|
if SiteSetting.unicode_usernames && opts[:group_name]
|
|
opts[:group_name] = UrlHelper.encode_component(opts[:group_name])
|
|
end
|
|
|
|
opts.delete(:category) if page_params.include?(:category_slug_path_with_id)
|
|
|
|
url = public_send(method, opts.merge(page_params)).sub(".json?", "?")
|
|
|
|
# Unicode usernames need to be encoded when calling Rails' path helper. However, it means that the already
|
|
# encoded username are encoded again which we do not want. As such, we unencode the path once when unicode usernames
|
|
# have been enabled.
|
|
if SiteSetting.unicode_usernames
|
|
uri = URI.parse(url)
|
|
uri.path = UrlHelper.unencode(uri.path)
|
|
url = uri.to_s
|
|
end
|
|
|
|
url
|
|
end
|
|
|
|
def ensure_can_see_profile!(target_user = nil)
|
|
raise Discourse::NotFound unless guardian.can_see_profile?(target_user)
|
|
end
|
|
|
|
def self.best_period_for(previous_visit_at, category_id = nil)
|
|
default_period =
|
|
(
|
|
(category_id && Category.where(id: category_id).pick(:default_top_period)) ||
|
|
SiteSetting.top_page_default_timeframe
|
|
).to_sym
|
|
|
|
default_period = SiteSetting.top_page_default_timeframe.to_sym if TopTopic.periods.exclude?(
|
|
default_period,
|
|
)
|
|
|
|
best_period_with_topics_for(previous_visit_at, category_id, default_period) || default_period
|
|
end
|
|
|
|
def self.best_period_with_topics_for(
|
|
previous_visit_at,
|
|
category_id = nil,
|
|
default_period = SiteSetting.top_page_default_timeframe
|
|
)
|
|
best_periods_for(previous_visit_at, default_period.to_sym).find do |period|
|
|
top_topics = TopTopic.where("#{TopTopic.score_column_for_period(period)} > 0")
|
|
top_topics =
|
|
top_topics.joins(:topic).where("topics.category_id = ?", category_id) if category_id
|
|
top_topics = top_topics.limit(SiteSetting.topics_per_period_in_top_page)
|
|
top_topics.count == SiteSetting.topics_per_period_in_top_page
|
|
end
|
|
end
|
|
|
|
def self.best_periods_for(date, default_period = :all)
|
|
return [default_period, :all].uniq unless date
|
|
|
|
periods = []
|
|
periods << :daily if date > (1.week + 1.day).ago
|
|
periods << :weekly if date > (1.month + 1.week).ago
|
|
periods << :monthly if date > (3.months + 3.weeks).ago
|
|
periods << :quarterly if date > (1.year + 1.month).ago
|
|
periods << :yearly if date > 3.years.ago
|
|
periods << :all
|
|
periods
|
|
end
|
|
end
|