simple-rss 2.2.0 → 2.3.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +492 -8
  4. data/Rakefile +1 -1
  5. data/examples/digest.rb +19 -0
  6. data/examples/discover.rb +14 -0
  7. data/examples/feedbag.rb +10 -0
  8. data/lib/simple-rss/discovery.rb +107 -0
  9. data/lib/simple-rss/entry_normalizer.rb +356 -0
  10. data/lib/simple-rss/http_client.rb +216 -0
  11. data/lib/simple-rss/json_entry_normalizer.rb +147 -0
  12. data/lib/simple-rss/json_feed.rb +176 -0
  13. data/lib/simple-rss/normalized_entry.rb +79 -0
  14. data/lib/simple-rss/request_errors.rb +21 -0
  15. data/lib/simple-rss/request_policy.rb +72 -0
  16. data/lib/simple-rss/xml_element.rb +136 -0
  17. data/lib/simple-rss.rb +225 -77
  18. data/simple-rss.gemspec +5 -5
  19. data/test/base/category_parsing_test.rb +217 -0
  20. data/test/base/date_ordering_test.rb +95 -0
  21. data/test/base/discovery_dependency_test.rb +29 -0
  22. data/test/base/discovery_test.rb +151 -0
  23. data/test/base/discovery_transport_test.rb +413 -0
  24. data/test/base/enumerable_test.rb +16 -0
  25. data/test/base/feedbag_integration_test.rb +21 -0
  26. data/test/base/json_feed_test.rb +372 -0
  27. data/test/base/normalized_entries_test.rb +410 -0
  28. data/test/base/normalized_fetch_test.rb +132 -0
  29. data/test/base/relation_links_test.rb +162 -0
  30. data/test/data/atom_categories.xml +23 -0
  31. data/test/data/atom_nested_link.xml +13 -0
  32. data/test/data/discovery.html +28 -0
  33. data/test/data/json_feed_1.json +75 -0
  34. data/test/data/json_feed_1_1.json +78 -0
  35. data/test/data/mixed_dates.xml +55 -0
  36. data/test/data/normalized_atom.xml +22 -0
  37. data/test/data/normalized_rss.xml +21 -0
  38. data/test/data/rss_categories.xml +15 -0
  39. data/test/support/http_server.rb +44 -0
  40. data/test/support/replace_method.rb +14 -0
  41. metadata +42 -10
@@ -0,0 +1,107 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "http_client"
4
+
5
+ class SimpleRSS::Discovery
6
+ MEDIA_TYPES = {
7
+ "application/rss+xml" => :rss, "application/rdf+xml" => :rss,
8
+ "application/atom+xml" => :atom, "application/feed+json" => :json_feed, "application/json" => :json_feed
9
+ }.freeze
10
+
11
+ # @rbs @options: Hash[Symbol, untyped]
12
+ # @rbs @parser: untyped
13
+
14
+ # @rbs (Hash[Symbol, untyped]) -> void
15
+ def initialize(options)
16
+ @options = { network_policy: :public }.merge(options)
17
+ require "nokogiri"
18
+ @parser = Object.const_get(:Nokogiri)
19
+ unless @parser.const_defined?(:HTML5)
20
+ raise SimpleRSS::DiscoveryDependencyError, "Discovery requires Nokogiri HTML5 support (available on CRuby)"
21
+ end
22
+ rescue LoadError
23
+ raise SimpleRSS::DiscoveryDependencyError, 'Install the optional "nokogiri" gem (>= 1.16, < 2) to use SimpleRSS.discover'
24
+ end
25
+
26
+ # @rbs (String) -> Array[Hash[Symbol, untyped]]
27
+ def discover(url)
28
+ raise SimpleRSS::PolicyError, "Expected a website URL string" unless url.is_a?(String)
29
+
30
+ url = "https:#{url}" if url.start_with?("//")
31
+ url = "https://#{url}" unless url.match?(/\A[a-z][a-z\d+.-]*:/i)
32
+ response, uri = SimpleRSS::HTTPClient.new(@options).get(url)
33
+ raise SimpleRSS::HTTPError, response.code.to_i unless response.is_a?(Net::HTTPSuccess)
34
+
35
+ candidates(response.body.to_s, uri, response.content_type, response.type_params["charset"])
36
+ end
37
+
38
+ private
39
+
40
+ # @rbs (String, untyped, String?, String?) -> Array[Hash[Symbol, untyped]]
41
+ def candidates(body, uri, media_type, encoding)
42
+ prefix = body.b.sub(/\A\xEF\xBB\xBF/n, "").lstrip
43
+ return [feed_candidate(body, uri, :json_feed)] if prefix.start_with?("{", "[")
44
+
45
+ document = @parser::XML.parse(body, uri.to_s, nil, @parser::XML::ParseOptions::NONET | @parser::XML::ParseOptions::RECOVER)
46
+ root = document.root
47
+ format = root && xml_format(root)
48
+ return [feed_candidate(body, uri, format)] if format
49
+
50
+ unless root&.name&.casecmp?("html") || %w[text/html application/xhtml+xml].include?(media_type) || prefix.match?(/\A(?:<!doctype\s+html|<html\b|<head\b)/i)
51
+ raise SimpleRSS::DiscoveryError, "Response is not a recognized feed or HTML page"
52
+ end
53
+
54
+ html_candidates(body, uri, encoding)
55
+ rescue SimpleRSS::RequestError, SimpleRSS::DiscoveryError
56
+ raise
57
+ rescue SimpleRSSError, ArgumentError, EncodingError => e
58
+ raise SimpleRSS::DiscoveryError, "Cannot parse discovery response: #{e.message}"
59
+ end
60
+
61
+ # @rbs (untyped) -> Symbol?
62
+ def xml_format(root)
63
+ return :rss if root.name == "rss" && root.namespace.nil?
64
+ return :atom if root.name == "feed" && [SimpleRSS::ATOM_NAMESPACE, "http://purl.org/atom/ns#"].include?(root.namespace&.href)
65
+ return :rss if root.name == "RDF" && root.namespace&.href == "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
66
+
67
+ nil
68
+ end
69
+
70
+ # @rbs (String, untyped, Symbol) -> Hash[Symbol, untyped]
71
+ def feed_candidate(body, uri, format)
72
+ feed = SimpleRSS.parse(body, source_url: uri.to_s)
73
+ title = feed.instance_variable_get(:@title)
74
+ { url: uri.to_s, title: title, format: format, media_type: MEDIA_TYPES.key(format), source: :document, verified: true }
75
+ end
76
+
77
+ # @rbs (String, untyped, String?) -> Array[Hash[Symbol, untyped]]
78
+ def html_candidates(body, uri, encoding)
79
+ document = @parser::HTML5.parse(body, uri.to_s, encoding, max_tree_depth: 128, max_attributes: 128)
80
+ head = document.at_css("html > head")
81
+ return [] unless head
82
+
83
+ base = resolve_url(head.at_xpath("./base[@href]")&.[]("href"), uri) || uri
84
+ candidates = head.xpath("./link[@rel][@type][@href]").filter_map do |link|
85
+ next unless link["rel"].downcase.split.include?("alternate")
86
+
87
+ media_type = link["type"].split(";", 2).first.to_s.strip.downcase
88
+ format = MEDIA_TYPES[media_type]
89
+ next unless format
90
+
91
+ target = resolve_url(link["href"], base)
92
+ next unless target
93
+
94
+ { url: target.to_s, title: link["title"], format: format, media_type: media_type, source: :html_link, verified: false }
95
+ end
96
+ candidates.uniq { |candidate| candidate[:url] }
97
+ end
98
+
99
+ # @rbs (String?, untyped) -> untyped
100
+ def resolve_url(value, base)
101
+ return if value.nil? || value.strip.empty?
102
+
103
+ SimpleRSS::RequestPolicy.parse_url(URI.join(base.to_s, value.strip).to_s)
104
+ rescue URI::Error, SimpleRSS::PolicyError
105
+ nil
106
+ end
107
+ end
@@ -0,0 +1,356 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "uri"
4
+
5
+ class SimpleRSS::EntryNormalizer
6
+ ATOM = SimpleRSS::ATOM_NAMESPACE
7
+ CONTENT = "http://purl.org/rss/1.0/modules/content/".freeze
8
+ DUBLIN_CORE = "http://purl.org/dc/elements/1.1/".freeze
9
+ MEDIA = "http://search.yahoo.com/mrss/".freeze
10
+ ITUNES = "http://www.itunes.com/dtds/podcast-1.0.dtd".freeze
11
+ XHTML = "http://www.w3.org/1999/xhtml".freeze
12
+
13
+ # @rbs (Hash[Symbol, untyped]) -> void
14
+ def self.validate_mappings(mappings)
15
+ allowed = %i[content_html content_text categories]
16
+ raise ArgumentError, "mappings must be a Hash with keys #{allowed.join(", ")}" unless mappings.is_a?(Hash) && (mappings.keys - allowed).empty?
17
+
18
+ %i[content_html content_text].each do |field|
19
+ next unless mappings.key?(field)
20
+ raise ArgumentError, "#{field} mapping must be a nonempty XML tag name" unless mappings[field].is_a?(String) && !mappings[field].strip.empty?
21
+ end
22
+ return unless mappings.key?(:categories)
23
+
24
+ categories = mappings[:categories]
25
+ raise ArgumentError, "categories mapping must be an array of tag/separator records" unless categories.is_a?(Array)
26
+
27
+ categories.each do |mapping|
28
+ valid = mapping.is_a?(Hash) && (mapping.keys - %i[tag separator]).empty? && mapping[:tag].is_a?(String) && !mapping[:tag].strip.empty?
29
+ valid &&= !mapping.key?(:separator) || (mapping[:separator].is_a?(String) && !mapping[:separator].empty?)
30
+ raise ArgumentError, "category mappings require a tag and an optional nonempty separator" unless valid
31
+ end
32
+ end
33
+
34
+ # @rbs (SimpleRSS::XmlElement, Hash[Symbol, untyped], Hash[Symbol, untyped]) -> void
35
+ def initialize(element, raw, options)
36
+ @element = element
37
+ @children = element.children
38
+ @source_url = options[:source_url] #: String?
39
+ @mappings = options.fetch(:mappings) #: Hash[Symbol, untyped]
40
+ @feed_authors = options.fetch(:feed_authors) #: Array[SimpleRSS::XmlElement]
41
+ @values = { raw: raw, raw_xml: options[:raw_xml], field_sources: {}, issues: [] } #: Hash[Symbol, untyped]
42
+ @field_elements = {} #: Hash[Symbol, SimpleRSS::XmlElement]
43
+ end
44
+
45
+ # @rbs () -> SimpleRSS::NormalizedEntry
46
+ def entry
47
+ read_identity
48
+ read_links
49
+ read_dates
50
+ read_content
51
+ read_categories
52
+ read_attachments
53
+ read_authors
54
+ SimpleRSS::NormalizedEntry.new(@values)
55
+ end
56
+
57
+ private
58
+
59
+ # @rbs () -> bool
60
+ def atom?
61
+ @element.namespace == ATOM
62
+ end
63
+
64
+ # @rbs (String) -> Array[SimpleRSS::XmlElement]
65
+ def core_elements(name)
66
+ @children.select { |element| atom? ? element.matches?(name, ATOM) : element.rss?(name) }
67
+ end
68
+
69
+ # @rbs (String, String) -> Array[SimpleRSS::XmlElement]
70
+ def extension_elements(name, namespace)
71
+ @children.select { |element| element.matches?(name, namespace) }
72
+ end
73
+
74
+ # @rbs (Symbol, untyped, SimpleRSS::XmlElement) -> void
75
+ def assign(field, value, element)
76
+ return if value.nil? || value == ""
77
+
78
+ @values[field] = value
79
+ @values[:field_sources][field] = element.name
80
+ @field_elements[field] = element
81
+ end
82
+
83
+ # @rbs (Symbol, Symbol, untyped, SimpleRSS::XmlElement) -> nil
84
+ def issue(field, code, value, element)
85
+ @values[:issues] << { field: field, code: code, value: value, source: element.name }
86
+ nil
87
+ end
88
+
89
+ # @rbs () -> void
90
+ def read_identity
91
+ identifier = core_elements(atom? ? "id" : "guid").find { |element| !element.text.empty? }
92
+ assign(:identifier, identifier.text, identifier) if identifier
93
+ title = core_elements("title").first
94
+ assign(:title, title.text, title) if title
95
+ end
96
+
97
+ # @rbs () -> void
98
+ def read_links
99
+ elements = @children.select { |element| element.matches?("link", ATOM) || (!atom? && element.rss?("link")) }
100
+ links = elements.map do |element|
101
+ href = element.namespace == ATOM ? element.attributes["href"] : element.text
102
+ relation = element.namespace == ATOM ? element.attributes.fetch("rel", "alternate") : "alternate"
103
+ relation = relation.delete_prefix("http://www.iana.org/assignments/relation/")
104
+ { url: resolve_url(href, element, :url), rel: relation, media_type: element.attributes["type"], raw: element.raw }
105
+ end
106
+ @values[:links] = links
107
+ candidates = links.each_index.select { |index| links[index][:rel] == "alternate" && links[index][:url] }
108
+ selected = candidates.min_by do |index|
109
+ link = links[index]
110
+ media_type = link[:media_type].to_s.split(";").first.to_s.downcase.strip
111
+ [!atom? && elements.fetch(index).rss?("link") ? 0 : 1, media_type_priority(media_type), index]
112
+ end
113
+ assign(:url, links[selected][:url], elements.fetch(selected)) if selected
114
+ end
115
+
116
+ # @rbs (String) -> Integer
117
+ def media_type_priority(media_type)
118
+ return 0 if %w[text/html application/xhtml+xml].include?(media_type)
119
+ return 1 if media_type.empty?
120
+
121
+ 2
122
+ end
123
+
124
+ # @rbs (String?, SimpleRSS::XmlElement, Symbol) -> String?
125
+ def resolve_url(value, element, field)
126
+ return if value.nil? || value.strip.empty?
127
+
128
+ reference = value.strip
129
+ return reference if URI.parse(reference).absolute?
130
+
131
+ base = base_url(element)
132
+ unless base
133
+ issue(field, :relative_url_without_base, value, element)
134
+ return reference
135
+ end
136
+ URI.join(base, reference).to_s
137
+ rescue URI::Error
138
+ issue(field, :invalid_url, value, element)
139
+ value
140
+ end
141
+
142
+ # @rbs (SimpleRSS::XmlElement) -> String?
143
+ def base_url(element)
144
+ candidates = [@source_url, *element.base_urls].compact
145
+ start = candidates.rindex { |value| URI.parse(value).absolute? }
146
+ return unless start
147
+
148
+ base = candidates.fetch(start)
149
+ candidates.drop(start + 1).each { |value| base = URI.join(base, value).to_s }
150
+ base
151
+ rescue URI::Error
152
+ issue(:base_url, :invalid_url, candidates, element)
153
+ end
154
+
155
+ # @rbs () -> void
156
+ def read_dates
157
+ published = atom? ? core_elements("published") : core_elements("pubDate") + extension_elements("date", DUBLIN_CORE)
158
+ updated = extension_elements("updated", ATOM)
159
+ updated += core_elements("modified") unless atom?
160
+ read_date(:published_at, published)
161
+ read_date(:updated_at, updated)
162
+ end
163
+
164
+ # @rbs (Symbol, Array[SimpleRSS::XmlElement]) -> void
165
+ def read_date(field, elements)
166
+ elements.each do |element|
167
+ next if element.text.empty?
168
+
169
+ begin
170
+ assign(field, Time.parse(element.text), element)
171
+ break
172
+ rescue ArgumentError, RangeError
173
+ issue(field, :invalid_date, element.text, element)
174
+ end
175
+ end
176
+ end
177
+
178
+ # @rbs () -> void
179
+ def read_content
180
+ %i[content_html content_text].each do |field|
181
+ selector = @mappings[field]
182
+ element = selector && @children.find { |child| child.selected?(selector) && !child.text.empty? }
183
+ assign(field, element.text, element) if element
184
+ end
185
+ encoded = extension_elements("encoded", CONTENT).find { |element| !element.text.empty? }
186
+ assign(:content_html, encoded.text, encoded) if encoded && !@values[:content_html]
187
+ read_atom_content
188
+ full_content = @field_elements[:content_html] || @field_elements[:content_text]
189
+ @values[:content_base_url] = base_url(content_container(full_content)) if full_content
190
+ summary = core_elements(atom? ? "summary" : "description").first
191
+ return unless summary
192
+
193
+ value, type = atom? ? text_construct(summary, :summary) : [summary.text, :html]
194
+ assign(:summary, value, summary)
195
+ @values[:summary_type] = type if @values[:summary]
196
+ end
197
+
198
+ # @rbs () -> void
199
+ def read_atom_content
200
+ content = extension_elements("content", ATOM).first
201
+ return unless content
202
+ return assign(:content_url, resolve_url(content.attributes["src"], content, :content_url), content) if content.attributes["src"]
203
+
204
+ value, type = text_construct(content, :content)
205
+ field = type == :html ? :content_html : :content_text
206
+ assign(field, value, content) unless @values[field]
207
+ end
208
+
209
+ # @rbs (SimpleRSS::XmlElement) -> SimpleRSS::XmlElement
210
+ def content_container(element)
211
+ type = element.attributes["type"].to_s.split(";", 2).first.to_s.strip.downcase
212
+ return element unless %w[xhtml application/xhtml+xml].include?(type)
213
+
214
+ element.children.find { |child| child.matches?("div", XHTML) } || element
215
+ end
216
+
217
+ # @rbs (SimpleRSS::XmlElement, Symbol) -> [String?, Symbol?]
218
+ def text_construct(element, field)
219
+ type = element.attributes.fetch("type", "text").split(";", 2).first.to_s.strip.downcase
220
+ case type
221
+ when "text", "text/plain"
222
+ return [element.text, :text] if element.children.empty?
223
+
224
+ issue(field, :unexpected_markup, element.content, element)
225
+ when "html", "text/html"
226
+ return [element.text, :html]
227
+ when "xhtml", "application/xhtml+xml"
228
+ children = element.children
229
+ return [children.first.xhtml_content, :html] if children.size == 1 && children.first.matches?("div", XHTML)
230
+
231
+ issue(field, :invalid_xhtml, element.content, element)
232
+ else
233
+ issue(field, :unsupported_content_type, type, element)
234
+ end
235
+ [nil, nil]
236
+ end
237
+
238
+ # @rbs () -> void
239
+ def read_categories
240
+ details = @children.flat_map do |element|
241
+ mappings = @mappings[:categories] || [] #: Array[Hash[Symbol, untyped]]
242
+ mapping = mappings.find { |candidate| element.selected?(candidate[:tag]) }
243
+ term = category_term(element)
244
+ next [] unless mapping || term
245
+
246
+ terms = [term]
247
+ terms = mapping[:separator] ? element.text.split(mapping[:separator]) : [element.text] if mapping
248
+ terms.filter_map do |value|
249
+ next if value.nil? || value.strip.empty?
250
+
251
+ {
252
+ term: value.strip, label: element.attributes["label"], scheme: element.attributes["scheme"] || element.attributes["domain"],
253
+ source: element.name, raw: element.raw
254
+ }
255
+ end
256
+ end
257
+ @values[:category_details] = details
258
+ @values[:categories] = details.map { |category| category[:term] }.uniq
259
+ end
260
+
261
+ # @rbs (SimpleRSS::XmlElement) -> String?
262
+ def category_term(element)
263
+ return element.attributes["term"] if element.matches?("category", ATOM)
264
+ return element.text if !atom? && element.rss?("category")
265
+ return element.text if element.matches?("subject", DUBLIN_CORE)
266
+ return element.text if element.matches?("keywords", MEDIA) || element.matches?("keywords", ITUNES)
267
+
268
+ nil
269
+ end
270
+
271
+ # @rbs () -> void
272
+ def read_attachments
273
+ elements = @children.flat_map do |element|
274
+ next [element] unless element.matches?("group", MEDIA)
275
+
276
+ element.children.select { |child| child.matches?("content", MEDIA) }
277
+ end
278
+ attachments = elements.filter_map do |element|
279
+ attributes = element.attributes
280
+ if element.matches?("content", MEDIA)
281
+ attachment(element, attributes["url"], attributes["fileSize"], attributes["duration"])
282
+ elsif !atom? && element.rss?("enclosure")
283
+ attachment(element, attributes["url"], attributes["length"], nil)
284
+ elsif element.matches?("link", ATOM) && attributes["rel"].to_s.delete_prefix("http://www.iana.org/assignments/relation/") == "enclosure"
285
+ attachment(element, attributes["href"], attributes["length"], nil)
286
+ end
287
+ end
288
+ duration = extension_elements("duration", ITUNES).first
289
+ if attachments.size == 1 && duration && !attachments.first[:raw][:attributes].key?("duration")
290
+ attachments.first[:duration_in_seconds] = duration_seconds(duration.text, duration)
291
+ attachments.first[:raw_duration] = duration.raw
292
+ end
293
+ @values[:attachments] = attachments
294
+ end
295
+
296
+ # @rbs (SimpleRSS::XmlElement, String?, String?, String?) -> Hash[Symbol, untyped]?
297
+ def attachment(element, url, size, duration)
298
+ resolved_url = resolve_url(url, element, :attachment_url)
299
+ return unless resolved_url
300
+
301
+ {
302
+ url: resolved_url, media_type: element.attributes["type"], size_in_bytes: size_in_bytes(size, element),
303
+ duration_in_seconds: duration_seconds(duration, element), source: element.name, raw: element.raw
304
+ }
305
+ end
306
+
307
+ # @rbs (String?, SimpleRSS::XmlElement) -> Integer?
308
+ def size_in_bytes(value, element)
309
+ return if value.nil?
310
+ return value.to_i if value.match?(/\A\d+\z/)
311
+
312
+ issue(:size_in_bytes, :invalid_number, value, element)
313
+ end
314
+
315
+ # @rbs (String?, SimpleRSS::XmlElement) -> (Integer | Float)?
316
+ def duration_seconds(value, element)
317
+ return if value.nil?
318
+ return value.to_f if value.match?(/\A\d+(?:\.\d+)?\z/) && value.to_f.finite?
319
+
320
+ unless element.matches?("duration", ITUNES) && value.match?(/\A\d+:\d{2}(?::\d{2})?\z/)
321
+ return issue(:duration_in_seconds, :invalid_number, value, element)
322
+ end
323
+
324
+ parts = value.split(":").map(&:to_i)
325
+ return issue(:duration_in_seconds, :invalid_number, value, element) unless parts.drop(1).all? { |part| part < 60 }
326
+
327
+ parts.reduce(0) { |total, part| (total * 60) + part }
328
+ end
329
+
330
+ # @rbs () -> void
331
+ def read_authors
332
+ @values[:authors] = author_elements.map do |element|
333
+ next { name: element.text, email: nil, url: nil, raw: element.raw } if element.matches?("creator", DUBLIN_CORE)
334
+ next { name: nil, email: element.text, url: nil, raw: element.raw } unless element.namespace == ATOM
335
+
336
+ children = element.children
337
+ name = children.find { |child| child.matches?("name", ATOM) }
338
+ email = children.find { |child| child.matches?("email", ATOM) }
339
+ uri = children.find { |child| child.matches?("uri", ATOM) }
340
+ { name: name&.text, email: email&.text, url: uri && resolve_url(uri.text, uri, :author_url), raw: element.raw }
341
+ end
342
+ end
343
+
344
+ # @rbs () -> Array[SimpleRSS::XmlElement]
345
+ def author_elements
346
+ elements = core_elements("author")
347
+ return elements + extension_elements("creator", DUBLIN_CORE) unless atom?
348
+ return elements unless elements.empty?
349
+
350
+ source = core_elements("source").first
351
+ return @feed_authors unless source
352
+
353
+ elements = source.children.select { |element| element.matches?("author", ATOM) }
354
+ elements.empty? ? @feed_authors : elements
355
+ end
356
+ end
@@ -0,0 +1,216 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "net/http"
4
+ require "timeout"
5
+ require "openssl"
6
+ require_relative "request_errors"
7
+ require_relative "request_policy"
8
+
9
+ class SimpleRSS::HTTPClient
10
+ ACCEPT = "application/feed+json, application/rss+xml, application/atom+xml, application/json, application/xml, text/xml, */*".freeze
11
+ REDIRECT_CODES = %w[301 302 303 307 308].freeze
12
+ CROSS_ORIGIN_HEADERS = %w[accept accept-language user-agent].freeze
13
+ PROTECTED_HEADERS = %w[host connection proxy-authorization proxy-connection accept-encoding range transfer-encoding content-length te trailer upgrade
14
+ expect].freeze
15
+
16
+ # @rbs @options: Hash[Symbol, untyped]
17
+ # @rbs @headers: Hash[untyped, untyped]
18
+ # @rbs @policy: SimpleRSS::RequestPolicy?
19
+ # @rbs @remaining_bytes: Integer
20
+ # @rbs @remaining_wire_bytes: Integer
21
+ # @rbs @redirect_limit: Integer
22
+ # @rbs @timeout: untyped
23
+
24
+ # @rbs (Hash[Symbol, untyped]) -> void
25
+ def initialize(options)
26
+ @options = options.dup
27
+ @headers = (@options[:headers] || {}).dup
28
+ @policy = @options.key?(:network_policy) ? SimpleRSS::RequestPolicy.new(@options[:network_policy]) : nil
29
+ @remaining_bytes = @options.fetch(:max_bytes, 2 * 1024 * 1024)
30
+ @remaining_wire_bytes = @remaining_bytes
31
+ @redirect_limit = @options.fetch(:max_redirects, 5)
32
+ @timeout = @options.fetch(:timeout, @policy ? 10 : nil)
33
+ validate_options
34
+ end
35
+
36
+ # @rbs (String) -> [untyped, untyped]
37
+ def get(url)
38
+ return perform(URI.parse(url)) unless @policy
39
+
40
+ Timeout.timeout(@timeout, SimpleRSS::RequestTimeout, "HTTP operation exceeded its timeout") do
41
+ perform(SimpleRSS::RequestPolicy.parse_url(url))
42
+ end
43
+ rescue Timeout::Error
44
+ raise unless @policy
45
+
46
+ raise SimpleRSS::RequestTimeout, "HTTP operation exceeded its timeout"
47
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Resolv::ResolvError, Zlib::Error => e
48
+ raise unless @policy
49
+
50
+ raise SimpleRSS::RequestError, "HTTP transport failed: #{e.message}"
51
+ end
52
+
53
+ private
54
+
55
+ # @rbs () -> void
56
+ def validate_options
57
+ if !@policy && (@options.key?(:max_bytes) || @options.key?(:max_redirects))
58
+ raise ArgumentError, "max_bytes and max_redirects require network_policy"
59
+ end
60
+ return unless @policy
61
+
62
+ unless @timeout.is_a?(Numeric) && @timeout.real? && @timeout.finite? && @timeout.positive?
63
+ raise ArgumentError, "timeout must be a positive finite number"
64
+ end
65
+ unless @remaining_bytes.is_a?(Integer) && @remaining_bytes.positive?
66
+ raise ArgumentError, "max_bytes must be a positive integer"
67
+ end
68
+ unless @redirect_limit.is_a?(Integer) && @redirect_limit >= 0
69
+ raise ArgumentError, "max_redirects must be a nonnegative integer"
70
+ end
71
+ raise ArgumentError, "headers must be a hash" unless @headers.is_a?(Hash)
72
+
73
+ @headers.each do |name, value|
74
+ if PROTECTED_HEADERS.include?(name.to_s.downcase)
75
+ raise ArgumentError, "The transport controls the #{name} header"
76
+ end
77
+ unless name.to_s.match?(/\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/) && value.is_a?(String) && !value.match?(/[\r\n\x00]/)
78
+ raise ArgumentError, "Invalid request header"
79
+ end
80
+ end
81
+ end
82
+
83
+ # @rbs (untyped) -> [untyped, untyped]
84
+ def perform(uri)
85
+ visited = {} #: Hash[String, bool]
86
+ redirects = @policy ? 0 : (@options[:_redirects] || 0)
87
+ redirect_error = @policy ? SimpleRSS::RedirectError : SimpleRSSError
88
+ loop do
89
+ raise SimpleRSS::RedirectError, "Redirect loop detected" if @policy && visited[uri.to_s]
90
+
91
+ visited[uri.to_s] = true
92
+ response = request(uri)
93
+ return [response, uri] unless redirect?(response)
94
+
95
+ location = response["Location"]
96
+ return [response, uri] unless location
97
+
98
+ redirects += 1
99
+ raise redirect_error, "Too many redirects" if redirects > @redirect_limit
100
+
101
+ next_uri = URI.join(uri.to_s, location)
102
+ next_uri = SimpleRSS::RequestPolicy.parse_url(next_uri.to_s) if @policy
103
+ strip_credentials if @policy && origin(uri) != origin(next_uri)
104
+ uri = next_uri
105
+ end
106
+ rescue URI::Error
107
+ raise unless @policy
108
+
109
+ raise SimpleRSS::PolicyError, "Malformed redirect URL"
110
+ end
111
+
112
+ # @rbs (untyped) -> bool
113
+ def redirect?(response)
114
+ return false if @options[:follow_redirects] == false
115
+ return REDIRECT_CODES.include?(response.code) if @policy
116
+
117
+ response.is_a?(Net::HTTPRedirection)
118
+ end
119
+
120
+ # @rbs (untyped) -> untyped
121
+ def request(uri)
122
+ http = build_http(uri)
123
+ request = Net::HTTP::Get.new(uri)
124
+ request["Accept"] = ACCEPT
125
+ request["User-Agent"] = "SimpleRSS/#{SimpleRSS::VERSION}"
126
+ request["If-None-Match"] = @options[:etag] if @options[:etag]
127
+ request["If-Modified-Since"] = @options[:last_modified] if @options[:last_modified]
128
+ @headers.each { |name, value| request[name] = value }
129
+ return http.request(request) unless @policy
130
+
131
+ request["Accept-Encoding"] = "gzip, deflate, identity"
132
+ http.request(request) { |response| read_response(response) }
133
+ end
134
+
135
+ # @rbs (untyped) -> untyped
136
+ def build_http(uri)
137
+ host = uri.hostname || raise(SimpleRSSError, "Invalid URL: missing host")
138
+ policy = @policy
139
+ http = policy ? Net::HTTP.new(host, uri.port, nil) : Net::HTTP.new(host, uri.port)
140
+ http.use_ssl = uri.scheme == "https"
141
+ if @timeout
142
+ http.open_timeout = @timeout
143
+ http.read_timeout = @timeout
144
+ end
145
+ return http unless policy
146
+
147
+ http.ipaddr = policy.address(uri)
148
+ http.write_timeout = @timeout
149
+ http.max_retries = 0
150
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
151
+ http.verify_hostname = true
152
+ http
153
+ end
154
+
155
+ # @rbs (untyped) -> void
156
+ def read_response(response)
157
+ return unless response.class.body_permitted?
158
+
159
+ encoding = response["Content-Encoding"].to_s.downcase
160
+ unless ["", "identity", "none", "gzip", "x-gzip", "deflate"].include?(encoding)
161
+ raise SimpleRSS::RequestError, "Unsupported Content-Encoding"
162
+ end
163
+ raise SimpleRSS::RequestError, "Partial HTTP responses are not supported" if response["Content-Range"] || response.code == "206"
164
+
165
+ body = +"".b
166
+ wire_bytes = 0
167
+ inflater = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) if %w[gzip x-gzip deflate].include?(encoding)
168
+ response.read_body do |chunk|
169
+ if chunk.bytesize > @remaining_wire_bytes
170
+ raise SimpleRSS::ResponseTooLarge, "HTTP response bodies exceeded max_bytes"
171
+ end
172
+
173
+ @remaining_wire_bytes -= chunk.bytesize
174
+ wire_bytes += chunk.bytesize
175
+ unless inflater
176
+ append_body(body, chunk)
177
+ next
178
+ end
179
+ inflater.inflate(chunk) { |decoded| append_body(body, decoded) }
180
+ raise SimpleRSS::RequestError, "Trailing data in compressed HTTP response" if inflater.total_in != wire_bytes
181
+ end
182
+ raise SimpleRSS::RequestError, "Incomplete compressed HTTP response" if inflater && !inflater.finished?
183
+
184
+ declared_length = response.content_length
185
+ if declared_length && !response.chunked? && wire_bytes != declared_length
186
+ raise SimpleRSS::RequestError, "Incomplete HTTP response body"
187
+ end
188
+
189
+ response.delete("Content-Encoding")
190
+ response["Content-Length"] = body.bytesize.to_s if declared_length
191
+ response.body = body
192
+ ensure
193
+ inflater&.close
194
+ end
195
+
196
+ # @rbs (String, String) -> nil
197
+ def append_body(body, chunk)
198
+ raise SimpleRSS::ResponseTooLarge, "HTTP response bodies exceeded max_bytes" if chunk.bytesize > @remaining_bytes
199
+
200
+ @remaining_bytes -= chunk.bytesize
201
+ body << chunk
202
+ nil
203
+ end
204
+
205
+ # @rbs (untyped) -> Array[untyped]
206
+ def origin(uri)
207
+ [uri.scheme.downcase, uri.hostname.downcase, uri.port]
208
+ end
209
+
210
+ # @rbs () -> void
211
+ def strip_credentials
212
+ @headers = @headers.select { |name, _value| CROSS_ORIGIN_HEADERS.include?(name.to_s.downcase) }
213
+ @options.delete(:etag)
214
+ @options.delete(:last_modified)
215
+ end
216
+ end