# frozen_string_literal: true require "current_user" class ApplicationController < ActionController::Base include CurrentUser include CanonicalURL::ControllerExtensions include JsonError include GlobalPath include Hijack include ReadOnlyMixin include VaryHeader attr_reader :theme_id serialization_scope :guardian protect_from_forgery # Default Rails 3.2 lets the request through with a blank session # we are being more pedantic here and nulling session / current_user # and then raising a CSRF exception def handle_unverified_request # NOTE: API key is secret, having it invalidates the need for a CSRF token unless is_api? || is_user_api? super clear_current_user render plain: "[\"BAD CSRF\"]", status: 403 end end before_action :rate_limit_crawlers before_action :check_readonly_mode before_action :handle_theme before_action :set_current_user_for_logs before_action :set_mp_snapshot_fields before_action :clear_notifications around_action :with_resolved_locale before_action :set_mobile_view before_action :block_if_readonly_mode before_action :authorize_mini_profiler before_action :redirect_to_login_if_required before_action :block_if_requires_login before_action :preload_json before_action :check_xhr after_action :add_readonly_header after_action :perform_refresh_session after_action :dont_cache_page after_action :conditionally_allow_site_embedding after_action :ensure_vary_header after_action :add_noindex_header, if: -> { is_feed_request? || !SiteSetting.allow_index_in_robots_txt } after_action :add_noindex_header_to_non_canonical, if: :spa_boot_request? after_action :set_cross_origin_opener_policy_header, if: :spa_boot_request? after_action :clean_xml, if: :is_feed_response? after_action :add_early_hint_header, if: -> { spa_boot_request? } HONEYPOT_KEY ||= "HONEYPOT_KEY" CHALLENGE_KEY ||= "CHALLENGE_KEY" layout :set_layout def has_escaped_fragment? SiteSetting.enable_escaped_fragments? && params.key?("_escaped_fragment_") end def show_browser_update? @show_browser_update ||= CrawlerDetection.show_browser_update?(request.user_agent) end helper_method :show_browser_update? def use_crawler_layout? @use_crawler_layout ||= request.user_agent && (request.media_type.blank? || request.media_type.include?("html")) && !%w[json rss].include?(params[:format]) && ( has_escaped_fragment? || params.key?("print") || show_browser_update? || CrawlerDetection.crawler?(request.user_agent, request.headers["HTTP_VIA"]) ) end def perform_refresh_session refresh_session(current_user) unless @readonly_mode end def immutable_for(duration) response.cache_control[:max_age] = duration.to_i response.cache_control[:public] = true response.cache_control[:extras] = ["immutable"] end def dont_cache_page if !response.headers["Cache-Control"] && response.cache_control.blank? response.cache_control[:no_cache] = true response.cache_control[:extras] = ["no-store"] end response.headers["Discourse-No-Onebox"] = "1" if SiteSetting.login_required end def conditionally_allow_site_embedding response.headers.delete("X-Frame-Options") if SiteSetting.allow_embedding_site_in_an_iframe end def ember_cli_required? Rails.env.development? && ENV["ALLOW_EMBER_CLI_PROXY_BYPASS"] != "1" && request.headers["X-Discourse-Ember-CLI"] != "true" end def application_layout ember_cli_required? ? "ember_cli" : "application" end def set_layout case request.headers["Discourse-Render"] when "desktop" return application_layout when "crawler" return "crawler" end use_crawler_layout? ? "crawler" : application_layout end class RenderEmpty < StandardError end class PluginDisabled < StandardError end rescue_from RenderEmpty do with_resolved_locale { render "default/empty" } end rescue_from ArgumentError do |e| if e.message == "string contains null byte" raise Discourse::InvalidParameters, e.message else raise e end end rescue_from PG::ReadOnlySqlTransaction do |e| Discourse.received_postgres_readonly! Rails.logger.error("#{e.class} #{e.message}: #{e.backtrace.join("\n")}") rescue_with_handler(Discourse::ReadOnly.new) || raise end rescue_from ActionController::ParameterMissing do |e| render_json_error e.message, status: 400 end rescue_from Discourse::SiteSettingMissing do |e| render_json_error I18n.t("site_setting_missing", name: e.message), status: 500 end rescue_from ActionController::RoutingError, PluginDisabled do rescue_discourse_actions(:not_found, 404) end # Handles requests for giant IDs that throw pg exceptions rescue_from ActiveModel::RangeError do |e| if e.message =~ /ActiveModel::Type::Integer/ rescue_discourse_actions(:not_found, 404) else raise e end end rescue_from ActiveRecord::RecordInvalid do |e| if request.format && request.format.json? render_json_error e, type: :record_invalid, status: 422 else raise e end end rescue_from ActiveRecord::StatementInvalid do |e| Discourse.reset_active_record_cache_if_needed(e) raise e end # If they hit the rate limiter rescue_from RateLimiter::LimitExceeded do |e| retry_time_in_seconds = e&.available_in response_headers = { "Retry-After": retry_time_in_seconds.to_s } response_headers["Discourse-Rate-Limit-Error-Code"] = e.error_code if e&.error_code with_resolved_locale do render_json_error( e.description, type: :rate_limit, status: 429, extras: { wait_seconds: retry_time_in_seconds, time_left: e&.time_left, }, headers: response_headers, ) end end rescue_from Discourse::NotLoggedIn do |e| if (request.format && request.format.json?) || request.xhr? || !request.get? rescue_discourse_actions(:not_logged_in, 403, include_ember: true) else rescue_discourse_actions(:not_found, 404) end end rescue_from Discourse::InvalidParameters do |e| opts = { custom_message: "invalid_params", custom_message_params: { message: e.message } } if (request.format && request.format.json?) || request.xhr? || !request.get? rescue_discourse_actions(:invalid_parameters, 400, opts.merge(include_ember: true)) else rescue_discourse_actions(:not_found, 400, opts) end end rescue_from Discourse::NotFound do |e| rescue_discourse_actions( :not_found, e.status, check_permalinks: e.check_permalinks, original_path: e.original_path, custom_message: e.custom_message, ) end rescue_from Discourse::InvalidAccess do |e| cookies.delete(e.opts[:delete_cookie]) if e.opts[:delete_cookie].present? rescue_discourse_actions( :invalid_access, 403, include_ember: true, custom_message: e.custom_message, custom_message_params: e.custom_message_params, group: e.group, ) end rescue_from Discourse::ReadOnly do unless response_body respond_to do |format| format.json do render_json_error I18n.t("read_only_mode_enabled"), type: :read_only, status: 503 end format.html { render status: 503, layout: "no_ember", template: "exceptions/read_only" } end end end rescue_from SecondFactor::AuthManager::SecondFactorRequired do |e| if request.xhr? render json: { second_factor_challenge_nonce: e.nonce }, status: 403 else redirect_to session_2fa_path(nonce: e.nonce) end end rescue_from SecondFactor::BadChallenge do |e| render json: { error: I18n.t(e.error_translation_key) }, status: e.status_code end def redirect_with_client_support(url, options = {}) if request.xhr? response.headers["Discourse-Xhr-Redirect"] = "true" render plain: url else redirect_to url, options end end def rescue_discourse_actions(type, status_code, opts = nil) opts ||= {} show_json_errors = (request.format && request.format.json?) || (request.xhr?) || ((params[:external_id] || "").ends_with? ".json") if type == :not_found && opts[:check_permalinks] url = opts[:original_path] || request.fullpath permalink = Permalink.find_by_url(url) # there are some cases where we have a permalink but no url # cause category / topic was deleted if permalink.present? && permalink.target_url # permalink present, redirect to that URL redirect_with_client_support permalink.target_url, status: :moved_permanently, allow_other_host: true return end end message = title = nil with_resolved_locale(check_current_user: false) do if opts[:custom_message] title = message = I18n.t(opts[:custom_message], opts[:custom_message_params] || {}) else message = I18n.t(type) if status_code == 403 title = I18n.t("page_forbidden.title") else title = I18n.t("page_not_found.title") end end end error_page_opts = { title: title, status: status_code, group: opts[:group] } if show_json_errors opts = { type: type, status: status_code } with_resolved_locale(check_current_user: false) do # Include error in HTML format for topics#show. if (request.params[:controller] == "topics" && request.params[:action] == "show") || ( request.params[:controller] == "categories" && request.params[:action] == "find_by_slug" ) opts[:extras] = { title: I18n.t("page_not_found.page_title"), html: build_not_found_page(error_page_opts), group: error_page_opts[:group], } end end render_json_error message, opts else begin # 404 pages won't have the session and theme_keys without these: current_user handle_theme rescue Discourse::InvalidAccess return render plain: message, status: status_code end with_resolved_locale do error_page_opts[:layout] = (opts[:include_ember] && @preloaded) ? set_layout : "no_ember" render html: build_not_found_page(error_page_opts) end end end # If a controller requires a plugin, it will raise an exception if that plugin is # disabled. This allows plugins to be disabled programmatically. def self.requires_plugin(plugin_name) before_action do if plugin = Discourse.plugins_by_name[plugin_name] raise PluginDisabled.new if !plugin.enabled? elsif Rails.env.test? raise "Required plugin '#{plugin_name}' not found. The string passed to requires_plugin should match the plugin's name at the top of plugin.rb" else Rails.logger.warn("Required plugin '#{plugin_name}' not found") end end end def set_current_user_for_logs if current_user Logster.add_to_env(request.env, "username", current_user.username) response.headers["X-Discourse-Username"] = current_user.username end response.headers["X-Discourse-Route"] = "#{controller_name}/#{action_name}" end def set_mp_snapshot_fields if defined?(Rack::MiniProfiler) Rack::MiniProfiler.add_snapshot_custom_field("Application version", Discourse.git_version) if Rack::MiniProfiler.snapshots_transporter? Rack::MiniProfiler.add_snapshot_custom_field("Site", Discourse.current_hostname) end end end def clear_notifications if current_user && !@readonly_mode cookie_notifications = cookies["cn"] notifications = request.headers["Discourse-Clear-Notifications"] if cookie_notifications if notifications.present? notifications += ",#{cookie_notifications}" else notifications = cookie_notifications end end if notifications.present? notification_ids = notifications.split(",").map(&:to_i) Notification.read(current_user, notification_ids) current_user.reload current_user.publish_notifications_state cookie_args = {} cookie_args[:path] = Discourse.base_path if Discourse.base_path.present? cookies.delete("cn", cookie_args) end end end def with_resolved_locale(check_current_user: true) if check_current_user && ( user = begin current_user rescue StandardError nil end ) locale = user.effective_locale else locale = Discourse.anonymous_locale(request) locale ||= SiteSetting.default_locale end locale = SiteSettings::DefaultsProvider::DEFAULT_LOCALE if !I18n.locale_available?(locale) I18n.ensure_all_loaded! I18n.with_locale(locale) { yield } end def store_preloaded(key, json) @preloaded ||= {} # I dislike that there is a gsub as opposed to a gsub! # but we can not be mucking with user input, I wonder if there is a way # to inject this safety deeper in the library or even in AM serializer @preloaded[key] = json.gsub("; rel=preconnect") end elsif GlobalSetting.early_hint_header_mode == "preload" links.push(*@asset_preload_links) end response.headers[GlobalSetting.early_hint_header_name] = links.join(", ") if links.present? end def spa_boot_request? request.get? && !(request.format && request.format.json?) && !request.xhr? end def load_font_map DiscourseFonts .fonts .each_with_object({}) do |font, font_map| next if !font[:variants] font_map[font[:key]] = font[:variants].map do |v| { url: "#{Discourse.base_url}/fonts/#{v[:filename]}?v=#{DiscourseFonts::VERSION}", weight: v[:weight], } end end end def fetch_limit_from_params(params: self.params, default:, max:) fetch_int_from_params(:limit, params: params, default: default, max: max) end def fetch_int_from_params(key, params: self.params, default:, min: 0, max: nil) key = key.to_sym if default.present? && ((max.present? && default > max) || (min.present? && default < min)) raise "default #{key.inspect} is not between #{min.inspect} and #{max.inspect}" end if params.has_key?(key) value = begin Integer(params[key]) rescue ArgumentError raise Discourse::InvalidParameters.new(key) end if (min.present? && value < min) || (max.present? && value > max) raise Discourse::InvalidParameters.new(key) end value else default end end def clean_xml response.body.gsub!(XmlCleaner::INVALID_CHARACTERS, "") end end