mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
366 lines
11 KiB
Ruby
366 lines
11 KiB
Ruby
# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
|
|
|
|
require 'net/http'
|
|
require 'net/https'
|
|
|
|
class UserAgent
|
|
|
|
# Make HTTP request via GET method
|
|
#
|
|
# @see .make_connection
|
|
def self.get(...)
|
|
make_connection(:get, ...)
|
|
end
|
|
|
|
# Make HTTP request via POST method
|
|
#
|
|
# @see .make_connection
|
|
def self.post(...)
|
|
make_connection(:post, ...)
|
|
end
|
|
|
|
# Make HTTP request via PATCH method
|
|
#
|
|
# @see .make_connection
|
|
def self.patch(...)
|
|
make_connection(:patch, ...)
|
|
end
|
|
|
|
# Make HTTP request via PUT method
|
|
#
|
|
# @see .make_connection
|
|
def self.put(...)
|
|
make_connection(:put, ...)
|
|
end
|
|
|
|
# Make HTTP request via DELETE method
|
|
#
|
|
# @see .make_connection
|
|
def self.delete(...)
|
|
make_connection(:delete, ...)
|
|
end
|
|
|
|
def self.get_http(uri, options)
|
|
http = UserAgent::HttpClient
|
|
.get_client(uri, options)
|
|
.new(uri.host, uri.port)
|
|
|
|
# Defaults raised for slow links (e.g. OAuth to external IdPs); override globally via ENV, per-request via options. See https://github.com/zammad/zammad/issues/5991
|
|
http.open_timeout = options[:open_timeout] || ENV.fetch('ZAMMAD_HTTP_OPEN_TIMEOUT', 30).to_i
|
|
http.read_timeout = options[:read_timeout] || ENV.fetch('ZAMMAD_HTTP_READ_TIMEOUT', 60).to_i
|
|
|
|
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 = {}, method = nil)
|
|
uri = URI.parse(url)
|
|
|
|
if method == :get && params.present?
|
|
uri.query = [uri.query, URI.encode_www_form(params)].join('&')
|
|
end
|
|
|
|
uri
|
|
end
|
|
|
|
def self.set_params(request, params, options)
|
|
if options[:json]
|
|
request.add_field('Content-Type', 'application/json; charset=utf-8')
|
|
if params.present?
|
|
request.body = params.to_json
|
|
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]
|
|
return if options[:log][:log_only_on_error] && response.is_a?(Net::HTTPSuccess)
|
|
|
|
# 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,
|
|
body: response.body,
|
|
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::HTTPServerError # Covers Net::HTTPInternalServerError, Net::HTTPServiceUnavailable etc
|
|
return Result.new(
|
|
error: "Server Error: #{response.inspect}!",
|
|
success: false,
|
|
code: response.code,
|
|
body: response.body,
|
|
header: response.each_header.to_h,
|
|
)
|
|
when Net::HTTPRedirection
|
|
if options[:do_not_follow_redirects]
|
|
raise __('The server returned a redirect response, but the current operation does not allow redirects.')
|
|
end
|
|
|
|
if count <= 0
|
|
raise __('Too many redirections for the original URL, halting.')
|
|
end
|
|
|
|
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.handled_open_timeout(tries)
|
|
tries ||= 1
|
|
|
|
tries.times do |index|
|
|
yield
|
|
rescue Net::OpenTimeout
|
|
raise if (index + 1) == tries
|
|
end
|
|
end
|
|
|
|
# Base method for making connection
|
|
#
|
|
# @param method [Symbol] HTTP request method style to use. Must be Net::HTTP::Class
|
|
# @param url [String] full URL to request
|
|
# @param params [Hash] to add either to GET URL or submit as POST-style data
|
|
# @param options [Hash]
|
|
# @option options [String] :send_as_raw_body to submit as raw POST-style request data body
|
|
# @option options [Integer] :total_timeout of connection
|
|
# @option options [Integer] :open_socket_tries count to retry connection
|
|
# @option options [Boolean] :verify_ssl
|
|
# @option options [Hash] :headers to apply to request
|
|
# @option options [String] :signature_token to set as X-Hub-Sighature header
|
|
# @option options [Boolean] :json is POST-style data parameters posted as JSON and response shall be parsed as JSON
|
|
# @option options [Boolean] :jsonParseDisable disable response parsing as JSON of :json is enabled
|
|
# @option options [String] :user for basic authentication
|
|
# @option options [String] :password for basic authentication
|
|
# @option options [String] :bearer_token for token authentication
|
|
# @option options [Hash] :log enable logging, use facility: to set logging facility and log_only_on_error: to only log failed requests
|
|
# @option options [String] :proxy address
|
|
# @option options [String] :proxy_no list of address to skip proxy for
|
|
# @option options [String] :proxy_username
|
|
# @option options [String] :proxy_password
|
|
# @option options [Integer] :open_timeout
|
|
# @option options [Integer] :read_timeout
|
|
# @option options [Boolean] :do_not_follow_redirects
|
|
# @option options [Hash, Boolean] :validate_safety to validate hostname safety via HostnameSafetyCheck.validate! with options as sub-keys
|
|
# @option log [String] :facility is sub-key as in options[:log][:facility] providing name to use when logging in HttpLog
|
|
# @param count [Integer] of redirects. Counts towards zero and then aborts
|
|
#
|
|
# @example
|
|
#
|
|
# result = UserAgent.make_connection(:get, 'http://host/some_dir/some_file?param1=123',
|
|
# { param1: 'some value' } , { option: value })
|
|
# result.data => { parsed: 'json' }
|
|
#
|
|
# @return [Result]
|
|
def self.make_connection(method, url, params = {}, options = {}, count = 10)
|
|
uri = parse_uri(url, params, method)
|
|
http = get_http(uri, options)
|
|
|
|
# prepare request
|
|
request = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
|
|
if options[:validate_safety]
|
|
validate_safety_options = options[:validate_safety].is_a?(Hash) ? options[:validate_safety] : nil
|
|
HostnameSafetyCheck.validate!(uri.hostname, **validate_safety_options)
|
|
end
|
|
|
|
# set headers
|
|
request = set_headers(request, options)
|
|
|
|
# set params for non-get requests
|
|
if !request.is_a?(Net::HTTP::Get)
|
|
request = set_params(request, params, options)
|
|
end
|
|
|
|
# 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] || ENV.fetch('ZAMMAD_HTTP_TOTAL_TIMEOUT', 60).to_i
|
|
|
|
handled_open_timeout(options[:open_socket_tries]) do
|
|
Timeout.timeout(total_timeout) do
|
|
response = if (send_as_raw_body = options[:send_as_raw_body])
|
|
http.request(request, send_as_raw_body)
|
|
else
|
|
http.request(request)
|
|
end
|
|
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
|
|
|
|
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
|