# 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: -> { request.get? && !(request.format && request.format.json?) && !request.xhr? } 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')) && !['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 if SiteSetting.login_required response.headers['Discourse-No-Onebox'] = '1' end end def conditionally_allow_site_embedding if SiteSetting.allow_embedding_site_in_an_iframe response.headers.delete('X-Frame-Options') end 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 } if e&.error_code response_headers['Discourse-Rate-Limit-Error-Code'] = e.error_code end 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| if e.opts[:delete_cookie].present? cookies.delete(e.opts[:delete_cookie]) end 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 do render status: 503, layout: 'no_ember', template: 'exceptions/read_only' end 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] ? 'application' : '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 raise PluginDisabled.new if Discourse.disabled_plugin_names.include?(plugin_name) 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 = current_user rescue nil) locale = user.effective_locale else if SiteSetting.set_locale_from_accept_language_header locale = locale_from_header else locale = SiteSetting.default_locale end end if !I18n.locale_available?(locale) locale = SiteSettings::DefaultsProvider::DEFAULT_LOCALE end 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("