mysociety/alaveteli

View on GitHub
app/controllers/application_controller.rb

Summary

Maintainability
C
1 day
Test Coverage
# controllers/application.rb:
# Parent class of all controllers in FOI site. Filters added to this controller
# apply to all controllers in the application. Likewise, all the methods added
# will be available for all controllers.
#
# Copyright (c) 2007 UK Citizens Online Democracy. All rights reserved.
# Email: hello@mysociety.org; WWW: http://www.mysociety.org/

class ApplicationController < ActionController::Base
  class PermissionDenied < StandardError
  end

  class RouteNotFound < StandardError
  end

  before_action :set_gettext_locale
  before_action :collect_locales

  protect_from_forgery if: :authenticated?, with: :exception
  skip_before_action :verify_authenticity_token, unless: :authenticated?

  # Deal with access denied errors from CanCan
  rescue_from CanCan::AccessDenied do |_exception|
    raise PermissionDenied
  end

  # assign our own handler method for non-local exceptions
  rescue_from Exception, with: :render_exception

  # Standard headers, footers and navigation for whole site
  layout "default"

  include FastGettext::Translation # make functions like _, n_, N_ etc available)
  include AlaveteliPro::PostRedirectHandler

  # NOTE: a filter stops the chain if it redirects or renders something
  before_action :html_response
  before_action :authentication_check
  before_action :check_in_post_redirect
  before_action :session_remember_me
  before_action :set_vary_header
  before_action :validate_session_timestamp
  after_action  :persist_session_timestamp

  def set_vary_header
    response.headers['Vary'] = 'Cookie'
  end

  helper_method :anonymous_cache, :short_cache, :medium_cache, :long_cache
  def anonymous_cache(time)
    return if authenticated?

    headers['Cache-Control'] = "max-age=#{time}, public"
  end

  def short_cache
    anonymous_cache(60.seconds)
  end

  def medium_cache
    anonymous_cache(60.minutes)
  end

  def long_cache
    anonymous_cache(24.hours)
  end

  # This is an override of the method provided by gettext_i18n_rails.
  # AlaveteliLocalization.set_session_locale explicitly sets I18n.locale,
  # required due to the I18nProxy used in Rails to trigger the
  # lookup_context and expire the template cache
  def set_gettext_locale
    params_locale = params[:locale]

    if AlaveteliConfiguration.include_default_locale_in_urls == false
      params_locale ||= AlaveteliLocalization.default_locale
    end

    if AlaveteliConfiguration.use_default_browser_language
      browser_locale = request.env['HTTP_ACCEPT_LANGUAGE']
    end

    locale = AlaveteliLocalization.set_session_locale(
      params_locale, session[:locale], cookies[:locale], browser_locale
    )

    # set the current locale to the requested_locale
    session[:locale] = locale

    # ensure current user locale attribute is up-to-date
    current_user.update_column(:locale, locale) if current_user
  end

  # Help work out which request causes RAM spike.
  # http://www.codeweblog.com/rails-to-monitor-the-process-of-memory-leaks-skills/
  # This shows the memory use increase of the Ruby process due to the request.
  # Since Ruby never returns memory to the OS, if the existing process previously
  # served a larger request, this won't show any consumption for the later request.
  # Ruby also grabs memory from the OS in variously sized jumps, so the extra
  # consumption of a request shown by this function will only appear in such
  # jumps.
  #
  # To find things that are using causing LOTS of peak memory, then do something like:
  # egrep "CONSUME MEMORY: [0-9]{7} KB" production.log
  around_action :record_memory
  def record_memory
    record_memory = AlaveteliConfiguration.debug_record_memory
    if record_memory
      logger.info "Processing request for #{request.url} with Rails process #{Process.pid}"
      File.read("/proc/#{Process.pid}/status").match(/VmRSS:\s+(\d+)/)
      rss_before_action = $1.to_i
      yield
      File.read("/proc/#{Process.pid}/status").match(/VmRSS:\s+(\d+)/)
      rss_after_action = $1.to_i
      logger.info("PID: #{Process.pid}\tCONSUME MEMORY: #{rss_after_action - rss_before_action} KB\tNow: #{rss_after_action} KB\t#{request.url}")
    else
      yield
    end
  end

  # Set cookie expiry according to "remember me" checkbox, as per "An easier
  # and more flexible hack" on this page:
  #   http://wiki.rubyonrails.org/rails/pages/HowtoChangeSessionOptions
  def session_remember_me
    # Reset the "sliding window" session expiry time.
    if request.env['rack.session.options']
      if session[:remember_me]
        request.env['rack.session.options'][:expire_after] = 1.month
      else
        request.env['rack.session.options'][:expire_after] = nil
      end
    end
  end

  # Set a TTL for non "remember me" sessions so that the cookie
  # is not replayable forever
  SESSION_TTL = 3.hours
  def validate_session_timestamp
    if session[:user_id] && session[:ttl] && session[:ttl] < SESSION_TTL.ago
      clear_session_credentials
    end
  end

  def persist_session_timestamp
    session[:ttl] = Time.zone.now if authenticated? && !session[:remember_me]
  end

  def sign_in(user, remember_me: nil)
    remember_me ||= session[:remember_me]
    clear_session_credentials
    session[:user_id] = user.id
    session[:user_login_token] = user.login_token
    session[:remember_me] = remember_me
    # Intentionally allow to fail silently so that we don't have to care whether
    # sign in recording is enabled.
    user.sign_ins.create(ip: user_ip, country: country_from_ip)
  end

  # Logout form
  def clear_session_credentials
    session[:admin_id] = nil
    session[:user_id] = nil
    session[:user_login_token] = nil
    session[:user_circumstance] = nil
    session[:remember_me] = false
    session[:using_admin] = nil
    session[:admin_name] = nil
    session[:change_password_post_redirect_id] = nil
    session[:post_redirect_token] = nil
    session[:ttl] = nil
  end

  def render_exception(exception)
    # In development let Rails handle the exception with its stack trace
    # templates.
    raise exception if Rails.application.config.consider_all_requests_local

    @exception_backtrace = exception.backtrace.join("\n")
    @exception_class = exception.class.to_s
    @exception_message = exception.message
    case exception
    when ActiveRecord::RecordNotFound, RouteNotFound, WillPaginate::InvalidPage
      @status = 404
      sanitize_path(params)
    when PermissionDenied
      @status = 403
    when ActionController::UnknownFormat
      @status = 406
    else
      message = "\n#{@exception_class} (#{@exception_message}):\n"
      backtrace = Rails.backtrace_cleaner.clean(exception.backtrace, :silent)
      message << "  " << backtrace.join("\n  ")
      Rails.logger.fatal("#{message}\n\n")
      if send_exception_notifications?
        ExceptionNotifier.notify_exception(exception, env: request.env)
      end
      @status = 500
    end
    respond_to do |format|
      format.html { render template: "general/exception_caught", status: @status }
      format.any { head @status }
    end
  end

  def render_hidden(template='request/hidden', opts = {})
    # An embargoed is totally hidden - no indication that anything exists there
    # to see
    raise ActiveRecord::RecordNotFound if @info_request && @info_request.embargo

    response_code = opts.delete(:response_code) { 403 } # forbidden
    options = { template: template, status: response_code }.merge(opts)

    respond_to do |format|
      format.html { render(options) }
      format.any { head response_code }
    end
    false
  end

  # A helper method to set @in_pro_area, for controller actions which are
  # used in both a pro and non-pro context and depend on the :pro parameter
  # to know which one they're displaying.
  # Intended to be used as a before_action, see RequestController for example
  # usage.
  def set_in_pro_area
    @in_pro_area = params[:pro] == "1" && current_user.present? && current_user.is_pro?
  end

  private

  # Override the Rails method to only set the CSRF form token if there is a
  # logged in user
  def form_authenticity_token(**args)
    super if authenticated?
  end

  # Check the user is logged in
  def authenticated?(as: nil)
    if as
      authenticated_user == as
    else
      authenticated_user.present?
    end
  end

  def ask_to_login(as: nil, **reason_params)
    if as
      reason_params[:user_name] = as.name
      reason_params[:user_url] = show_user_url(url_name: as.url_name)

      if authenticated?
        # They are already logged in, but as the wrong user
        @reason_params = reason_params
        render(template: 'user/wrong_user') && return
      end
    end

    post_redirect = reason_params.delete(:post_redirect)
    post_redirect ||= PostRedirect.new(uri: request.fullpath,
                                       post_params: params,
                                       reason_params: reason_params)
    post_redirect.save!

    # Make sure this redirect does not get cached - it only applies to this user
    # HTTP 1.1
    headers['Cache-Control'] = 'private, no-cache, no-store, max-age=0, must-revalidate'
    # HTTP 1.0
    headers['Pragma'] = 'no-cache'
    # Proxies
    headers['Expires'] = '0'

    # 'modal' controls whether the sign-in form will be displayed in the typical
    # full-blown page or on its own, useful for pop-ups
    redirect_to signin_url(token: post_redirect.token, modal: params[:modal])

    false
  end

  # Return logged in user
  def authenticated_user
    return unless session[:user_id]
    @user ||= User.authenticate_from_session(session)
  end

  # For CanCanCan and other libs which need a Devise-like current_user method
  alias current_user authenticated_user
  helper_method :current_user

  # Do a POST redirect. This is a nasty hack - we store the posted values in
  # the session, and when the GET redirect with "?post_redirect=1" happens,
  # load them in.
  def do_post_redirect(post_redirect, user=nil)
    uri = SafeRedirect.new(post_redirect.uri).path
    if feature_enabled?(:alaveteli_pro) &&
       user &&
       user.is_pro? &&
       session[:admin_confirmation] != 1
      uri = override_post_redirect_for_pro(uri,
                                           post_redirect,
                                           user)
    end
    session[:post_redirect_token] = post_redirect.token
    uri = add_post_redirect_param_to_uri(uri)
    session.delete(:admin_confirmation)
    redirect_to uri
  end

  def add_post_redirect_param_to_uri(uri)
    add_query_params_to_url(uri, post_redirect: 1)
  end

  # If we are in a faked redirect to POST request, then set post params.
  def check_in_post_redirect
    if params[:post_redirect]
      if session[:post_redirect_token]
        post_redirect =
          PostRedirect.find_by_token(session[:post_redirect_token])
        if post_redirect
          post_redirect_params =
            params_to_unsafe_hash(post_redirect.post_params)
          params.merge!(post_redirect_params)
          params[:post_redirect_user] = post_redirect.user
        end
      else
        logger.warn "Missing post redirect token. " \
                    "Session: #{session.to_hash} " \
                    "IP: #{user_ip} " \
                    "Params: #{params}"
      end
    end
  end

  def html_response
    respond_to :html
  end

  # Default layout shows user in corner, so needs access to it
  def authentication_check
    @user ||= authenticated_user
  end

  #
  def check_read_only
    unless AlaveteliConfiguration.read_only.empty?
      if feature_enabled?(:annotations)
        flash[:notice] = {
          partial: "general/read_only_annotations",
          locals: {
            site_name: site_name,
            read_only: AlaveteliConfiguration.read_only
          }
        }
      else
        flash[:notice] = {
          partial: "general/read_only",
          locals: {
            site_name: site_name,
            read_only: AlaveteliConfiguration.read_only
          }
        }
      end
      redirect_to frontpage_url
    end
  end

  # Convert URL name for sort by order, to Xapian query
  def order_to_sort_by(sortby)
    if sortby.nil?
      [nil, nil]
    elsif sortby == 'newest'
      ['created_at', true]
    elsif sortby == 'described'
      ['described_at', true] # use this for some RSS
    elsif sortby == 'relevant'
      [nil, nil]
    else
      raise "Unknown sort order " + @sortby
    end
  end

  # Function for search
  def perform_search(models, query, sortby, collapse, per_page = 25, this_page = nil)
    @query = query
    @sortby = sortby

    # Work out sorting method
    order, ascending = order_to_sort_by(@sortby)

    # Perform the search
    @per_page = per_page
    @page = this_page || get_search_page_from_params

    result = ActsAsXapian::Search.new(models, @query,
                                      offset: (@page - 1) * @per_page,
                                      limit: @per_page,
                                      sort_by_prefix: order,
                                      sort_by_ascending: ascending,
                                      collapse_by_prefix: collapse
                                      )
    result.results # Touch the results to load them, otherwise accessing them from the view
    # might fail later if the database has subsequently been reopened.
    result
  end

  def get_search_page_from_params
    page = (params[:page] || "1").to_i
    page = 1 if page < 1
    page
  end

  def typeahead_search(query, options)
    @page = get_search_page_from_params
    @per_page = options[:per_page] || 25
    options.merge!( page: @page,
                    per_page: @per_page )
    typeahead_search = TypeaheadSearch.new(query, options)
    typeahead_search.xapian_search
  end

  # Store last visited pages, for contact form; but only for logged in users, as otherwise this breaks caching
  def set_last_request(info_request)
    return unless authenticated?

    cookies["last_request_id"] = info_request.id
    cookies["last_body_id"] = nil
  end

  def set_last_body(public_body)
    return unless authenticated?

    cookies["last_request_id"] = nil
    cookies["last_body_id"] = public_body.id
  end

  def country_from_ip
    return AlaveteliGeoIP.country_code_from_ip(user_ip) if user_ip

    AlaveteliConfiguration.iso_country_code
  end

  def user_ip
    request.remote_ip
  rescue ActionDispatch::RemoteIp::IpSpoofAttackError
    nil
  end

  # URL Encode the path parameter for use in render_exception
  #
  # params - the params Hash
  #
  # Returns a Hash
  def sanitize_path(params)
    params.merge!(path: Rack::Utils.escape(params[:path])) if params.key?(:path)
  end

  # Collect the current and available locales for the locale switcher
  #
  # Returns a Hash
  def collect_locales
    @locales = { current: AlaveteliLocalization.locale, available: [] }
    AlaveteliLocalization.available_locales.each do |possible_locale|
      if possible_locale == AlaveteliLocalization.locale
        @locales[:current] = possible_locale
      else
        @locales[:available] << possible_locale
      end
    end
  end

  # URL generating functions are needed by all controllers (for redirects),
  # views (for links) and mailers (for use in emails), so include them into
  # all of all.
  include LinkToHelper

  # Site-wide access to configuration settings
  include ConfigHelper

  include HashableParams
end