metainspector 5.18.1 → 5.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +15 -0
- data/README.md +125 -6
- data/lib/meta_inspector/document.rb +25 -5
- data/lib/meta_inspector/http_session.rb +65 -0
- data/lib/meta_inspector/image_downloader.rb +53 -0
- data/lib/meta_inspector/parser.rb +4 -2
- data/lib/meta_inspector/parsers/images.rb +19 -14
- data/lib/meta_inspector/parsers/links.rb +2 -1
- data/lib/meta_inspector/parsers/texts.rb +13 -4
- data/lib/meta_inspector/request.rb +43 -42
- data/lib/meta_inspector/url_policy.rb +58 -0
- data/lib/meta_inspector/version.rb +1 -1
- data/spec/document_spec.rb +20 -0
- data/spec/image_download_spec.rb +114 -0
- data/spec/meta_inspector/head_first_spec.rb +195 -0
- data/spec/meta_inspector/images_spec.rb +233 -0
- data/spec/meta_inspector/links_spec.rb +22 -0
- data/spec/meta_inspector/meta_inspector_spec.rb +58 -7
- data/spec/meta_inspector/redirections_spec.rb +20 -6
- data/spec/meta_inspector/texts_spec.rb +115 -0
- data/spec/request_spec.rb +29 -0
- data/spec/url_filter_spec.rb +206 -0
- metadata +7 -1
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
require 'http/cookie'
|
|
2
|
-
require 'faraday'
|
|
3
|
-
require 'faraday-cookie_jar'
|
|
4
|
-
require 'faraday-http-cache'
|
|
5
|
-
require 'faraday/encoding'
|
|
6
|
-
require 'faraday/follow_redirects'
|
|
7
|
-
require 'faraday/gzip'
|
|
8
|
-
require 'faraday/retry'
|
|
9
1
|
require 'timeout'
|
|
2
|
+
require_relative 'http_session'
|
|
10
3
|
|
|
11
4
|
module MetaInspector
|
|
12
5
|
|
|
@@ -15,16 +8,15 @@ module MetaInspector
|
|
|
15
8
|
def initialize(initial_url, options = {})
|
|
16
9
|
@url = initial_url
|
|
17
10
|
|
|
18
|
-
|
|
11
|
+
URLPolicy.validate!(@url.url)
|
|
19
12
|
|
|
20
13
|
@allow_redirections = options[:allow_redirections]
|
|
14
|
+
@head_first = options[:head_first]
|
|
15
|
+
@allow_non_html_content = options[:allow_non_html_content]
|
|
21
16
|
@connection_timeout = options[:connection_timeout]
|
|
22
17
|
@read_timeout = options[:read_timeout]
|
|
23
|
-
@retries = options[:retries]
|
|
24
18
|
@encoding = options[:encoding]
|
|
25
|
-
@
|
|
26
|
-
@faraday_options = options[:faraday_options] || {}
|
|
27
|
-
@faraday_http_cache = options[:faraday_http_cache]
|
|
19
|
+
@http_session = options[:http_session] || HttpSession.new(options)
|
|
28
20
|
|
|
29
21
|
response # request early so we can fail early
|
|
30
22
|
end
|
|
@@ -42,12 +34,13 @@ module MetaInspector
|
|
|
42
34
|
end
|
|
43
35
|
|
|
44
36
|
def content_type
|
|
45
|
-
|
|
46
|
-
response.headers['content-type'].split(';')[0] if response
|
|
37
|
+
response_content_type(response)
|
|
47
38
|
end
|
|
48
39
|
|
|
49
40
|
def response
|
|
50
41
|
@response ||= fetch
|
|
42
|
+
rescue URLPolicy::Failure => failure
|
|
43
|
+
raise failure.original
|
|
51
44
|
rescue Faraday::TimeoutError => e
|
|
52
45
|
raise MetaInspector::TimeoutError.new(e)
|
|
53
46
|
rescue Faraday::ConnectionFailed, Faraday::SSLError, URI::InvalidURIError, Faraday::FollowRedirects::RedirectLimitReached => e
|
|
@@ -56,36 +49,23 @@ module MetaInspector
|
|
|
56
49
|
|
|
57
50
|
private
|
|
58
51
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
session = Faraday.new(@faraday_options) do |faraday|
|
|
65
|
-
faraday.request :retry, max: @retries
|
|
66
|
-
|
|
67
|
-
faraday.request :gzip
|
|
68
|
-
|
|
69
|
-
if @allow_redirections
|
|
70
|
-
follow_redirects_options[:limit] ||= 10
|
|
71
|
-
faraday.use Faraday::FollowRedirects::Middleware, **follow_redirects_options
|
|
72
|
-
faraday.use :cookie_jar
|
|
73
|
-
end
|
|
52
|
+
# Once per process: apps fetch many pages with the same options, and a warning
|
|
53
|
+
# per request would flood their logs
|
|
54
|
+
def self.warn_faraday_http_cache_deprecation
|
|
55
|
+
return if @faraday_http_cache_deprecation_warned
|
|
74
56
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
57
|
+
@faraday_http_cache_deprecation_warned = true
|
|
58
|
+
warn 'DEPRECATION WARNING: the top-level faraday_http_cache option is deprecated and will be removed ' \
|
|
59
|
+
'in the next major version. Pass it inside faraday_options instead: ' \
|
|
60
|
+
'faraday_options: { faraday_http_cache: { store: cache } }'
|
|
61
|
+
end
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
end
|
|
63
|
+
def fetch
|
|
64
|
+
Timeout::timeout(fatal_timeout) do
|
|
65
|
+
session = @http_session.build(url)
|
|
84
66
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
req.options.open_timeout = @read_timeout
|
|
88
|
-
end
|
|
67
|
+
check_head_content_type(session) if @head_first
|
|
68
|
+
response = perform_request(session, :get)
|
|
89
69
|
|
|
90
70
|
if @allow_redirections
|
|
91
71
|
@url.url = response.env.url.to_s
|
|
@@ -97,6 +77,27 @@ module MetaInspector
|
|
|
97
77
|
raise MetaInspector::TimeoutError.new(e)
|
|
98
78
|
end
|
|
99
79
|
|
|
80
|
+
def check_head_content_type(session)
|
|
81
|
+
head_response = perform_request(session, :head)
|
|
82
|
+
return unless head_response.success?
|
|
83
|
+
|
|
84
|
+
type = response_content_type(head_response)
|
|
85
|
+
if !@allow_non_html_content && !type.nil? && type != 'text/html'
|
|
86
|
+
fail MetaInspector::NonHtmlError.new "The url provided contains #{type} content instead of text/html content"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def response_content_type(response)
|
|
91
|
+
response.headers['content-type']&.split(';')&.first
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def perform_request(session, method)
|
|
95
|
+
session.public_send(method) do |req|
|
|
96
|
+
req.options.timeout = @connection_timeout
|
|
97
|
+
req.options.open_timeout = @read_timeout
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
100
101
|
# Timeouts when connecting / reading a request are handled by Faraday, but in the
|
|
101
102
|
# case of URLs that respond with streaming, Faraday will never return. In that case,
|
|
102
103
|
# we'll resort to our own timeout
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module MetaInspector
|
|
2
|
+
# Runs inside redirect handling, before cache access or the network adapter.
|
|
3
|
+
class URLPolicy < Faraday::Middleware
|
|
4
|
+
class Failure < StandardError
|
|
5
|
+
attr_reader :original
|
|
6
|
+
|
|
7
|
+
def initialize(original)
|
|
8
|
+
@original = original
|
|
9
|
+
super('URL policy failed')
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
IMAGE_HEADERS = %w[user-agent accept accept-language accept-encoding range].freeze
|
|
14
|
+
|
|
15
|
+
def self.validate!(value)
|
|
16
|
+
uri = URI.parse(value.to_s)
|
|
17
|
+
unless uri.is_a?(URI::HTTP) && uri.hostname && !uri.hostname.empty?
|
|
18
|
+
raise MetaInspector::RequestError, 'URL must be HTTP or HTTPS'
|
|
19
|
+
end
|
|
20
|
+
uri
|
|
21
|
+
rescue URI::InvalidURIError => error
|
|
22
|
+
raise MetaInspector::RequestError.new(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(app, filter, image_origin = nil)
|
|
26
|
+
super(app)
|
|
27
|
+
@filter = filter
|
|
28
|
+
@image_origin = image_origin && self.class.validate!(image_origin)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
uri = self.class.validate!(env.url)
|
|
33
|
+
if @image_origin && origin(uri) != origin(@image_origin)
|
|
34
|
+
env.request_headers.delete_if { |key, _| !IMAGE_HEADERS.include?(key.downcase) }
|
|
35
|
+
end
|
|
36
|
+
check_filter(uri) if @filter
|
|
37
|
+
@app.call(env)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def origin(uri)
|
|
43
|
+
[uri.scheme.downcase, uri.hostname.downcase, uri.port]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_filter(uri)
|
|
47
|
+
# Parsing detaches the strings too: URI#dup alone shares its hostname.
|
|
48
|
+
begin
|
|
49
|
+
allowed = @filter.call(URI.parse(uri.to_s).freeze)
|
|
50
|
+
rescue StandardError => error
|
|
51
|
+
raise Failure.new(error)
|
|
52
|
+
end
|
|
53
|
+
unless allowed
|
|
54
|
+
raise Failure.new(MetaInspector::RequestError.new('Request blocked by url_filter'))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/spec/document_spec.rb
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe MetaInspector::Document do
|
|
4
|
+
describe 'max_image_downloads option' do
|
|
5
|
+
[-1, 1.5, '1', true, false].each do |limit|
|
|
6
|
+
it "should reject #{limit.inspect} before fetching the document" do
|
|
7
|
+
expect do
|
|
8
|
+
MetaInspector::Document.new('http://example.com/', max_image_downloads: limit)
|
|
9
|
+
end.to raise_error(ArgumentError, 'max_image_downloads must be a non-negative Integer or nil')
|
|
10
|
+
|
|
11
|
+
expect(WebMock).not_to have_requested(:get, 'http://example.com/')
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
4
16
|
describe 'passing the contents of the document as html' do
|
|
5
17
|
let(:doc) { MetaInspector::Document.new('http://cnn.com/', :document => "<html><head><title>Hello From Passed Html</title><a href='/hello'>Hello link</a></head><body></body></html>") }
|
|
6
18
|
|
|
@@ -17,6 +29,13 @@ describe MetaInspector::Document do
|
|
|
17
29
|
expect(MetaInspector::Document.new('http://pagerankalert.com').to_s.class).to eq(String)
|
|
18
30
|
end
|
|
19
31
|
|
|
32
|
+
it 'should include a nil language in the hash when the document has no declared language' do
|
|
33
|
+
stub_request(:get, 'http://example.com/').to_return(body: '<html><body></body></html>', headers: { 'Content-Type' => 'text/html' })
|
|
34
|
+
doc = MetaInspector::Document.new('http://example.com/')
|
|
35
|
+
|
|
36
|
+
expect(doc.to_hash).to include('language' => nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
20
39
|
it "should return a Hash with all the values set" do
|
|
21
40
|
doc = MetaInspector::Document.new('http://pagerankalert.com')
|
|
22
41
|
expect(doc.to_hash).to eq({
|
|
@@ -24,6 +43,7 @@ describe MetaInspector::Document do
|
|
|
24
43
|
"scheme" => "http",
|
|
25
44
|
"host" => "pagerankalert.com",
|
|
26
45
|
"root_url" => "http://pagerankalert.com/",
|
|
46
|
+
"language" => "en",
|
|
27
47
|
"title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
|
|
28
48
|
"best_title" => "PageRankAlert.com :: Track your PageRank changes & receive alerts",
|
|
29
49
|
"author" => nil,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'socket'
|
|
3
|
+
|
|
4
|
+
RSpec.describe 'bounded image downloads' do
|
|
5
|
+
let(:png) { "\x89PNG\r\n\x1a\n".b + [13].pack('N') + 'IHDR' + [37, 29].pack('NN') + "\x08\x02\x00\x00\x00".b }
|
|
6
|
+
|
|
7
|
+
it 'inherits proxy and TLS options without modifying caller-owned hashes' do
|
|
8
|
+
image = 'https://images.example/image.png'
|
|
9
|
+
stub_request(:get, image).to_return(body: png)
|
|
10
|
+
ssl = { verify: false }.freeze
|
|
11
|
+
redirect = { limit: 2 }.freeze
|
|
12
|
+
options = { proxy: 'http://proxy.example:3128', ssl: ssl, redirect: redirect }.freeze
|
|
13
|
+
expect(Faraday).to receive(:new).with(hash_including(proxy: options[:proxy], ssl: ssl)).and_call_original
|
|
14
|
+
page = MetaInspector.new('https://page.example/', document: %(<img src="#{image}">), faraday_options: options)
|
|
15
|
+
expect(page.images.with_size).to eq([[image, 37, 29]])
|
|
16
|
+
expect(options).to eq(proxy: 'http://proxy.example:3128', ssl: ssl, redirect: redirect)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'does not cache streamed image responses' do
|
|
20
|
+
cache = Class.new(Hash) do
|
|
21
|
+
def read(key); self[key]; end
|
|
22
|
+
def write(key, value); self[key] = value; end
|
|
23
|
+
end.new
|
|
24
|
+
stub_request(:get, 'https://images.example/image.png').to_return(body: png,
|
|
25
|
+
headers: { 'Cache-Control' => 'public, max-age=3600' })
|
|
26
|
+
2.times do
|
|
27
|
+
page = MetaInspector.new('https://page.example/', document: '<img src="https://images.example/image.png">',
|
|
28
|
+
faraday_options: { faraday_http_cache: { store: cache } })
|
|
29
|
+
expect(page.images.with_size.first.last(2)).to eq([37, 29])
|
|
30
|
+
end
|
|
31
|
+
expect(cache).to be_empty
|
|
32
|
+
expect(a_request(:get, 'https://images.example/image.png')).to have_been_made.twice
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
context 'with a real HTTP server' do
|
|
36
|
+
around do |example|
|
|
37
|
+
@server = TCPServer.new('127.0.0.1', 0)
|
|
38
|
+
@url = "http://127.0.0.1:#{@server.addr[1]}/image"
|
|
39
|
+
# WebMock buffers real responses before yielding to Net::HTTP's block.
|
|
40
|
+
# Disable its adapter here to exercise actual incremental reads.
|
|
41
|
+
WebMock.disable!
|
|
42
|
+
example.run
|
|
43
|
+
ensure
|
|
44
|
+
@server&.close
|
|
45
|
+
@worker&.kill
|
|
46
|
+
@worker&.join
|
|
47
|
+
WebMock.enable!
|
|
48
|
+
WebMock.disable_net_connect!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serve(&block)
|
|
52
|
+
@worker = Thread.new do
|
|
53
|
+
socket = @server.accept
|
|
54
|
+
while (line = socket.gets) && line != "\r\n"; end
|
|
55
|
+
block.call(socket)
|
|
56
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
57
|
+
# Reaching the byte/time limit closes the peer connection.
|
|
58
|
+
ensure
|
|
59
|
+
socket&.close
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'bounds binary IO and stops a server that ignores Range before reading its full body' do
|
|
64
|
+
sent = 0
|
|
65
|
+
serve do |socket|
|
|
66
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 4194304\r\nContent-Type: image/png\r\nConnection: close\r\n\r\n")
|
|
67
|
+
socket.write(png)
|
|
68
|
+
1024.times do
|
|
69
|
+
socket.write('x' * 4096)
|
|
70
|
+
sent += 4096
|
|
71
|
+
sleep 0.002
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
expect(FastImage).to receive(:size).with(an_instance_of(StringIO)) do |io|
|
|
75
|
+
expect(io.string.bytesize).to eq(256 * 1024)
|
|
76
|
+
expect(io.string.encoding).to eq(Encoding::BINARY)
|
|
77
|
+
[37, 29]
|
|
78
|
+
end
|
|
79
|
+
page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
|
|
80
|
+
expect(page.images.with_size).to eq([[@url, 37, 29]])
|
|
81
|
+
expect(sent).to be < 4 * 1024 * 1024
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'also stops oversized redirect bodies before following their location' do
|
|
85
|
+
sent = 0
|
|
86
|
+
serve do |socket|
|
|
87
|
+
socket.write("HTTP/1.1 302 Found\r\nLocation: /next\r\nContent-Length: 4194304\r\nConnection: close\r\n\r\n")
|
|
88
|
+
1024.times do
|
|
89
|
+
socket.write('x' * 4096)
|
|
90
|
+
sent += 4096
|
|
91
|
+
sleep 0.0005
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
|
|
95
|
+
expect(page.images.with_size).to eq([[@url, 0, 0]])
|
|
96
|
+
expect(sent).to be < 4 * 1024 * 1024
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'bounds the entire image request even while response data keeps arriving' do
|
|
100
|
+
serve do |socket|
|
|
101
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n")
|
|
102
|
+
loop do
|
|
103
|
+
socket.write('x')
|
|
104
|
+
sleep 0.02
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
page = MetaInspector.new('http://page.example/', document: %(<img src="#{@url}">))
|
|
109
|
+
expect(page.images.with_size).to eq([[@url, 0, 0]])
|
|
110
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
111
|
+
expect(elapsed).to be < 2.8
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe MetaInspector, 'head_first option' do
|
|
4
|
+
let(:page_url) { 'http://example.com/head-first' }
|
|
5
|
+
let(:html) { '<html><head><title>Downloaded page</title></head></html>' }
|
|
6
|
+
let!(:get_request) do
|
|
7
|
+
stub_request(:get, page_url).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'does not send HEAD by default' do
|
|
11
|
+
expect(MetaInspector.new(page_url).title).to eq('Downloaded page')
|
|
12
|
+
|
|
13
|
+
expect(a_request(:head, page_url)).not_to have_been_made
|
|
14
|
+
expect(get_request).to have_been_requested.once
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'does not send HEAD when explicitly disabled' do
|
|
18
|
+
expect(MetaInspector.new(page_url, head_first: false).title).to eq('Downloaded page')
|
|
19
|
+
|
|
20
|
+
expect(a_request(:head, page_url)).not_to have_been_made
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'sends HEAD before GET for HTML and exposes the GET response' do
|
|
24
|
+
requests = []
|
|
25
|
+
head_request = stub_request(:head, page_url).to_return do
|
|
26
|
+
requests << :head
|
|
27
|
+
{ headers: { 'Content-Type' => 'text/html; charset=UTF-8', 'X-Response' => 'head' } }
|
|
28
|
+
end
|
|
29
|
+
stub_request(:get, page_url).to_return do
|
|
30
|
+
requests << :get
|
|
31
|
+
{ body: html, headers: { 'Content-Type' => 'text/html', 'X-Response' => 'get' } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
page = MetaInspector.new(page_url, head_first: true)
|
|
35
|
+
|
|
36
|
+
expect(page.title).to eq('Downloaded page')
|
|
37
|
+
expect(page.content_type).to eq('text/html')
|
|
38
|
+
expect(page.response.headers['x-response']).to eq('get')
|
|
39
|
+
expect(page.to_s).to eq(html)
|
|
40
|
+
expect(head_request).to have_been_requested.once
|
|
41
|
+
expect(requests).to eq([:head, :get])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'rejects non-HTML content without sending GET' do
|
|
45
|
+
head_request = stub_request(:head, page_url)
|
|
46
|
+
.to_return(headers: { 'Content-Type' => 'application/pdf; version=1.7' })
|
|
47
|
+
|
|
48
|
+
expect do
|
|
49
|
+
MetaInspector.new(page_url, head_first: true)
|
|
50
|
+
end.to raise_error(MetaInspector::NonHtmlError,
|
|
51
|
+
'The url provided contains application/pdf content instead of text/html content')
|
|
52
|
+
|
|
53
|
+
expect(head_request).to have_been_requested.once
|
|
54
|
+
expect(get_request).not_to have_been_requested
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'downloads non-HTML content when explicitly allowed' do
|
|
58
|
+
head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/plain' })
|
|
59
|
+
stub_request(:get, page_url).to_return(body: 'Plain text', headers: { 'Content-Type' => 'text/plain' })
|
|
60
|
+
|
|
61
|
+
page = MetaInspector.new(page_url, head_first: true, allow_non_html_content: true)
|
|
62
|
+
|
|
63
|
+
expect(page.to_s).to eq('Plain text')
|
|
64
|
+
expect(page.content_type).to eq('text/plain')
|
|
65
|
+
expect(head_request).to have_been_requested.once
|
|
66
|
+
expect(get_request).to have_been_requested.once
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'continues to GET when HEAD has no content type' do
|
|
70
|
+
head_request = stub_request(:head, page_url).to_return(status: 200)
|
|
71
|
+
|
|
72
|
+
expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
|
|
73
|
+
|
|
74
|
+
expect(head_request).to have_been_requested.once
|
|
75
|
+
expect(get_request).to have_been_requested.once
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
[403, 404, 405, 501].each do |status|
|
|
79
|
+
it "continues to GET when HEAD returns #{status}" do
|
|
80
|
+
head_request = stub_request(:head, page_url)
|
|
81
|
+
.to_return(status: status, headers: { 'Content-Type' => 'text/plain' })
|
|
82
|
+
|
|
83
|
+
expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
|
|
84
|
+
|
|
85
|
+
expect(head_request).to have_been_requested.once
|
|
86
|
+
expect(get_request).to have_been_requested.once
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'still rejects a non-HTML GET response when HEAD reports HTML' do
|
|
91
|
+
head_request = stub_request(:head, page_url).to_return(headers: { 'Content-Type' => 'text/html' })
|
|
92
|
+
stub_request(:get, page_url).to_return(headers: { 'Content-Type' => 'application/pdf' })
|
|
93
|
+
|
|
94
|
+
expect do
|
|
95
|
+
MetaInspector.new(page_url, head_first: true)
|
|
96
|
+
end.to raise_error(MetaInspector::NonHtmlError)
|
|
97
|
+
|
|
98
|
+
expect(head_request).to have_been_requested.once
|
|
99
|
+
expect(get_request).to have_been_requested.once
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'does not send requests for a supplied document' do
|
|
103
|
+
expect(MetaInspector.new(page_url, head_first: true, document: html).title).to eq('Downloaded page')
|
|
104
|
+
|
|
105
|
+
expect(a_request(:head, page_url)).not_to have_been_made
|
|
106
|
+
expect(get_request).not_to have_been_requested
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'sends custom headers on both requests' do
|
|
110
|
+
headers = { 'User-Agent' => 'Custom agent', 'Authorization' => 'Bearer test-token' }
|
|
111
|
+
head_request = stub_request(:head, page_url).with(headers: headers)
|
|
112
|
+
.to_return(headers: { 'Content-Type' => 'text/html' })
|
|
113
|
+
get_with_headers = stub_request(:get, page_url).with(headers: headers).to_return(body: html)
|
|
114
|
+
|
|
115
|
+
expect(MetaInspector.new(page_url, head_first: true, headers: headers).title).to eq('Downloaded page')
|
|
116
|
+
|
|
117
|
+
expect(head_request).to have_been_requested.once
|
|
118
|
+
expect(get_with_headers).to have_been_requested.once
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'follows HEAD redirects to reject non-HTML content before GET' do
|
|
122
|
+
target = 'http://example.com/large.pdf'
|
|
123
|
+
stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => target })
|
|
124
|
+
target_request = stub_request(:head, target).to_return(headers: { 'Content-Type' => 'application/pdf' })
|
|
125
|
+
|
|
126
|
+
expect do
|
|
127
|
+
MetaInspector.new(page_url, head_first: true)
|
|
128
|
+
end.to raise_error(MetaInspector::NonHtmlError)
|
|
129
|
+
|
|
130
|
+
expect(target_request).to have_been_requested.once
|
|
131
|
+
expect(a_request(:get, /example.com/)).not_to have_been_made
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'follows GET redirects independently of HEAD and exposes the final GET URL' do
|
|
135
|
+
head_target = 'http://example.com/head-target'
|
|
136
|
+
get_target = 'http://example.com/get-target'
|
|
137
|
+
head_request = stub_request(:head, page_url).to_return(status: 302, headers: { 'Location' => head_target })
|
|
138
|
+
stub_request(:head, head_target).to_return(headers: { 'Content-Type' => 'text/html' })
|
|
139
|
+
stub_request(:get, page_url).to_return(status: 302, headers: { 'Location' => get_target })
|
|
140
|
+
final_request = stub_request(:get, get_target).to_return(body: html, headers: { 'Content-Type' => 'text/html' })
|
|
141
|
+
|
|
142
|
+
page = MetaInspector.new(page_url, head_first: true)
|
|
143
|
+
|
|
144
|
+
expect(page.title).to eq('Downloaded page')
|
|
145
|
+
expect(page.url).to eq(get_target)
|
|
146
|
+
expect(head_request).to have_been_requested.once
|
|
147
|
+
expect(final_request).to have_been_requested.once
|
|
148
|
+
expect(a_request(:get, head_target)).not_to have_been_made
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'does not follow HEAD redirects when redirections are disabled' do
|
|
152
|
+
head_request = stub_request(:head, page_url)
|
|
153
|
+
.to_return(status: 302, headers: { 'Location' => 'http://example.com/target', 'Content-Type' => 'text/plain' })
|
|
154
|
+
|
|
155
|
+
page = MetaInspector.new(page_url, head_first: true, allow_redirections: false)
|
|
156
|
+
|
|
157
|
+
expect(page.title).to eq('Downloaded page')
|
|
158
|
+
expect(page.url).to eq(page_url)
|
|
159
|
+
expect(head_request).to have_been_requested.once
|
|
160
|
+
expect(a_request(:head, 'http://example.com/target')).not_to have_been_made
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'keeps cookies from HEAD for the GET request' do
|
|
164
|
+
head_request = stub_request(:head, page_url)
|
|
165
|
+
.to_return(headers: { 'Content-Type' => 'text/html', 'Set-Cookie' => 'session=checked; path=/' })
|
|
166
|
+
get_with_cookie = stub_request(:get, page_url).with(headers: { 'Cookie' => 'session=checked' }).to_return(body: html)
|
|
167
|
+
|
|
168
|
+
expect(MetaInspector.new(page_url, head_first: true).title).to eq('Downloaded page')
|
|
169
|
+
|
|
170
|
+
expect(head_request).to have_been_requested.once
|
|
171
|
+
expect(get_with_cookie).to have_been_requested.once
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'wraps HEAD timeouts without sending GET' do
|
|
175
|
+
head_request = stub_request(:head, page_url).to_raise(Net::ReadTimeout)
|
|
176
|
+
|
|
177
|
+
expect do
|
|
178
|
+
MetaInspector.new(page_url, head_first: true, retries: 0)
|
|
179
|
+
end.to raise_error(MetaInspector::TimeoutError)
|
|
180
|
+
|
|
181
|
+
expect(head_request).to have_been_requested.once
|
|
182
|
+
expect(get_request).not_to have_been_requested
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'wraps HEAD connection failures without sending GET' do
|
|
186
|
+
head_request = stub_request(:head, page_url).to_raise(Faraday::ConnectionFailed.new('connection failed'))
|
|
187
|
+
|
|
188
|
+
expect do
|
|
189
|
+
MetaInspector.new(page_url, head_first: true, retries: 0)
|
|
190
|
+
end.to raise_error(MetaInspector::RequestError)
|
|
191
|
+
|
|
192
|
+
expect(head_request).to have_been_requested.once
|
|
193
|
+
expect(get_request).not_to have_been_requested
|
|
194
|
+
end
|
|
195
|
+
end
|