indexmap 0.8.2 → 0.9.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +287 -35
  4. data/UPGRADE.md +361 -0
  5. data/docs/architecture.md +55 -0
  6. data/docs/deployment.md +223 -0
  7. data/docs/domain-terms.md +27 -0
  8. data/docs/dynamic-sitemaps.md +302 -0
  9. data/docs/search-engine-ping.md +368 -0
  10. data/docs/storage.md +94 -0
  11. data/lib/generators/indexmap/install_generator.rb +1 -1
  12. data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
  13. data/lib/indexmap/artifact.rb +9 -0
  14. data/lib/indexmap/configuration.rb +12 -1
  15. data/lib/indexmap/creator.rb +45 -42
  16. data/lib/indexmap/index_now_configuration.rb +3 -1
  17. data/lib/indexmap/output.rb +19 -5
  18. data/lib/indexmap/parser.rb +112 -52
  19. data/lib/indexmap/pinger/base.rb +6 -5
  20. data/lib/indexmap/pinger/google.rb +12 -8
  21. data/lib/indexmap/pinger/index_now.rb +97 -52
  22. data/lib/indexmap/publication.rb +89 -0
  23. data/lib/indexmap/robots.rb +67 -0
  24. data/lib/indexmap/robots_controller.rb +13 -0
  25. data/lib/indexmap/sitemaps_controller.rb +57 -0
  26. data/lib/indexmap/storage/active_storage.rb +132 -1
  27. data/lib/indexmap/storage/filesystem.rb +182 -11
  28. data/lib/indexmap/storage/memory.rb +1 -1
  29. data/lib/indexmap/task_runner.rb +21 -28
  30. data/lib/indexmap/validator/duplicates.rb +83 -0
  31. data/lib/indexmap/validator.rb +171 -59
  32. data/lib/indexmap/version.rb +1 -1
  33. data/lib/indexmap/writer.rb +89 -34
  34. data/lib/indexmap.rb +14 -1
  35. data/lib/tasks/indexmap_tasks.rake +21 -2
  36. data/test/indexmap/correctness_test.rb +153 -0
  37. data/test/indexmap/install_generator_test.rb +36 -4
  38. data/test/indexmap/parser_test.rb +77 -0
  39. data/test/indexmap/pinger/google_test.rb +22 -0
  40. data/test/indexmap/pinger/index_now_test.rb +168 -0
  41. data/test/indexmap/publication_test.rb +280 -0
  42. data/test/indexmap/robots_test.rb +73 -0
  43. data/test/indexmap/sitemaps_controller_test.rb +50 -0
  44. data/test/indexmap/storage_test.rb +27 -0
  45. data/test/indexmap/task_runner_test.rb +24 -0
  46. data/test/indexmap/validator_test.rb +14 -0
  47. data/test/indexmap/writer_test.rb +42 -0
  48. data/test/integration/queue_checks.rb +244 -0
  49. data/test/release_task_test.rb +153 -0
  50. metadata +22 -5
  51. data/lib/indexmap/storage/file.rb +0 -11
data/docs/storage.md ADDED
@@ -0,0 +1,94 @@
1
+ # Storage contract
2
+
3
+ > Atomic publication and streamed delivery describe unreleased main.
4
+ > See [release status](../README.md#release-status).
5
+
6
+ All outputs share `config.storage`. Operation instances resolve storage lazily
7
+ once; construct a new runner, validator, creator, or pinger for a new storage
8
+ context. Collaborators receive the same adapter through `storage:`. Configure
9
+ public URLs where crawlers can fetch artifacts, even when the backing objects are private. Built-in persistent
10
+ adapters are `Indexmap::Storage::Filesystem` and
11
+ `Indexmap::Storage::ActiveStorage`; the latter uses a host-owned model and attachment.
12
+ `Storage::Memory` is useful in tests and retains entire bodies in memory.
13
+
14
+ ## Legacy adapters
15
+
16
+ The six-method contract remains supported without atomic publication:
17
+
18
+ ```ruby
19
+ storage.write(filename, body, content_type: "application/xml")
20
+ storage.read(filename) # XML string; missing keys should raise
21
+ storage.exist?(filename) # boolean
22
+ storage.list(prefix: nil, suffix: nil) # sorted relative keys matching both filters
23
+ storage.delete(filename)
24
+ storage.public_url(filename) # public URL for this key
25
+ ```
26
+
27
+ `prefix` and `suffix` match the whole relative key, not just its basename.
28
+ Write/delete return values are not used by the generation pipeline. Custom
29
+ adapters must reject traversal outside their storage namespace and accept nested
30
+ relative keys. Restrict the configured filesystem directory to sitemap artifacts:
31
+ its `list` method restricts traversal to the prefix's literal directory and
32
+ leading filename fragment. Calls without a prefix scan the whole visible tree.
33
+ Hidden paths and symlinks are excluded; prefixes must be normalized relative
34
+ paths without traversal, absolute paths, or redundant separators.
35
+
36
+ ## Streaming and metadata
37
+
38
+ These optional methods avoid whole-file read/write fallbacks during generation
39
+ and validation. Both are mandatory for the sitemap HTTP controller's reads:
40
+
41
+ ```ruby
42
+ storage.open(filename) { |io| ... } # readable binary IO, closed after the block
43
+ storage.metadata(filename) # {etag:, bytes:, last_modified:}
44
+ storage.write_io(filename, io, content_type: "application/xml")
45
+ ```
46
+
47
+ An ETag must change whenever content changes; `bytes` is uncompressed byte length
48
+ and `last_modified` is a time. Active Storage also provides `generation` metadata
49
+ for active roots. `write_io` reads from the current IO position. Validation and
50
+ parsing use `read` when `open` plus `metadata` are unavailable. Generation uses
51
+ `write` when `write_io` is unavailable. These fallbacks retain a whole bounded
52
+ file in memory.
53
+
54
+ ## Atomic publication and pruning
55
+
56
+ ```ruby
57
+ storage.revision(root) # opaque revision token; nil when the root does not exist
58
+ storage.publish(staged_storage, files: filenames, root: root, expected: revision)
59
+ storage.prune(root: root, before: cutoff_time) # deleted relative paths
60
+ ```
61
+
62
+ `files` lists staged children and the root. `staged_storage.open(key)` supplies
63
+ validated XML. An atomic adapter must preserve immutable child keys, coordinate
64
+ activation against `expected`, and raise `Indexmap::SupersededPublication` on a
65
+ revision mismatch. Root replacement must expose a complete publication; preserve
66
+ previously published children for cached roots. A failed publish must not activate
67
+ a partial root. Built-in adapters clean up ordinary failed builds immediately;
68
+ crashes/outages can still leave orphans. `publish` has no required return value.
69
+
70
+ Atomic mode requires `revision` and `publish`; pruning additionally requires
71
+ `prune`. The shared HTTP controller uses the built-in `Publication` ownership and
72
+ archive naming contract. Custom atomic storage intended for that controller must
73
+ also preserve those archives and expose `open`/metadata. Do not claim controller
74
+ compatibility from `publish` alone.
75
+
76
+ Pruning must coordinate with activation, preserve the current generation and
77
+ other outputs, and remove only expired artifacts owned by the selected root.
78
+ Archive retirement time controls the retained generation's grace period.
79
+
80
+ Active Storage rejects stale builds before uploading, checks again before each
81
+ upload, and destroys/purges its failed build's new records and blobs outside the
82
+ root lock. Activation verifies child existence under that lock. Work already in
83
+ flight can be lost to a race; crashes still need retention/unattached cleanup.
84
+ `PublicationCleanupError#failures` identifies cleanup artifacts and errors; the
85
+ original publication error remains its cause. Preserve object-key diagnostics if
86
+ service deletion fails after a blob row has been destroyed.
87
+
88
+ Filesystem copies and root-archive preparation occur in private destination
89
+ staging outside the root lock. Activation uses renames under the lock. Each build
90
+ holds its own staging lock; prune skips live builds and reclaims abandoned
91
+ staging after the grace period, returning private directory paths too. See
92
+ [publication and retention](dynamic-sitemaps.md#atomic-publication-and-storage)
93
+ for filesystem semantics, the required unique Active Storage filename index,
94
+ attachment cleanup, and retention operations.
@@ -28,7 +28,7 @@ module Indexmap
28
28
  indexmap_tasks_requested = Rake.application.options.show_tasks ||
29
29
  ARGV.any? { |argument| ["-T", "--tasks"].include?(argument) }
30
30
  require "indexmap/tasks" if indexmap_tasks_requested ||
31
- ARGV.any? { |argument| argument.start_with?("indexmap:") }
31
+ (Rake.application.top_level_tasks + ARGV).any? { |argument| argument.start_with?("indexmap:") }
32
32
 
33
33
  RUBY
34
34
  end
@@ -1,33 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module IndexmapConfiguration
4
- MUTEX = Mutex.new
3
+ module Indexmap
4
+ module HostConfiguration
5
+ MUTEX = Mutex.new
5
6
 
6
- def self.apply
7
- return if @applied
8
-
9
- MUTEX.synchronize do
7
+ def self.apply
10
8
  return if @applied
11
9
 
12
- require "indexmap"
10
+ MUTEX.synchronize do
11
+ return if @applied
12
+
13
+ require "indexmap"
13
14
 
14
- Indexmap.configure do |config|
15
- config.base_url = -> { ENV.fetch("INDEXMAP_BASE_URL", "http://localhost:3000") }
16
- config.storage = lambda {
17
- Indexmap::Storage::Filesystem.new(
15
+ Indexmap.configure do |config|
16
+ config.base_url = -> { ENV.fetch("INDEXMAP_BASE_URL", "http://localhost:3000") }
17
+ config.storage = Indexmap::Storage::Filesystem.new(
18
18
  path: Rails.public_path,
19
19
  public_url: config.base_url
20
20
  )
21
- }
22
21
 
23
- # Search-engine pings start from config.index_filename (default: "sitemap.xml").
24
- # Configure the sitemap data owned by your application:
25
- # config.sections = -> { Sitemap.sections }
26
- end
22
+ # Search-engine pings start from config.index_filename (default: "sitemap.xml").
23
+ # Configure the sitemap data owned by your application:
24
+ # config.sections = -> { Sitemap.sections }
25
+ end
27
26
 
28
- @applied = true
27
+ @applied = true
28
+ end
29
29
  end
30
30
  end
31
31
  end
32
32
 
33
- IndexmapConfiguration.apply if defined?(Indexmap)
33
+ Indexmap::HostConfiguration.apply if defined?(Indexmap::Configuration)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Indexmap
4
+ Artifact = Struct.new(:filename, :body, :content_type) do
5
+ def basename
6
+ File.basename(filename)
7
+ end
8
+ end
9
+ end
@@ -4,6 +4,8 @@ module Indexmap
4
4
  class Configuration
5
5
  VALID_FORMATS = %i[index single_file].freeze
6
6
 
7
+ attr_accessor :atomic, :max_urls, :max_bytes
8
+
7
9
  attr_writer :base_url, :entries, :format, :index_filename, :sections, :storage
8
10
 
9
11
  def initialize
@@ -18,7 +20,8 @@ module Indexmap
18
20
  end
19
21
 
20
22
  def entries
21
- Array(resolve(@entries))
23
+ value = resolve(@entries)
24
+ value.is_a?(Enumerable) ? value : Array(value)
22
25
  end
23
26
 
24
27
  def format
@@ -38,6 +41,10 @@ module Indexmap
38
41
  @index_now ||= IndexNowConfiguration.new
39
42
  end
40
43
 
44
+ def robots
45
+ @robots ||= Robots.new(configuration: self)
46
+ end
47
+
41
48
  def storage
42
49
  resolve(@storage) || Storage::Filesystem.new(path: "public", public_url: base_url)
43
50
  end
@@ -46,6 +53,10 @@ module Indexmap
46
53
  Array(resolve(@sections))
47
54
  end
48
55
 
56
+ def output_names
57
+ [:default, *@outputs.keys].uniq
58
+ end
59
+
49
60
  def output(name)
50
61
  output = output_for(name)
51
62
  yield(output) if block_given?
@@ -1,62 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri"
3
+ require "tmpdir"
4
+ require "securerandom"
4
5
 
5
6
  module Indexmap
6
7
  class Creator
7
8
  ValidationConfiguration = Struct.new(:base_url, :index_filename, :storage)
8
9
 
9
- def initialize(output:)
10
+ attr_reader :sitemaps
11
+
12
+ def initialize(output:, storage: nil)
10
13
  @output = output
14
+ @storage = storage
11
15
  end
12
16
 
13
17
  def create
14
- files = format(write)
15
- validate(files)
16
- publish(files)
17
- files.map(&:filename)
18
+ atomic = output.atomic
19
+ root = output.index_filename
20
+ raise ConfigurationError, "Root filename uses the reserved immutable namespace" if Publication.immutable?(root)
21
+ if atomic && !(storage.respond_to?(:revision) && storage.respond_to?(:publish))
22
+ raise ConfigurationError, "Atomic publication requires storage revision and publish capabilities"
23
+ end
24
+ expected = storage.revision(root) if atomic
25
+ writer = output.writer(generation: atomic ? SecureRandom.hex(16) : nil)
26
+ files = []
27
+ Dir.mktmpdir("indexmap-build") do |directory|
28
+ staged = Storage::Filesystem.new(path: directory)
29
+ writer.each_file do |file|
30
+ raise ConfigurationError, "Duplicate sitemap filename: #{file.filename}" if staged.exist?(file.filename)
31
+
32
+ staged.write(file.filename, file.body, content_type: file.content_type)
33
+ files << file.filename
34
+ end
35
+ Validator.new(configuration: ValidationConfiguration.new(
36
+ base_url: output.base_url, index_filename: root, storage: staged
37
+ )).validate!(collect: false)
38
+ if atomic
39
+ storage.publish(staged, files: files, root: root, expected: expected)
40
+ else
41
+ files.each do |filename|
42
+ if storage.respond_to?(:write_io)
43
+ staged.open(filename) { |io| storage.write_io(filename, io, content_type: "application/xml") }
44
+ else
45
+ storage.write(filename, staged.read(filename), content_type: "application/xml")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ @sitemaps = files.map do |filename|
51
+ location = storage.respond_to?(:public_url) ? storage.public_url(filename) : filename
52
+ {filename: filename, location: location, link_count: writer.counts.fetch(filename)}
53
+ end
54
+ files
18
55
  end
19
56
 
20
57
  private
21
58
 
22
59
  attr_reader :output
23
60
 
24
- def write
25
- output.writer.write
26
- end
27
-
28
- def format(files)
29
- files.map do |file|
30
- document = Nokogiri::XML(
31
- file.body,
32
- nil,
33
- nil,
34
- Nokogiri::XML::ParseOptions::DEFAULT_XML | Nokogiri::XML::ParseOptions::NOBLANKS
35
- )
36
- save_options = Nokogiri::XML::Node::SaveOptions::FORMAT | Nokogiri::XML::Node::SaveOptions::AS_XML
37
-
38
- Storage::File.new(
39
- filename: file.filename,
40
- body: document.to_xml(indent: 2, save_with: save_options),
41
- content_type: file.content_type
42
- )
43
- end
44
- end
45
-
46
- def validate(files)
47
- Validator.new(
48
- configuration: ValidationConfiguration.new(
49
- base_url: output.base_url,
50
- index_filename: output.index_filename,
51
- storage: Storage::Memory.new(files)
52
- )
53
- ).validate!
54
- end
55
-
56
- def publish(files)
57
- files.each do |file|
58
- output.storage.write(file.filename, file.body, content_type: file.content_type)
59
- end
61
+ def storage
62
+ @storage ||= output.storage
60
63
  end
61
64
  end
62
65
  end
@@ -5,6 +5,8 @@ module Indexmap
5
5
  DEFAULT_ENDPOINT = "https://api.indexnow.org"
6
6
  DEFAULT_MAX_URLS_PER_REQUEST = 500
7
7
 
8
+ attr_accessor :max_attempts, :max_wait
9
+
8
10
  attr_writer :dry_run, :endpoint, :key, :key_filename, :max_urls_per_request, :write_key_file
9
11
 
10
12
  def dry_run?
@@ -40,7 +42,7 @@ module Indexmap
40
42
  value = resolve(@max_urls_per_request)
41
43
  return DEFAULT_MAX_URLS_PER_REQUEST if value.nil?
42
44
 
43
- value.to_i
45
+ Integer(value.to_s, 10)
44
46
  end
45
47
 
46
48
  private
@@ -4,6 +4,8 @@ module Indexmap
4
4
  class Output
5
5
  VALID_FORMATS = %i[index single_file].freeze
6
6
 
7
+ attr_writer :atomic, :max_urls, :max_bytes
8
+
7
9
  attr_writer :base_url, :entries, :format, :index_filename, :sections
8
10
 
9
11
  def initialize(configuration:)
@@ -17,7 +19,8 @@ module Indexmap
17
19
  def entries
18
20
  resolved_entries = resolve(@entries)
19
21
 
20
- Array(resolved_entries.nil? ? configuration.entries : resolved_entries)
22
+ value = resolved_entries.nil? ? configuration.entries : resolved_entries
23
+ value.is_a?(Enumerable) ? value : Array(value)
21
24
  end
22
25
 
23
26
  def format
@@ -39,7 +42,13 @@ module Indexmap
39
42
  Array(resolved_sections.nil? ? configuration.sections : resolved_sections)
40
43
  end
41
44
 
42
- def writer
45
+ def atomic
46
+ @atomic.nil? ? configuration.atomic == true : @atomic == true
47
+ end
48
+
49
+ def writer(generation: nil)
50
+ base_url = self.base_url
51
+ format = self.format
43
52
  raise ConfigurationError, "Indexmap base_url is not configured" if base_url.to_s.strip.empty?
44
53
 
45
54
  unless VALID_FORMATS.include?(format)
@@ -47,8 +56,10 @@ module Indexmap
47
56
  end
48
57
 
49
58
  if format == :single_file
50
- raise ConfigurationError, "Indexmap entries are not configured" if entries.empty?
51
- elsif sections.empty?
59
+ entries = self.entries
60
+ raise ConfigurationError, "Indexmap entries are not configured" if entries.is_a?(Array) && entries.empty?
61
+ else
62
+ sections = self.sections
52
63
  raise ConfigurationError, "Indexmap sections are not configured" if sections.empty?
53
64
  end
54
65
 
@@ -57,7 +68,10 @@ module Indexmap
57
68
  format: format,
58
69
  sections: sections,
59
70
  base_url: base_url,
60
- index_filename: index_filename
71
+ index_filename: index_filename,
72
+ max_urls: @max_urls || configuration.max_urls || Validator::MAX_ENTRIES,
73
+ max_bytes: @max_bytes || configuration.max_bytes || Validator::MAX_BYTES,
74
+ generation: generation
61
75
  )
62
76
  end
63
77
 
@@ -3,6 +3,8 @@
3
3
  require "net/http"
4
4
  require "nokogiri"
5
5
  require "uri"
6
+ require "pathname"
7
+ require "stringio"
6
8
 
7
9
  module Indexmap
8
10
  class Parser
@@ -19,8 +21,49 @@ module Indexmap
19
21
  return reset! && entries if reset
20
22
  return @entries if defined?(@entries)
21
23
 
24
+ @entries = each_entry.to_a
25
+ end
26
+
27
+ # Unlike entries, this does not cache or retain the URL inventory.
28
+ def each_entry
29
+ return enum_for(:each_entry) unless block_given?
30
+
22
31
  visited = Set.new
23
- @entries = parse_source(@source, visited: visited)
32
+ pending = [@source]
33
+ until pending.empty?
34
+ source = normalize_source(pending.pop)
35
+ next if source.nil? || visited.include?(source)
36
+
37
+ visited.add(source)
38
+ children = []
39
+ open_source(source) do |io|
40
+ reader = Nokogiri::XML::Reader.from_io(io, nil, nil, Nokogiri::XML::ParseOptions::NONET)
41
+ root = nil
42
+ reader.each do |node|
43
+ if node.node_type == Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE
44
+ raise ValidationError, "Sitemap DTDs are not allowed: #{source}"
45
+ end
46
+ next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
47
+
48
+ root = node.local_name if node.depth == 0
49
+ next unless node.depth == 1
50
+
51
+ if root == "sitemapindex" && node.local_name == "sitemap"
52
+ fields = read_fields(reader)
53
+ child = resolve_child_sitemap(source, fields.fetch("loc").to_s.strip)
54
+ children << child if child
55
+ elsif root == "urlset" && node.local_name == "url"
56
+ fields = read_fields(reader)
57
+ loc = fields.fetch("loc").to_s.strip
58
+ next if loc.empty?
59
+
60
+ lastmod = fields.fetch("lastmod").to_s.strip
61
+ yield Entry.new(loc: loc, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: source)
62
+ end
63
+ end
64
+ end
65
+ pending.concat(children.reverse)
66
+ end
24
67
  end
25
68
 
26
69
  def paths(reset: false)
@@ -60,33 +103,26 @@ module Indexmap
60
103
 
61
104
  attr_reader :index_filename, :storage
62
105
 
63
- def parse_source(source, visited:)
64
- normalized_source = normalize_source(source)
65
- return [] if normalized_source.nil? || visited.include?(normalized_source)
66
-
67
- visited.add(normalized_source)
68
- xml = read_source(normalized_source)
69
- return [] if xml.to_s.strip.empty?
70
-
71
- document = Nokogiri::XML(xml)
72
- document.remove_namespaces!
73
-
74
- if document.at_xpath("/sitemapindex")
75
- document.xpath("//sitemap/loc").flat_map do |node|
76
- child_source = resolve_child_sitemap(normalized_source, node.text.to_s.strip)
77
- next [] if child_source.nil?
78
-
79
- parse_source(child_source, visited: visited)
106
+ def read_fields(reader)
107
+ fields = {"loc" => nil, "lastmod" => nil}
108
+ return fields if reader.self_closing?
109
+
110
+ text = nil
111
+ while reader.read
112
+ type = reader.node_type
113
+ if type == Nokogiri::XML::Reader::TYPE_END_ELEMENT
114
+ break if reader.depth == 1
115
+
116
+ text = nil if reader.depth == 2
117
+ elsif type == Nokogiri::XML::Reader::TYPE_ELEMENT && reader.depth == 2
118
+ name = reader.local_name
119
+ text = fields.key?(name) ? (fields[name] = +"") : nil
120
+ elsif text && [Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA,
121
+ Nokogiri::XML::Reader::TYPE_WHITESPACE, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE].include?(type)
122
+ text << reader.value
80
123
  end
81
- else
82
- document.xpath("//url").map do |url_node|
83
- loc = url_node.at_xpath("loc")&.text.to_s.strip
84
- next if loc.empty?
85
-
86
- lastmod = url_node.at_xpath("lastmod")&.text.to_s.strip
87
- Entry.new(loc: loc, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: normalized_source)
88
- end.compact
89
124
  end
125
+ fields
90
126
  end
91
127
 
92
128
  def resolve_child_sitemap(parent_source, loc)
@@ -97,7 +133,7 @@ module Indexmap
97
133
  if remote_source?(loc)
98
134
  remote_child_source(parent_uri, loc)
99
135
  else
100
- URI.join(parent_uri.to_s, loc).to_s
136
+ remote_child_source(parent_uri, URI.join(parent_uri.to_s, loc).to_s)
101
137
  end
102
138
  elsif remote_source?(loc)
103
139
  uri = URI.parse(loc)
@@ -120,12 +156,12 @@ module Indexmap
120
156
 
121
157
  def remote_child_source(parent_uri, loc)
122
158
  child_uri = URI.parse(loc)
123
- return child_uri.to_s unless @rebase_remote_children
124
- return child_uri.to_s if child_uri.host == parent_uri.host && child_uri.port == parent_uri.port && child_uri.scheme == parent_uri.scheme
125
-
126
- child_uri.scheme = parent_uri.scheme
127
- child_uri.host = parent_uri.host
128
- child_uri.port = parent_uri.port
159
+ if @rebase_remote_children
160
+ child_uri.scheme = parent_uri.scheme
161
+ child_uri.host = parent_uri.host
162
+ child_uri.port = parent_uri.port
163
+ end
164
+ check_remote_origin!(child_uri)
129
165
  child_uri.to_s
130
166
  end
131
167
 
@@ -155,35 +191,59 @@ module Indexmap
155
191
  false
156
192
  end
157
193
 
158
- def read_source(source)
194
+ def open_source(source)
159
195
  if remote_source?(source)
160
- fetch_remote_source(source)
196
+ body = fetch_remote_source(source)
197
+ yield StringIO.new(body) if body && !body.strip.empty?
161
198
  elsif storage.exist?(source)
162
- storage.read(source)
199
+ if storage.respond_to?(:open) && storage.respond_to?(:metadata)
200
+ check_size!(storage.metadata(source).fetch(:bytes))
201
+ storage.open(source) { |io| yield io }
202
+ else
203
+ body = storage.read(source)
204
+ check_size!(body.bytesize)
205
+ yield StringIO.new(body) unless body.strip.empty?
206
+ end
207
+ end
208
+ end
209
+
210
+ def check_size!(bytes)
211
+ raise ValidationError, "Sitemap exceeds #{Validator::MAX_BYTES} bytes" if bytes > Validator::MAX_BYTES
212
+ end
213
+
214
+ def check_remote_origin!(uri)
215
+ origin = URI.parse(@source)
216
+ unless uri.is_a?(URI::HTTP) && !uri.userinfo &&
217
+ [uri.scheme, uri.host, uri.port] == [origin.scheme, origin.host, origin.port]
218
+ raise ValidationError, "Remote sitemap must use the source origin: #{uri}"
163
219
  end
164
220
  end
165
221
 
166
222
  def fetch_remote_source(source, redirects_remaining: 3)
167
223
  uri = URI.parse(source)
224
+ check_remote_origin!(uri)
168
225
  request = Net::HTTP::Get.new(uri)
169
226
  request["User-Agent"] = "Indexmap::Parser/1.0"
170
-
171
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 20) do |http|
172
- http.request(request)
173
- end
174
-
175
- case response
176
- when Net::HTTPSuccess
177
- response.body
178
- when Net::HTTPRedirection
179
- return if redirects_remaining <= 0
180
-
181
- location = response["location"].to_s
182
- return if location.empty?
183
-
184
- redirected = URI.join(source, location).to_s
185
- fetch_remote_source(redirected, redirects_remaining: redirects_remaining - 1)
227
+ redirected = nil
228
+ body = nil
229
+
230
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 20) do |http|
231
+ http.request(request) do |response|
232
+ check_size!(response.content_length) if response.content_length
233
+ body = +"" if response.is_a?(Net::HTTPSuccess)
234
+ if response.is_a?(Net::HTTPRedirection)
235
+ location = response["location"].to_s
236
+ redirected = URI.join(source, location).to_s unless location.empty? || redirects_remaining <= 0
237
+ end
238
+ bytes = 0
239
+ response.read_body do |chunk|
240
+ bytes += chunk.bytesize
241
+ check_size!(bytes)
242
+ body << chunk if body
243
+ end
244
+ end
186
245
  end
246
+ redirected ? fetch_remote_source(redirected, redirects_remaining: redirects_remaining - 1) : body
187
247
  rescue URI::InvalidURIError
188
248
  nil
189
249
  end
@@ -10,8 +10,9 @@ module Indexmap
10
10
  new(...).ping
11
11
  end
12
12
 
13
- def initialize(configuration: Indexmap.configuration)
13
+ def initialize(configuration: Indexmap.configuration, storage: nil)
14
14
  @configuration = configuration
15
+ @storage = storage
15
16
  end
16
17
 
17
18
  def ping
@@ -34,6 +35,10 @@ module Indexmap
34
35
 
35
36
  attr_reader :configuration
36
37
 
38
+ def storage
39
+ @storage ||= configuration.storage
40
+ end
41
+
37
42
  def host
38
43
  configuration.base_url
39
44
  end
@@ -46,10 +51,6 @@ module Indexmap
46
51
  hostname.sub(/\Awww\./, "")
47
52
  end
48
53
 
49
- def storage
50
- configuration.storage
51
- end
52
-
53
54
  def sitemap_files
54
55
  index_filename = configuration.index_filename.to_s
55
56
  return [] if index_filename.empty? || !storage.exist?(index_filename)