lib/http2/response_reader.rb
class Http2::ResponseReader
attr_reader :response
def initialize(args:, http2:, sock:, request:)
@mode = "headers"
@transfer_encoding = nil
@request = request
@response = Http2::Response.new(debug: http2.debug, request: request)
@rec_count = 0
@args = args
@debug = http2.debug
@http2 = http2
@sock = sock
@nl = @http2.nl
@conn = @http2.connection
read_headers
read_body if @length == nil || @length.positive?
finish
end
def read_headers
loop do
line = @conn.gets
check_line_read(line)
if line == "\n" || line == "\r\n" || line == @nl
puts "Http2: Changing mode to body!" if @debug
raise "No headers was given at all? Possibly corrupt state after last request?" if @response.headers.empty?
@mode = "body"
@http2.on_content_call(@args, @nl)
break
end
parse_header(line)
end
end
def read_body
loop do
if @length
line = @conn.read(@length)
raise "Expected to get #{@length} of bytes but got #{line.bytesize}" if @length != line.bytesize
else
line = @conn.gets
end
check_line_read(line)
stat = parse_body(line)
break if stat == :break
next if stat == :next
end
end
def finish
# Check if we should reconnect based on keep-alive-max.
@conn.close if !@conn.closed? && (@keepalive_max == 1 || @connection == "close")
# Validate that the response is as it should be.
puts "Http2: Validating response." if @debug
raise "No status-code was received from the server. Headers: '#{@response.headers}' Body: '#{@response.body}'." unless @response.code
@response.validate!
check_and_decode
@http2.autostate_register(@response) if @http2.args[:autostate]
handle_errors
if (response = check_and_follow_redirect)
@response = response
end
end
private
def check_and_follow_redirect
if redirect_response?
url, args = url_and_args_from_location
if redirect_using_same_connection?(args)
@http2.get(url)
else
::Http2.new(**args).get(url)
end
end
end
REDIRECT_CODES = [301, 302, 303, 307, 308].freeze
def redirect_response?
REDIRECT_CODES.include?(response.code.to_i) && response.header?("location") && @http2.args[:follow_redirects]
end
def redirect_using_same_connection?(args)
if !args[:host] || args[:host] == @args[:host]
true
else
false
end
end
def url
@url ||= response.header("location")
end
SSL_AND_PORT_KEYS = [:ssl, :port].freeze
def url_and_args_from_location
uri = URI.parse(url)
url = uri.path
url << "?#{uri.query}" unless uri.query.to_s.empty?
url = url.delete_prefix("/")
args = @http2.args
.reject { |k, _v| SSL_AND_PORT_KEYS.include?(k) }
.merge(host: uri.host)
args[:ssl] = true if uri.scheme == "https"
args[:port] = uri.port if uri.port
[url, args]
end
def check_and_decode
# Check if the content is gzip-encoded - if so: decode it!
if @encoding == "gzip"
puts "Http2: Decoding GZip." if @debug
require "zlib"
require "stringio"
io = StringIO.new(@response.body)
gz = Zlib::GzipReader.new(io)
untrusted_str = gz.read
begin
valid_string = ic.encode("UTF-8")
rescue StandardError
valid_string = untrusted_str.force_encoding("UTF-8").encode("UTF-8", invalid: :replace, replace: "").encode("UTF-8")
end
@response.body = valid_string
end
end
def handle_errors
return unless @http2.raise_errors
case @response.code
when "500"
err = Http2::Errors::Internalserver.new("A internal server error occurred")
when "403"
err = Http2::Errors::Noaccess.new("No access")
when "400"
err = Http2::Errors::Badrequest.new("Bad request")
when "401"
err = Http2::Errors::Unauthorized.new("Unauthorized")
when "404"
err = Http2::Errors::Notfound.new("Not found")
when "415"
err = Http2::Errors::UnsupportedMediaType.new("Unsupported media type")
end
if err
err.response = @response
raise err
end
end
def check_line_read(line)
if line
@rec_count += line.length
elsif !line && @rec_count <= 0
parts = [
"KeepAliveMax: '#{@http2.keepalive_max}'",
"Connection: '#{@connection}'",
"PID: '#{Process.pid}'"
]
raise Errno::ECONNABORTED, "Server closed the connection before being able to read anything (#{parts.join(", ")})."
end
end
def parse_cookie(cookie_line)
::Http2::Utils.parse_set_cookies(cookie_line).each do |cookie|
@http2.cookies[cookie.name] = cookie
end
end
def parse_keep_alive(keep_alive_line)
keep_alive_line.scan(/([a-z]+)=(\d+)/) do |match|
if match[0] == "timeout"
puts "Http2: Keepalive-max set to: '#{@keepalive_max}'." if @debug
@http2.keepalive_timeout = match[1].to_i
elsif match[0] == "max"
puts "Http2: Keepalive-timeout set to: '#{@keepalive_timeout}'." if @debug
@http2.keepalive_max = match[1].to_i
end
end
end
def parse_content_type(content_type_line)
if (match_charset = content_type_line.match(/\s*;\s*charset=(.+)/i))
@charset = match_charset[1].downcase
@response.charset = @charset
content_type_line.gsub!(match_charset[0], "")
end
@response.content_type = content_type_line
end
# Parse a header-line and saves it on the object.
#===Examples
# http.parse_header("Content-Type: text/html\r\n")
def parse_header(line)
if (match = line.match(/^(.+?):\s*(.+)#{@nl}$/))
key = match[1].downcase
set_header_special_values(key, match[2])
parse_normal_header(line, key, match[1], match[2])
elsif (match = line.match(/^HTTP\/([\d\.]+)\s+(\d+)\s+(.+)$/))
@response.code = match[2]
@response.http_version = match[1]
@http2.on_content_call(@args, line)
else
raise "Could not understand header string: '#{line}'."
end
end
def set_header_special_values(key, value)
parse_cookie(value) if key == "set-cookie"
parse_keep_alive(value) if key == "keep-alive"
parse_content_type(value) if key == "content-type"
if key == "connection"
@connection = value.downcase
elsif key == "content-encoding"
@encoding = value.downcase
puts "Http2: Setting encoding to #{@encoding}" if @debug
elsif key == "content-length"
@length = value.to_i
elsif key == "transfer-encoding"
@transfer_encoding = value.downcase.strip
end
end
def parse_normal_header(line, key, orig_key, value)
puts "Http2: Parsed header: #{orig_key}: #{value}" if @debug
@response.headers[key] = [] unless @response.headers.key?(key)
@response.headers[key] << value
@http2.on_content_call(@args, line) if key != "transfer-encoding" && key != "content-length" && key != "connection" && key != "keep-alive"
end
# Parses the body based on given headers and saves it to the result-object.
# http.parse_body(str)
def parse_body(line)
return :break if @length&.zero?
if @transfer_encoding == "chunked"
parse_body_chunked(line)
else
puts "Http2: Adding #{line.to_s.bytesize} to the body." if @debug
@response.body << line
@http2.on_content_call(@args, line)
return :break if @response.content_length && @response.body.length >= @response.content_length # rubocop:disable Style/RedundantReturn
end
end
def parse_body_chunked(line)
len = line.strip.hex
if len.positive?
read = @conn.read(len)
return :break if read == "" || read == "\n" || read == "\r\n"
@response.body << read
@http2.on_content_call(@args, read)
end
nl = @conn.gets
if len.zero?
if nl == "\n" || nl == "\r\n"
return :break
else
raise "Dont know what to do :'-("
end
end
raise "Should have read newline but didnt: '#{nl}'." if nl != @nl
end
end