lib/http2.rb
require "socket"
require "uri"
require "monitor" unless Kernel.const_defined?(:Monitor)
require "string-cases"
# This class tries to emulate a browser in Ruby without any visual stuff.
# Remember cookies, keep sessions alive, reset connections according to keep-alive rules and more.
#===Examples
# Http2.new(host: "www.somedomain.com", port: 80, ssl: false, debug: false) do |http|
# res = http.get("index.rhtml?show=some_page")
# html = res.body
# print html
#
# res = res.post("index.rhtml?choice=login", {"username" => "John Doe", "password" => 123})
# print res.body
# print "#{res.headers}"
# end
class Http2
# Autoloader for subclasses.
def self.const_missing(name)
file_path = "#{File.dirname(__FILE__)}/http2/#{::StringCases.camel_to_snake(name)}.rb"
if File.exist?(file_path)
require file_path
return Http2.const_get(name) if Http2.const_defined?(name)
end
super
end
attr_reader :autostate, :connection, :cookies, :args, :debug, :mutex, :resp, :raise_errors, :nl, :user_agent
attr_accessor :keepalive_max, :keepalive_timeout
DEFAULT_USER_AGENT = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)".freeze
VALID_ARGUMENTS_INITIALIZE = [
:host, :port, :skip_port_in_host_header, :ssl, :ssl_skip_verify, :nl, :user_agent, :raise_errors,
:follow_redirects, :debug, :encoding_gzip, :autostate, :basic_auth, :extra_headers, :proxy
].freeze
def initialize(user_agent: DEFAULT_USER_AGENT, **args)
@args = parse_init_args(args)
set_default_values
@cookies = {}
@mutex = Monitor.new
@connection = ::Http2::Connection.new(self)
@user_agent = user_agent
if block_given?
begin
yield(self)
ensure
destroy
end
end
end
def host
@args.fetch(:host)
end
def port
@args.fetch(:port)
end
def ssl?
@args[:ssl] ? true : false
end
def reconnect
@connection.reconnect
end
def new_url
builder = Http2::UrlBuilder.new
builder.host = host
builder.port = port
builder.protocol = @args[:protocol]
builder
end
# Closes current connection if any, changes the arguments on the object and reconnects keeping all cookies and other stuff intact.
def change(args)
@args.merge!(args)
@connection.destroy
@connection = ::Http2::Connection.new(self)
end
# Destroys the object unsetting all variables and closing all sockets.
#===Examples
# http.destroy
def destroy
@cookies = nil
@debug = nil
@mutex = nil
@keepalive_timeout = nil
@request_last = nil
@connection.destroy
@connection = nil
end
# Forces various stuff into arguments-hash like URL from original arguments and enables single-string-shortcuts and more.
def parse_args(*args)
if args.length == 1 && args.first.is_a?(String)
args = {url: args.first}
elsif args.length == 1
args = args.first
else
raise "Invalid arguments: '#{args.class.name}'"
end
raise "Invalid URL: '#{args[:url]}'" if args[:url] != "" && args[:url].to_s.split("\n").length != 1
args
end
# Returns a result-object based on the arguments.
#===Examples
# res = http.get("somepage.html")
# print res.body #=> <String>-object containing the HTML gotten.
def get(args)
::Http2::GetRequest.new(self, args).execute
end
# Proxies the request to another method but forces the method to be "DELETE".
def delete(args)
if args[:json]
post(args.merge(method: :delete))
else
get(args.merge(method: :delete))
end
end
# Returns the default headers for a request.
#===Examples
# headers_hash = http.default_headers
# print "#{headers_hash}"
def default_headers(args = {})
return args[:default_headers] if args[:default_headers]
headers = {
"Connection" => "Keep-Alive",
"User-Agent" => user_agent,
"Host" => host_header
}
headers["Accept-Encoding"] = "gzip" if @args[:encoding_gzip]
if @args[:basic_auth]
require "base64" unless ::Kernel.const_defined?(:Base64)
headers["Authorization"] = "Basic #{Base64.strict_encode64("#{@args[:basic_auth][:user]}:#{@args[:basic_auth][:passwd]}").strip}"
end
if @args[:proxy] && @args[:proxy][:user] && @args[:proxy][:passwd] && !@connection.proxy_connect?
require "base64" unless ::Kernel.const_defined?(:Base64)
puts "Http2: Adding proxy auth header to request" if @debug
headers["Proxy-Authorization"] = "Basic #{Base64.strict_encode64("#{@args[:proxy][:user]}:#{@args[:proxy][:passwd]}").strip}"
end
headers.merge!(@args[:extra_headers]) if @args[:extra_headers]
headers.merge!(args[:headers]) if args[:headers]
headers
end
# Posts to a certain page.
#===Examples
# res = http.post("login.php", {"username" => "John Doe", "password" => 123)
def post(args)
::Http2::PostRequest.new(self, args).execute
end
# Posts to a certain page using the multipart-method.
#===Examples
# res = http.post_multipart("upload.php", {"normal_value" => 123, "file" => Tempfile.new(?)})
def post_multipart(*args)
::Http2::PostMultipartRequest.new(self, *args).execute
end
# Returns a header-string which normally would be used for a request in the given state.
def header_str(headers_hash)
headers_hash["Cookie"] = cookie_header_string unless cookie_header_string.empty?
headers_str = ""
headers_hash.each do |key, val|
headers_str << "#{key}: #{val}#{@nl}"
end
headers_str
end
def cookie_header_string
cstr = ""
first = true
@cookies.each_value do |cookie|
cstr << "; " unless first
first = false if first
ensure_single_lines([cookie.name, cookie.value])
cstr << "#{Http2::Utils.urlenc(cookie.name)}=#{Http2::Utils.urlenc(cookie.value)}"
end
cstr
end
def cookie(name)
name = name.to_s
return @cookies.fetch(name) if @cookies.key?(name)
raise "No cookie by that name: '#{name}' in '#{@cookies.keys.join(", ")}'"
end
def ensure_single_lines(*strings)
strings.each do |string|
raise "More than one line: #{string}." unless string.to_s.lines.to_a.length == 1
end
end
def on_content_call(args, str)
args[:on_content].call(str) if args.key?(:on_content)
end
# Reads the response after posting headers and data.
#===Examples
# res = http.read_response
def read_response(request, args = {})
::Http2::ResponseReader.new(http2: self, sock: @sock, args: args, request: request).response
end
def to_s
"<Http2 host: #{host}:#{port}>"
end
def inspect
to_s
end
private
def host_header
# Possible to give custom host-argument.
host = args[:host] || self.host
port = args[:port] || self.port
host_header_string = host.dup # Copy host string to avoid changing the original string if port has been given!
host_header_string << ":#{port}" if port && ![80, 443].include?(port.to_i) && !@args[:skip_port_in_host_header]
host_header_string
end
# Registers the states from a result.
def autostate_register(res)
puts "Http2: Running autostate-register on result." if @debug
@autostate_values.clear
res.body.to_s.scan(/<input type="hidden" name="__(EVENTTARGET|EVENTARGUMENT|VIEWSTATE|LASTFOCUS)" id="(.*?)" value="(.*?)" \/>/) do |match|
name = "__#{match[0]}"
value = match[2]
puts "Http2: Registered autostate-value with name '#{name}' and value '#{value}'." if @debug
@autostate_values[name] = Http2::Utils.urldec(value)
end
raise "No states could be found." if @autostate_values.empty?
end
# Sets the states on the given post-hash.
def autostate_set_on_post_hash(phash)
phash.merge!(@autostate_values)
end
def parse_init_args(args)
args = {host: args} if args.is_a?(String)
raise "Arguments wasnt a hash." unless args.is_a?(Hash)
args.each_key do |key|
raise "Invalid key: '#{key}'." unless VALID_ARGUMENTS_INITIALIZE.include?(key)
end
args[:proxy][:connect] = true if args[:proxy] && !args[:proxy].key?(:connect) && args[:ssl]
raise "No host was given." unless args[:host]
args
end
def set_default_values
@debug = @args[:debug]
@autostate_values = {} if autostate
@nl = @args[:nl] || "\r\n"
unless @args[:port]
if @args[:ssl]
@args[:port] = 443
else
@args[:port] = 80
end
end
if !@args.key?(:raise_errors) || @args[:raise_errors]
@raise_errors = true
else
@raise_errors = false
end
end
end