lib/rodauth/features/internal_request.rb



# frozen-string-literal: true

require 'stringio'

module Rodauth
  INVALID_DOMAIN = "invalidurl @@.com"

  class InternalRequestError < StandardError
    attr_accessor :flash
    attr_accessor :reason
    attr_accessor :field_errors

    def initialize(attrs)
      return super if attrs.is_a?(String)

      @flash = attrs[:flash]
      @reason = attrs[:reason]
      @field_errors = attrs[:field_errors] || {}

      super(build_message)
    end

    private

    def build_message
      extras = []
      extras << reason if reason
      extras << field_errors unless field_errors.empty?
      extras = (" (#{extras.join(", ")})" unless extras.empty?)

      "#{flash}#{extras}"
    end
  end

  module InternalRequestMethods
    attr_accessor :session
    attr_accessor :params
    attr_reader :flash
    attr_accessor :internal_request_block

    def domain
      d = super
      if d == INVALID_DOMAIN
        raise InternalRequestError, "must set domain in configuration, as it cannot be determined from internal request"
      end
      d
    end

    def raw_param(k)
      @params[k]
    end

    def set_error_flash(message)
      @flash = message
      _handle_internal_request_error
    end
    alias set_redirect_error_flash set_error_flash

    def set_notice_flash(message)
      @flash = message
    end
    alias set_notice_now_flash set_notice_flash

    def modifications_require_password?
      false
    end
    alias require_login_confirmation? modifications_require_password?
    alias require_password_confirmation? modifications_require_password?
    alias change_login_requires_password? modifications_require_password?
    alias change_password_requires_password? modifications_require_password?
    alias close_account_requires_password? modifications_require_password?
    alias two_factor_modifications_require_password? modifications_require_password?

    def otp_setup_view
      hash = {:otp_setup=>otp_user_key}
      hash[:otp_setup_raw] = otp_key if hmac_secret
      _return_from_internal_request(hash)
    end

    def add_recovery_codes_view
      _return_from_internal_request(recovery_codes)
    end

    def handle_internal_request(meth)
      catch(:halt) do
        _around_rodauth do
          before_rodauth
          send(meth, request)
        end
      end

      @internal_request_return_value
    end

    def only_json?
      false
    end

    private

    def internal_request?
      true
    end

    def set_error_reason(reason)
      @error_reason = reason
    end

    def after_login
      super
      _set_internal_request_return_value(account_id) unless @return_false_on_error
    end

    def after_remember
      super
      if params[remember_param] == remember_remember_param_value
        _set_internal_request_return_value("#{account_id}_#{convert_token_key(remember_key_value)}")
      end
    end

    def after_load_memory
      super
      _return_from_internal_request(session_value)
    end

    def before_change_password_route
      super
      params[new_password_param] ||= params[password_param]
    end

    def before_email_auth_request_route
      super
      _set_login_param_from_account
    end

    def before_login_route
      super
      _set_login_param_from_account
    end

    def before_unlock_account_request_route
      super
      _set_login_param_from_account
    end

    def before_reset_password_request_route
      super
      _set_login_param_from_account
    end

    def before_verify_account_resend_route
      super
      _set_login_param_from_account
    end

    def account_from_key(token, status_id=nil)
      return super unless session_value
      return unless yield session_value
      ds = account_ds(session_value)
      ds = ds.where(account_status_column=>status_id) if status_id && !skip_status_checks?
      ds.first
    end

    def _set_internal_request_return_value(value)
      @internal_request_return_value = value
    end

    def _return_from_internal_request(value)
      _set_internal_request_return_value(value)
      throw(:halt)
    end

    def _handle_internal_request_error
      if @return_false_on_error
        _return_from_internal_request(false)
      else
        raise InternalRequestError.new(flash: @flash, reason: @error_reason, field_errors: @field_errors)
      end
    end

    def _return_false_on_error!
      @return_false_on_error = true
    end

    def _set_login_param_from_account
      if session_value && !params[login_param] && (account = account_ds(session_value).first)
        params[login_param] = account[login_column]
      end
    end

    def _get_remember_cookie
      params[remember_param]
    end

    def _handle_internal_request_eval(_)
      v = instance_eval(&internal_request_block)
      _set_internal_request_return_value(v) unless defined?(@internal_request_return_value)
    end

    def _handle_account_id_for_login(_)
      raise InternalRequestError, "no login provided" unless login = param_or_nil(login_param)
      raise InternalRequestError, "no account for login" unless account = account_from_login(login)
      _return_from_internal_request(account[account_id_column])
    end

    def _handle_account_exists?(_)
      raise InternalRequestError, "no login provided" unless login = param_or_nil(login_param)
      _return_from_internal_request(!!account_from_login(login))
    end

    def _handle_lock_account(_)
      raised_uniqueness_violation{account_lockouts_ds(session_value).insert(_setup_account_lockouts_hash(session_value, generate_unlock_account_key))}
    end

    def _handle_remember_setup(request)
      params[remember_param] = remember_remember_param_value
      _handle_remember(request)
    end

    def _handle_remember_disable(request)
      params[remember_param] = remember_disable_param_value
      _handle_remember(request)
    end

    def _handle_account_id_for_remember_key(request)
      load_memory
      raise InternalRequestError, "invalid remember key"
    end

    def _handle_otp_setup_params(request)
      request.env['REQUEST_METHOD'] = 'GET'
      _handle_otp_setup(request)
    end

    def _predicate_internal_request(meth, request)
      _return_false_on_error!
      _set_internal_request_return_value(true)
      send(meth, request)
    end

    def _handle_valid_login_and_password?(request)
      _predicate_internal_request(:_handle_login, request)
    end

    def _handle_valid_email_auth?(request)
      _predicate_internal_request(:_handle_email_auth, request)
    end

    def _handle_valid_otp_auth?(request)
      _predicate_internal_request(:_handle_otp_auth, request)
    end

    def _handle_valid_recovery_auth?(request)
      _predicate_internal_request(:_handle_recovery_auth, request)
    end

    def _handle_valid_sms_auth?(request)
      _predicate_internal_request(:_handle_sms_auth, request)
    end
  end

  module InternalRequestClassMethods
    def internal_request(route, opts={}, &block)
      opts = opts.dup
      
      env = {
         'REQUEST_METHOD'=>'POST',
         'PATH_INFO'=>'/',
         "SCRIPT_NAME" => "",
         "HTTP_HOST" => INVALID_DOMAIN,
         "SERVER_NAME" => INVALID_DOMAIN,
         "SERVER_PORT" => 443,
         "CONTENT_TYPE" => "application/x-www-form-urlencoded",
         "rack.input"=>StringIO.new(''),
         "rack.url_scheme"=>"https"
      }
      env.merge!(opts.delete(:env)) if opts[:env]

      session = {}
      session.merge!(opts.delete(:session)) if opts[:session]

      params = {}
      params.merge!(opts.delete(:params)) if opts[:params]

      scope = roda_class.new(env)
      rodauth = new(scope)
      rodauth.session = session
      rodauth.params = params
      rodauth.internal_request_block = block

      unless account_id = opts.delete(:account_id)
        if (account_login = opts.delete(:account_login))
          if (account = rodauth.send(:_account_from_login, account_login))
            account_id = account[rodauth.account_id_column]
          else
            raise InternalRequestError, "no account for login: #{account_login.inspect}"
          end
        end
      end

      if account_id
        session[rodauth.session_key] = account_id
        unless authenticated_by = opts.delete(:authenticated_by)
          authenticated_by = case route
          when :otp_auth, :sms_request, :sms_auth, :recovery_auth, :valid_otp_auth?, :valid_sms_auth?, :valid_recovery_auth?
            ['internal1']
          else
            ['internal1', 'internal2']
          end
        end
        session[rodauth.authenticated_by_session_key] = authenticated_by
      end

      opts.keys.each do |k|
        meth = :"#{k}_param"
        params[rodauth.public_send(meth).to_s] = opts.delete(k) if rodauth.respond_to?(meth)
      end

      unless opts.empty?
        warn "unhandled options passed to #{route}: #{opts.inspect}"
      end

      rodauth.handle_internal_request(:"_handle_#{route}")
    end
  end

  Feature.define(:internal_request, :InternalRequest) do
    configuration_module_eval do
      def internal_request_configuration(&block)
        @auth.instance_exec do
          (@internal_request_configuration_blocks ||= []) << block
        end
      end
    end

    def post_configure
      super

      return if is_a?(InternalRequestMethods)

      klass = self.class
      internal_class = Class.new(klass)

      if blocks = klass.instance_variable_get(:@internal_request_configuration_blocks)
        configuration = internal_class.configuration
        blocks.each do |block|
          configuration.instance_exec(&block)
        end
      end
      internal_class.send(:extend, InternalRequestClassMethods)
      internal_class.send(:include, InternalRequestMethods)
      internal_class.allocate.post_configure

      ([:base] + internal_class.features).each do |feature_name|
        feature = FEATURES[feature_name]
        if meths = feature.internal_request_methods
          meths.each do |name|
            klass.define_singleton_method(name){|opts={}, &block| internal_class.internal_request(name, opts, &block)}
          end
        end
      end

      klass.const_set(:InternalRequest, internal_class)
      klass.private_constant :InternalRequest
    end
  end
end