# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

cs__scoped_require 'resolv'
cs__scoped_require 'timeout'

cs__scoped_require 'contrast/utils/object_share'
cs__scoped_require 'contrast/utils/string_utils'
cs__scoped_require 'contrast/utils/hash_digest'
cs__scoped_require 'contrast/components/interface'

module Contrast
  module Agent
    # This class is the Contrast representation of the Rack::Request object. It
    # provides access to the original Rack::Request object as well as extracts
    # data in a format that the Agent expects, caching those transformations in
    # order to avoid repeatedly creating Strings & thrashing GC.
    class Request
      include Contrast::Components::Interface
      access_component :agent, :logging, :scope

      extend Forwardable

      INNER_REST_TOKEN = %r{/[\d]+/}.cs__freeze
      LAST_REST_TOKEN = %r{/[\d]+$}.cs__freeze
      INNER_NUMBER_MARKER = '/{n}/'
      LAST_NUMBER_MARKER = '/{n}'
      OMITTED_BODY = '{{body-omitted-by-contrast}}'

      attr_reader :rack_request

      def_delegators :@rack_request,
                     :env,
                     :query_string,
                     :user_agent,
                     :path,
                     :base_url

      # receiver is memoized because it is the address/host/port of the server, once we
      # resolve this for the first time, it shouldn't change
      def self.receiver
        @_receiver ||= build_receiver
      end

      def initialize rack_request
        @rack_request = rack_request
      end

      ACCEPT = 'ACCEPT'
      def accept_headers
        accepts = Array(normalized_request_headers[ACCEPT])
        accepts.any? ? accepts : nil
      end

      STATIC_SUFFIXES = /\.(?:js|css|jpeg|jpg|gif|png|ico|woff|svg|pdf|eot|ttf|jar)$/i.cs__freeze
      WILDCARD = '*/*'
      # Utility method for checking if a request is for a static resource.
      # @return [Boolean] true, if the request is for a well-known static
      #  type, like the following, and false otherwise: .js, .css, .jpg,
      # .gif, .png, .ico
      def static_request?
        return true if trimmed_uri&.match?(STATIC_SUFFIXES)

        accepts = accept_headers
        if accepts
          return false if accepts[0].to_s.start_with?(WILDCARD)
          return true if media_content_type?(accepts[0])
        end

        false
      end

      MEDIA_TYPE_MARKERS = %w[image/ text/css text/javascript].cs__freeze
      def media_content_type? str
        str = str.to_s
        str.start_with?(*MEDIA_TYPE_MARKERS)
      end

      def trimmed_uri
        @_trimmed_uri ||= begin
          raise ArgumentError, 'url was nil when attempting to trim' unless uri

          trimmed = uri.split(Contrast::Utils::ObjectShare::SEMICOLON).first # remove ;jsessionid
          trimmed.split(Contrast::Utils::ObjectShare::QUESTION_MARK).first # remove ?query_string=
        end
      end

      # Returns a normalized form of the URI. In "normal" URIs
      # this will return an unchanged String, but in REST-y
      # URIs this will normalize the digit path tokens, e.g.:
      #
      # /accounts/5/view
      # ...becomes:
      # /accounts/{n}/view
      #
      # Should also handle the ;jsessionid.
      def normalized_uri
        @_normalized_uri ||= begin
          uri = trimmed_uri
          uri = uri.gsub(INNER_REST_TOKEN, INNER_NUMBER_MARKER) # replace interior tokens
          uri.gsub(LAST_REST_TOKEN, LAST_NUMBER_MARKER) # replace last token
        end
      end

      UNKNOWN_REQUEST_METHOD = 'UNKNOWN'

      def request_method
        rack_request.get_header(Rack::REQUEST_METHOD)
      rescue StandardError => e
        logger.warn('Unable to extract request method', e)
        UNKNOWN_REQUEST_METHOD
      end

      def document_type_from_header
        case content_type
        when /xml/i
          :XML
        when /json/i
          :JSON
        when /html/i
          :NORMAL
        end
      end

      def document_type
        @_document_type ||= begin
          type = document_type_from_header
          if type
            type
          elsif request_body_str.start_with?('<?xml')
            :XML
          elsif request_body_str.match?(/\s*[{\[]/)
            :JSON
          else
            :NORMAL
          end
        end
      end

      def request_headers
        @_request_headers ||= begin
          with_contrast_scope do
            headers = header_pairs(env).each_with_object({}) do |pair, h|
              h[pair[0]] = pair[1]
            end
            headers
          end
        end
      end

      # Header keys upcased and any underscores replaced with dashes
      def normalized_request_headers
        @_normalized_request_headers ||= begin
          hash = {}
          request_headers.each_pair do |header_name, header_value|
            hash[Contrast::Utils::StringUtils.normalized_key(header_name)] = header_value
          end
          hash
        end
      end

      # Return a flattened hash of params with realized paths for keys, in
      # addition to a separate, valueless, entry for each nest key.
      # See RUBY-621 for more details.
      # { key : { nested_key : ['x','y','z' ] } }
      # becomes
      # {
      #   key[nested_key][0] : 'x'
      #   key[nested_key][1] : 'y'
      #   key[nested_key][2] : 'z'
      #   key :                ''
      #   nested_key :         ''
      # }
      def normalize_params val, prefix: nil
        # In non-recursive invocations, val should always be a Hash
        # (rather than breaking this out into two methods)
        case val
        when Tempfile
          { prefix => Contrast::Utils::StringUtils.force_utf8(val.path) }
        when Hash
          res = val.each_with_object({}) do |(k, v), hash|
            k = Contrast::Utils::StringUtils.force_utf8(k)
            nested_prefix = prefix.nil? ? k : "#{ prefix }[#{ k }]"
            hash[k] = Contrast::Utils::ObjectShare::EMPTY_STRING
            hash.merge! normalize_params(v, prefix: nested_prefix)
          end
          res[prefix] = Contrast::Utils::ObjectShare::EMPTY_STRING if prefix
          res
        when Enumerable
          res = val.each_with_index.each_with_object({}) do |(v, i), hash|
            hash.merge! normalize_params(v, prefix: "#{ prefix }[#{ i }]")
          end
          res[prefix] = Contrast::Utils::ObjectShare::EMPTY_STRING if prefix
          res
        else
          { prefix => Contrast::Utils::StringUtils.force_utf8(val) }
        end
      end

      def request_body
        # Memoize a flag indicating whether we've tried to read the body or not
        # (can't use body because it might be nil)
        @_request_body_read ||= begin
          body = @rack_request.body
          if defined?(Rack::Multipart)
            if defined?(Rack::Multipart::UploadedFile) && body.is_a?(Rack::Multipart::UploadedFile)
              logger.trace("not parsing uploaded file body :: #{ body.original_filename }::#{ body.content_type }")
              @_request_body = nil
            else
              logger.trace("parsing body from request :: #{ body.cs__class.cs__name }")
              @_request_body = Contrast::Utils::StringUtils.force_utf8(read_body(body))
            end
          else
            logger.trace('Rack before 1.3.x does not support Rack::Multipart')
            @_request_body = Contrast::Utils::StringUtils.force_utf8(read_body(body))
          end

          true
        end

        # Return memoized body (which might be nil)
        @_request_body
      end

      def request_body_str
        request_body.to_s || Contrast::Utils::ObjectShare::EMPTY_STRING
      end

      # This will become to_protobuf
      # Expectation is that all data from a previous phase will be populated
      # before the subsequent one begins
      def dtm
        @_dtm ||= begin
          with_contrast_scope do
            http_request = Contrast::Api::Dtm::HttpRequest.new
            http_request.uuid = Contrast::Utils::StringUtils.force_utf8(__id__)
            http_request.timestamp_ms = Contrast::Utils::Timer.now_ms.to_i

            append_receiver(http_request)
            append_connection(http_request)
            append_params(http_request)
            append_headers(http_request)
            append_body(http_request)

            http_request
          end
        end
      end

      def append_receiver http_request
        r = cs__class.receiver
        r.port = port.to_i if port
        http_request.receiver = r unless r.nil?
      end

      def append_connection http_request
        http_request.sender = Contrast::Api::Dtm::Address.new
        http_request.sender.ip = Contrast::Utils::StringUtils.force_utf8(ip)
        http_request.protocol = Contrast::Utils::StringUtils.force_utf8(scheme)
        http_request.version = '1.1' # currently not in rack request; hard-coding
        http_request.method = Contrast::Utils::StringUtils.force_utf8(request_method)
        http_request.raw = Contrast::Utils::StringUtils.force_utf8(@rack_request.path_info)
        http_request.parsed_connection = false
      end

      def append_params http_request
        parameters.each do |k, v|
          next unless k && v
          next if v.is_a?(Hash)

          key = Contrast::Utils::StringUtils.force_utf8(k)
          val = Contrast::Utils::StringUtils.force_utf8(v)
          params = http_request.normalized_request_params
          params[key] = Contrast::Api::Dtm::Pair.new unless params[key].is_a?(Contrast::Api::Dtm::Pair)
          params[key].key = key
          params[key].values << val
        end
      end

      def append_headers http_request
        request_headers.each do |k, v|
          next unless k && v
          next if v.is_a?(Hash)

          key = Contrast::Utils::StringUtils.force_utf8(k)
          val = Contrast::Utils::StringUtils.force_utf8(v)
          http_request.request_headers[key] = val
        end

        http_request.parsed_request_headers = true

        normalized_request_headers.each do |k, v|
          next unless k && v
          next if v.is_a?(Hash)

          key = Contrast::Utils::StringUtils.force_utf8(k)
          val = Contrast::Utils::StringUtils.force_utf8(v)
          http_request.normalized_request_headers[key] ||= Contrast::Api::Dtm::Pair.new
          http_request.normalized_request_headers[key].key = key
          http_request.normalized_request_headers[key].values << val
        end

        request_cookies.each do |k, v|
          next unless k && v
          next if v.is_a?(Hash)

          key = Contrast::Utils::StringUtils.force_utf8(k)
          val = Contrast::Utils::StringUtils.force_utf8(v)
          http_request.normalized_cookies[key] ||= Contrast::Api::Dtm::Pair.new
          http_request.normalized_cookies[key].key = key
          http_request.normalized_cookies[key].values << val
        end
      end

      def append_body http_request
        http_request.document_type = Contrast::Utils::StringUtils.force_utf8(document_type)

        http_request.request_body = if omit_body?
                                      OMITTED_BODY
                                    else
                                      Contrast::Utils::StringUtils.force_utf8(request_body)
                                    end
        return if file_names.empty?

        file_names.each do |name, filename|
          pair = Contrast::Api::Dtm::SimplePair.new
          pair.key = Contrast::Utils::StringUtils.force_utf8(name)
          pair.value = Contrast::Utils::StringUtils.force_utf8(filename)
          http_request.multipart_headers << pair
        end
      end

      def omit_body?
        return true if AGENT.omit_body?
        return false if document_type == :XML
        return false if document_type == :JSON

        content_type&.include?('multipart/form-data')
      end

      def self.build_receiver
        address = Contrast::Api::Dtm::Address.new
        address.host = 'localhost'
        address.ip = '127.0.0.1'
        begin
          Timeout.timeout(1) do
            address.host = Contrast::Utils::StringUtils.force_utf8(Socket.gethostname)
            address.ip = Contrast::Utils::StringUtils.force_utf8(Resolv.getaddress(address.host))
          end
        rescue StandardError => e
          logger.warn('Unable to resolve host or ip', e, address: address)
        end
        address
      end

      # Memoized Rack Request Values
      def ip
        @_ip ||= @rack_request.ip
      end

      def scheme
        @_scheme ||= @rack_request.scheme
      end

      def port
        @_port ||= @rack_request.port
      end

      def uri
        @_uri ||= @rack_request.path
      end

      def url
        @_url ||= @rack_request.url
      end

      def content_type
        @_content_type ||= @rack_request.content_type
      end

      def request_cookies
        @_request_cookies ||= @rack_request.cookies
      end

      def parameters
        @_parameters ||= with_contrast_scope { normalize_params(@rack_request.params) }
      end

      # End of Rack Request memoized values

      def file_names
        @_file_names ||= begin
                           names = {}
                           parsed_data = Rack::Multipart.parse_multipart(@rack_request.env)
                           traverse_parsed_multipart(parsed_data, names)
                         rescue StandardError => _e
                           logger.warn('Unable to parse multipart request!')
                           {}
                         end
      end

      def hash_id
        @_hash_id ||= Contrast::Utils::HashDigest.generate_request_hash(self)
      end

      # we just need to map length to a repeatable value
      # unlike Java, we hash with strings, so we'll use single character
      # strings for our purposes.
      CHARS = %w[a b c d e f g].cs__freeze
      def normalized_length_header chr
        chr = chr.to_s
        tmp = CHARS[Math.log10(chr.length).to_i] if chr
        tmp ||= CHARS[6]
        tmp
      end

      private

      HTTP_PREFIX = /^[Hh][Tt][Tt][Pp][_-]/i.cs__freeze

      def header_pairs env
        selected = env.select do |k, _v|
          k.to_s.start_with?(Contrast::Utils::ObjectShare::HTTP_SCORE)
        end
        selected.map do |k, v|
          name = k.to_s.sub(HTTP_PREFIX, Contrast::Utils::ObjectShare::EMPTY_STRING)
          [name, v]
        end
      end

      def read_body body
        return body if body.is_a?(String)

        begin
          can_rewind = Contrast::Utils::DuckUtils.quacks_to?(body, :rewind)
          # if we are after a middleware that failed to rewind
          body.rewind if can_rewind
          body.read
        rescue StandardError => e
          logger.error('Error in attempt to read body', message: e.message)
          logger.trace('With Stack', e)
          body.to_s
        ensure
          # be a good citizen and rewind
          body.rewind if can_rewind
        end
      end

      def traverse_parsed_multipart multipart_data, current_names
        return current_names unless multipart_data

        multipart_data.each_value do |data_value|
          next unless data_value.is_a?(Hash)

          tempfile = data_value[:tempfile]
          if tempfile.nil?
            traverse_parsed_multipart(data_value, current_names)
          else
            name = data_value[:name].to_s
            file_name = data_value[:filename].to_s
            current_names[name] = file_name
          end
        end
        current_names
      end
    end
  end
end