zammad/zammad

View on GitHub
lib/user_agent.rb

Summary

Maintainability
D
2 days
Test Coverage
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

require 'net/http'
require 'net/https'
require 'net/ftp'

class UserAgent

=begin

get http/https calls

  result = UserAgent.get('http://host/some_dir/some_file?param1=123')

  result = UserAgent.get(
    'http://host/some_dir/some_file?param1=123',
    {
      param1: 'some value',
    },
    {
      open_timeout: 4,
      read_timeout: 10,
      verify_ssl:   true,
      user:         'http basic auth username',
      password:     'http basic auth password',
      bearer_token: 'bearer token authentication',
    },
  )

returns

  result.body # as response

get json object

  result = UserAgent.get(
    'http://host/some_dir/some_file?param1=123',
    {},
    {
      json: true,
    }
  )

returns

  result.data # as json object

=end

  def self.get(url, params = {}, options = {}, count = 10)
    # Any params must be added to the URL for GET requests.
    uri  = parse_uri(url, params)
    http = get_http(uri, options)

    # prepare request
    request = Net::HTTP::Get.new(uri)

    # set headers
    request = set_headers(request, options)

    # http basic auth (if needed)
    request = set_basic_auth(request, options)

    # bearer token auth (if needed)
    request = set_bearer_token_auth(request, options)

    # add signature
    request = set_signature(request, options)

    # start http call
    begin
      total_timeout = options[:total_timeout] || 60

      handled_open_timeout(options[:open_socket_tries]) do
        Timeout.timeout(total_timeout) do
          response = http.request(request)
          return process(request, response, uri, count, params, options)
        end
      end
    rescue => e
      log(url, request, nil, options)
      Result.new(
        error:   e.inspect,
        success: false,
        code:    0,
      )
    end
  end

=begin

post http/https calls

  result = UserAgent.post(
    'http://host/some_dir/some_file',
    {
      param1: 1,
      param2: 2,
    },
    {
      open_timeout: 4,
      read_timeout: 10,
      verify_ssl:   true,
      user:         'http basic auth username',
      password:     'http basic auth password',
      bearer_token: 'bearer token authentication',
      total_timeout: 60,
    },
  )

returns

  result # result object

=end

  def self.post(url, params = {}, options = {}, count = 10)
    uri  = parse_uri(url)
    http = get_http(uri, options)

    # prepare request
    request = Net::HTTP::Post.new(uri)

    # set headers
    request = set_headers(request, options)

    # set params
    request = set_params(request, params, options)

    # http basic auth (if needed)
    request = set_basic_auth(request, options)

    # bearer token auth (if needed)
    request = set_bearer_token_auth(request, options)

    # add signature
    request = set_signature(request, options)

    # start http call
    begin
      total_timeout = options[:total_timeout] || 60

      handled_open_timeout(options[:open_socket_tries]) do
        Timeout.timeout(total_timeout) do
          response = http.request(request)
          return process(request, response, uri, count, params, options)
        end
      end
    rescue => e
      log(url, request, nil, options)
      Result.new(
        error:   e.inspect,
        success: false,
        code:    0,
      )
    end
  end

=begin

put http/https calls

  result = UserAgent.put(
    'http://host/some_dir/some_file',
    {
      param1: 1,
      param2: 2,
    },
    {
      open_timeout: 4,
      read_timeout: 10,
      verify_ssl:   true,
      user:         'http basic auth username',
      password:     'http basic auth password',
      bearer_token: 'bearer token authentication',
    },
  )

returns

  result # result object

=end

  def self.put(url, params = {}, options = {}, count = 10)
    uri  = parse_uri(url)
    http = get_http(uri, options)

    # prepare request
    request = Net::HTTP::Put.new(uri)

    # set headers
    request = set_headers(request, options)

    # set params
    request = set_params(request, params, options)

    # http basic auth (if needed)
    request = set_basic_auth(request, options)

    # bearer token auth (if needed)
    request = set_bearer_token_auth(request, options)

    # add signature
    request = set_signature(request, options)

    # start http call
    begin
      total_timeout = options[:total_timeout] || 60

      handled_open_timeout(options[:open_socket_tries]) do
        Timeout.timeout(total_timeout) do
          response = http.request(request)
          return process(request, response, uri, count, params, options)
        end
      end
    rescue => e
      log(url, request, nil, options)
      Result.new(
        error:   e.inspect,
        success: false,
        code:    0,
      )
    end
  end

=begin

delete http/https calls

  result = UserAgent.delete(
    'http://host/some_dir/some_file',
    {
      open_timeout: 4,
      read_timeout: 10,
      verify_ssl:   true,
      user:         'http basic auth username',
      password:     'http basic auth password',
      bearer_token: 'bearer token authentication',
    },
  )

returns

  result # result object

=end

  def self.delete(url, params = {}, options = {}, count = 10)
    uri  = parse_uri(url)
    http = get_http(uri, options)

    # prepare request
    request = Net::HTTP::Delete.new(uri)

    # set headers
    request = set_headers(request, options)

    # http basic auth (if needed)
    request = set_basic_auth(request, options)

    # bearer token auth (if needed)
    request = set_bearer_token_auth(request, options)

    # add signature
    request = set_signature(request, options)

    # start http call
    begin
      total_timeout = options[:total_timeout] || 60
      handled_open_timeout(options[:open_socket_tries]) do
        Timeout.timeout(total_timeout) do
          response = http.request(request)
          return process(request, response, uri, count, params, options)
        end
      end
    rescue => e
      log(url, request, nil, options)
      Result.new(
        error:   e.inspect,
        success: false,
        code:    0,
      )
    end
  end

=begin

perform get http/https/ftp calls

  result = UserAgent.request('ftp://host/some_dir/some_file.bin')

  result = UserAgent.request('http://host/some_dir/some_file.bin')

  result = UserAgent.request('https://host/some_dir/some_file.bin')

  # get request
  result = UserAgent.request(
    'http://host/some_dir/some_file?param1=123',
    {
      open_timeout: 4,
      read_timeout: 10,
    },
  )

returns

  result # result object

=end

  def self.request(url, options = {})

    uri = parse_uri(url)
    case uri.scheme.downcase
    when %r{ftp}
      ftp(uri, options)
    when %r{http|https}
      get(url, {}, options)
    end

  end

  def self.get_http(uri, options)

    proxy = options['proxy'] || Setting.get('proxy')
    proxy_no = options['proxy_no'] || Setting.get('proxy_no') || ''
    proxy_no = proxy_no.split(',').map(&:strip) || []
    proxy_no.push('localhost', '127.0.0.1', '::1')
    if proxy.present? && proxy_no.exclude?(uri.host.downcase)
      if proxy =~ %r{^(.+?):(.+?)$}
        proxy_host = $1
        proxy_port = $2
      end

      if proxy_host.blank? || proxy_port.blank?
        raise "Invalid proxy address: #{proxy} - expect e.g. proxy.example.com:3128"
      end

      proxy_username = options['proxy_username'] || Setting.get('proxy_username')
      if proxy_username.blank?
        proxy_username = nil
      end
      proxy_password = options['proxy_password'] || Setting.get('proxy_password')
      if proxy_password.blank?
        proxy_password = nil
      end

      http = Net::HTTP::Proxy(proxy_host, proxy_port, proxy_username, proxy_password).new(uri.host, uri.port)
    else
      http = Net::HTTP.new(uri.host, uri.port)
    end

    http.open_timeout = options[:open_timeout] || 4
    http.read_timeout = options[:read_timeout] || 10

    if uri.scheme == 'https'
      http.use_ssl = true

      if options.fetch(:verify_ssl, true)
        Certificate::ApplySSLCertificates.ensure_fresh_ssl_context
      else
        http.verify_mode = OpenSSL::SSL::VERIFY_NONE
      end
    end

    # http.set_debug_output($stdout) if options[:debug]

    http
  end

  def self.set_basic_auth(request, options)

    # http basic auth (if needed)
    if options[:user].present? && options[:password].present?
      request.basic_auth options[:user], options[:password]
    end
    request
  end

  def self.set_bearer_token_auth(request, options)
    request.tap do |req|
      next if options[:bearer_token].blank?

      req['Authorization'] = "Bearer #{options[:bearer_token]}"
    end
  end

  def self.parse_uri(url, params = {})
    uri = URI.parse(url)
    uri.query = [uri.query, URI.encode_www_form(params)].join('&') if params.present?
    uri
  end

  def self.set_params(request, params, options)
    if options[:json]
      if !request.is_a?(Net::HTTP::Get) # GET requests pass params in query, see 'parse_uri'.
        request.add_field('Content-Type', 'application/json; charset=utf-8')
        if params.present?
          request.body = params.to_json
        end
      end
    elsif params.present?
      request.set_form_data(params)
    end
    request
  end

  def self.set_headers(request, options)
    defaults = { 'User-Agent' => __('Zammad User Agent') }
    headers  = defaults.merge(options.fetch(:headers, {}))

    headers.each do |header, value|
      request[header] = value
    end

    request
  end

  def self.set_signature(request, options)
    return request if options[:signature_token].blank?
    return request if request.body.blank?

    signature = OpenSSL::HMAC.hexdigest('sha1', options[:signature_token], request.body)
    request['X-Hub-Signature'] = "sha1=#{signature}"

    request
  end

  def self.log(url, request, response, options)
    return if !options[:log]

    # request
    request_data = {
      content:          '',
      content_type:     request['Content-Type'],
      content_encoding: request['Content-Encoding'],
      source:           request['User-Agent'] || request['Server'],
    }
    request.each_header do |key, value|
      request_data[:content] += "#{key}: #{value}\n"
    end
    body = request.body
    if body
      request_data[:content] += "\n#{body}"
    end

    # response
    response_data = {
      code:             0,
      content:          '',
      content_type:     nil,
      content_encoding: nil,
      source:           nil,
    }
    if response
      response_data[:code] = response.code
      response_data[:content_type] = response['Content-Type']
      response_data[:content_encoding] = response['Content-Encoding']
      response_data[:source] = response['User-Agent'] || response['Server']
      response.each_header do |key, value|
        response_data[:content] += "#{key}: #{value}\n"
      end
      body = response.body
      if body
        response_data[:content] += "\n#{body}"
      end
    end

    record = {
      direction: 'out',
      facility:  options[:log][:facility],
      url:       url,
      status:    response_data[:code],
      ip:        nil,
      request:   request_data,
      response:  response_data,
      method:    request.method,
    }
    HttpLog.create(record)
  end

  def self.process(request, response, uri, count, params, options) # rubocop:disable Metrics/ParameterLists
    log(uri.to_s, request, response, options)

    if !response
      return Result.new(
        error:   "Can't connect to #{uri}, got no response!",
        success: false,
        code:    0,
      )
    end

    case response
    when Net::HTTPNotFound
      return Result.new(
        error:   "No such file #{uri}, 404!",
        success: false,
        code:    response.code,
        header:  response.each_header.to_h,
      )
    when Net::HTTPClientError
      return Result.new(
        error:   "Client Error: #{response.inspect}!",
        success: false,
        code:    response.code,
        body:    response.body,
        header:  response.each_header.to_h,
      )
    when Net::HTTPInternalServerError
      return Result.new(
        error:   "Server Error: #{response.inspect}!",
        success: false,
        code:    response.code,
        header:  response.each_header.to_h,
      )
    when Net::HTTPRedirection
      raise __('Too many redirections for the original URL, halting.') if count <= 0

      url = response['location']
      return get(url, params, options, count - 1)
    when Net::HTTPSuccess
      data = nil
      if options[:json] && !options[:jsonParseDisable] && response.body
        data = JSON.parse(response.body)
      end
      return Result.new(
        data:         data,
        body:         response.body,
        content_type: response['Content-Type'],
        success:      true,
        code:         response.code,
        header:       response.each_header.to_h,
      )
    end

    raise "Unable to process http call '#{response.inspect}'"
  end

  def self.ftp(uri, options)
    host       = uri.host
    filename   = File.basename(uri.path)
    remote_dir = File.dirname(uri.path)

    temp_file = Tempfile.new("download-#{filename}")
    temp_file.binmode

    begin
      Net::FTP.open(host) do |ftp|
        ftp.passive = true
        if options[:user] && options[:password]
          ftp.login(options[:user], options[:password])
        else
          ftp.login
        end
        ftp.chdir(remote_dir) if remote_dir != '.'

        begin
          ftp.getbinaryfile(filename, temp_file)
        rescue => e
          return Result.new(
            error:   e.inspect,
            success: false,
            code:    '550',
          )
        end
      end
    rescue => e
      return Result.new(
        error:   e.inspect,
        success: false,
        code:    0,
      )
    end

    contents = temp_file.read
    temp_file.close
    Result.new(
      body:    contents,
      success: true,
      code:    '200',
    )
  end

  def self.handled_open_timeout(tries)
    tries ||= 1

    tries.times do |index|
      yield
    rescue Net::OpenTimeout
      raise if (index + 1) == tries
    end
  end

  class Result

    attr_reader :error, :body, :data, :code, :content_type, :header

    def initialize(options)
      @success      = options[:success]
      @body         = options[:body]
      @data         = options[:data]
      @code         = options[:code]
      @content_type = options[:content_type]
      @error        = options[:error]
      @header       = options[:header]
    end

    def success?
      return true if @success

      false
    end
  end
end