roqua/quby_engine

View on GitHub
app/controllers/quby/answers_controller.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

# -*- coding: utf-8 -*-
require 'addressable/uri'
require 'browser'

module Quby
  class AnswersController < Quby::ApplicationController
    DISPLAY_MODES = %w(paged bulk single_page).freeze

    before_action :load_token_and_hmac_and_timestamp
    before_action :load_return_url_and_token
    before_action :load_custom_stylesheet
    before_action :load_display_mode

    before_action :verify_answer_id_against_session
    before_action :verify_hmac, only: [:edit, :pdf]

    before_action :find_questionnaire
    before_action :check_questionnaire_valid

    before_action :find_answer
    before_action :verify_token, only: [:show, :edit, :update]

    before_action :check_aborted, only: :update

    rescue_from TokenValidationError,                                           with: :bad_authorization
    rescue_from TimestampValidationError,                                       with: :bad_authorization
    rescue_from TimestampExpiredError,                                          with: :bad_authorization
    rescue_from InvalidAuthorizationError,                                      with: :bad_authorization
    rescue_from MissingAuthorizationError,                                      with: :bad_authorization
    rescue_from Quby::Questionnaires::Repos::QuestionnaireNotFound,             with: :bad_questionnaire
    rescue_from InvalidQuestionnaireDefinitionError,                            with: :bad_questionnaire_definition
    rescue_from Quby::Questionnaires::Entities::Questionnaire::ValidationError, with: :bad_questionnaire_definition

    def show
      redirect_to action: "edit"
    end

    def edit
      default_to_textvar_values(@answer)
      render versioned_template_options(@display_mode)
    end

    def update
      if session[:has_downloaded_pdf_for] == @answer.id && defined?(Appsignal) && on_ios_safari?
        Appsignal.increment_counter("ios_safari_downloaded_pdf_and_pressed_done", 1)
      end

      update_or_fail do
        if @return_url.blank?
          render versioned_template_options("completed", layout: request.xhr? ? "content_only" : 'application')
        else
          redirect_url = return_url(status: 'updated', go: form_action)
          request.xhr? ?
            render(js: "window.location = '#{redirect_url}'") :
            redirect_to(redirect_url)
        end
      end
    end

    def pdf
      if defined?(Appsignal) && on_ios_safari?
        session[:has_downloaded_pdf_for] = @answer.id
        Appsignal.increment_counter("ios_safari_downloaded_pdf", 1)
      end

      update_or_fail do
        template_string = render_to_string versioned_template_options("print", layout: "pdf")
        begin
          pdf_binary = Quby::PdfRenderer.render_pdf(template_string)
          # type is not a application/pdf, to prevent previews on ios
          send_data pdf_binary, filename: "#{@questionnaire.title} #{Time.zone.now.to_s(:filename)}.pdf",
                              type: 'application/octet-stream', disposition: :attachment
        rescue StandardError
          flash.now[:notice] = I18n.t('pdf_download_failed_message')
          render versioned_template_options(@display_mode, layout: request.xhr? ? "content_only" : 'application')
        end
      end
    end

    def bad_authorization(exception)
      if @return_url
        redirect_to return_url(status: 'error', error: exception.class.to_s)
      else
        @error = "U probeert een vragenlijst te openen waar u geen toegang toe heeft op dit moment."
        render template: 'quby/errors/generic', layout: 'quby/dialog'
        handle_exception exception
      end
    end

    def bad_questionnaire(exception)
      if @return_url
        redirect_to return_url(status: 'error', error: exception.class.to_s)
      else
        @error = exception
        render template: "quby/errors/questionnaire_not_found", layout: "quby/dialog", status: 404
      end
    end

    def bad_questionnaire_definition(exception)
      if Quby.show_exceptions
        render action: :show_questionnaire_errors
      elsif @return_url
        redirect_to return_url(status: 'error', error: exception.class.to_s)
        handle_exception exception
      else
        @error = "Er is iets mis met de vragenlijst zoals deze in ons systeem is ingebouwd."
        render template: 'quby/errors/generic', layout: 'quby/dialog'
        handle_exception exception
      end
    end

    protected

    def update_or_fail
      updater = Answers::Services::UpdatesAnswers.new(@answer)

      updater.on_success do
        yield
      end

      updater.on_failure do
        flash.now[:notice] = "De vragenlijst is nog niet volledig ingevuld." if @display_mode == "paged"
        render versioned_template_options(@display_mode, layout: request.xhr? ? "content_only" : 'application')
      end

      updater.update((params[:answer] || {}).merge("rendered_at" => params[:rendered_at]))
    end

    def find_questionnaire
      if params[:questionnaire_id]
        @questionnaire = Quby.questionnaires.find(params[:questionnaire_id])
      end
    end

    def check_questionnaire_valid
      # don't use valid?, since it clears the errors
      return if @questionnaire.errors.size == 0
      fail InvalidQuestionnaireDefinitionError
    end

    def find_answer
      @answer = Quby.answers.find(@questionnaire.key, params[:id])
    end

    def check_aborted
      if (params[:abort] && @questionnaire.abortable) ||
        (params[:save_anyway] && (@display_mode == "bulk" || @display_mode == "single_page")) ||
        (params[:previous_questionnaire])
        params[:answer] ||= HashWithIndifferentAccess.new
        params[:answer][:aborted] = true
      else
        params[:answer] ||= HashWithIndifferentAccess.new
        params[:answer][:aborted] = false
      end
    end

    def verify_answer_id_against_session
      if Quby::Settings.authorize_with_id_from_session
        fail MissingAuthorizationError unless session[:quby_answer_id].present?
        fail InvalidAuthorizationError unless params[:id].to_s == session[:quby_answer_id].to_s
      end
    end

    def verify_token
      if Quby::Settings.authorize_with_hmac
        fail InvalidAuthorizationError unless @answer.token == (params[:token] || @answer_token)
      end
    end

    def verify_hmac # rubocop:disable CyclomaticComplexity
      if Quby::Settings.authorize_with_hmac
        fail TokenValidationError, "No HMAC secret is configured" unless Quby::Settings.shared_secret.present?
        hmac      = (params['hmac']      || @hmac         || '').strip
        token     = (params['token']     || @answer_token || '').strip
        timestamp = (params['timestamp'] || @timestamp    || '').strip

        current_hmac  = calculate_hmac(Quby::Settings.shared_secret, token, timestamp)

        if Quby::Settings.previous_shared_secret.present?
          previous_hmac = calculate_hmac(Quby::Settings.previous_shared_secret, token, timestamp)
        end

        unless timestamp =~ /^\d\d\d\d-?\d\d-?\d\d[tT ]?\d?\d:?\d\d/ and time = Time.parse(timestamp)
          logger.error "ERROR::Authentication error: Invalid timestamp."
          fail TimestampValidationError
        end

        if time < 24.hours.ago or 1.hour.since < time
          logger.error "ERROR::Authentication error: Request expired"
          fail TimestampExpiredError
        end

        if current_hmac != hmac && (previous_hmac.blank? || previous_hmac != hmac)
          logger.error "ERROR::Authentication error: Token does not validate"
          fail TokenValidationError, "HMAC"
        end
      end
    end # rubocop:enable CyclomaticComplexity

    def load_token_and_hmac_and_timestamp
      @answer_token = params[:token]     if params[:token]
      @hmac         = params[:hmac]      if params[:hmac]
      @timestamp    = params[:timestamp] if params[:timestamp]
    end

    def load_return_url_and_token
      if params[:return_url]
        @return_url   = CGI.unescape(params[:return_url])
        @return_token = params[:return_token]
      end
    end

    def load_display_mode
      @display_mode = params[:display_mode] if DISPLAY_MODES.include? params[:display_mode]
      @display_mode ||= 'paged'
    end

    def load_custom_stylesheet
      @custom_stylesheet = params[:stylesheet]
    end

    def versioned_template_options(template_name, options = {})
      {template: "quby/#{@questionnaire.renderer_version}/#{template_name}",
       layout: "quby/#{@questionnaire.renderer_version}/layouts/#{options.fetch(:layout, "application")}"}
    end

    def form_action
      if params[:abort]
        'stop'
      elsif params[:previous_questionnaire]
        'back'
      else
        'next'
      end
    end

    def return_url(options = {})
      address = Addressable::URI.parse(@return_url)

      # Addressable behaves strangely if were to do this directly on
      # it's own hash, hence the (otherwise unneeded) temporary variable
      query_values = (address.query_values || {})
      query_values.merge!(key: @return_token, return_from: "quby")
      query_values.merge!(return_from_answer: params[:id])
      options.each { |key, value| query_values[key] = value if value }

      address.query_values = query_values

      logger.info address.to_s
      address.to_s
    end

    def calculate_hmac(*args)
      Digest::SHA1.hexdigest(args.join('|'))
    end

    def default_to_textvar_values(answer)
      @questionnaire.questions.each do |question|
        textvar = question.sets_textvar or next
        if answer.textvars.key?(textvar.to_sym) && !answer.value.key?(question.key.to_s)
          answer.value[question.key.to_s] ||= answer.textvars[textvar.to_sym]
        end
      end
    end

    # I18n.t based on the questionnaire's configuration.
    # This is done instead of configuring I18n.locale to allow for example
    # English questionnaires within a Dutch RoQua.
    def translate(key, options = {})
      I18n.t(key, options.merge(locale: @questionnaire.language))
    end
    helper_method :translate

    def on_ios_safari?
      browser = Browser.new(request.user_agent)
      browser.safari? && browser.platform.ios?
    end
  end
end