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
@@ -4,12 +4,13 @@ require "google/apis/searchconsole_v1"
4
4
  require "googleauth"
5
5
  require "json"
6
6
  require "stringio"
7
+ require "tmpdir"
7
8
 
8
9
  module Indexmap
9
10
  module Pinger
10
11
  class Google < Base
11
- def initialize(configuration: Indexmap.configuration, service: nil, credentials_builder: nil)
12
- super(configuration: configuration)
12
+ def initialize(configuration: Indexmap.configuration, storage: nil, service: nil, credentials_builder: nil)
13
+ super(configuration: configuration, storage: storage)
13
14
  @service = service
14
15
  @credentials_builder = credentials_builder
15
16
  end
@@ -80,7 +81,7 @@ module Indexmap
80
81
  end
81
82
 
82
83
  def authorizer
83
- json_key = JSON.parse(google_configuration.credentials).to_json
84
+ json_key = JSON.generate(JSON.parse(google_configuration.credentials))
84
85
  scope = "https://www.googleapis.com/auth/webmasters"
85
86
 
86
87
  return credentials_builder.call(credentials: json_key, scope: scope) if credentials_builder
@@ -92,12 +93,15 @@ module Indexmap
92
93
  end
93
94
 
94
95
  def sitemap_url_count(files)
95
- files.each_with_object(Set.new) do |sitemap_file, urls|
96
- Parser.new(source: sitemap_file, storage: storage).entries.each do |entry|
97
- loc = entry.loc.to_s.strip
98
- urls.add(loc) unless loc.empty?
96
+ Dir.mktmpdir("indexmap-google-count") do |directory|
97
+ urls = Validator::Duplicates.new(directory: directory)
98
+ files.each do |sitemap_file|
99
+ Parser.new(source: sitemap_file, storage: storage).each_entry do |entry|
100
+ urls.add(JSON.generate(entry.loc))
101
+ end
99
102
  end
100
- end.count
103
+ urls.unique_count
104
+ end
101
105
  end
102
106
 
103
107
  def summarize_results(results, url_count:)
@@ -10,8 +10,8 @@ module Indexmap
10
10
  class IndexNow < Base
11
11
  KEY_FORMAT = /\A[a-f0-9]{32}\z/
12
12
 
13
- def initialize(configuration: Indexmap.configuration, connection: nil)
14
- super(configuration: configuration)
13
+ def initialize(configuration: Indexmap.configuration, storage: nil, connection: nil)
14
+ super(configuration: configuration, storage: storage)
15
15
  @connection = connection
16
16
  end
17
17
 
@@ -22,13 +22,9 @@ module Indexmap
22
22
  return {status: :skipped, reason: :missing_key}
23
23
  end
24
24
 
25
+ batch_size = max_urls_per_request
25
26
  entries = entries_to_ping
26
- if entries.empty?
27
- logger.debug("IndexNow: no URLs matched the current filter.")
28
- return {status: :skipped, reason: :no_urls}
29
- end
30
-
31
- results = entries.each_slice(max_urls_per_request).map do |batch|
27
+ results = entries.each_slice(batch_size).map.with_index(1) do |batch, number|
32
28
  urls = batch.map(&:loc)
33
29
 
34
30
  if dry_run?
@@ -36,12 +32,55 @@ module Indexmap
36
32
  next({status: :dry_run, url_count: urls.count})
37
33
  end
38
34
 
39
- submit_batch(api_key: api_key, urls: urls)
35
+ attempts = index_now_configuration.max_attempts || 1
36
+ max_wait = index_now_configuration.max_wait || 30
37
+ unless attempts.is_a?(Integer) && (1..5).cover?(attempts) && max_wait.is_a?(Numeric) && (0..60).cover?(max_wait)
38
+ raise ConfigurationError, "IndexNow retries require 1..5 attempts and 0..60 seconds max_wait"
39
+ end
40
+ result = nil
41
+ attempts.times do |attempt|
42
+ yield({event: :attempt_started, batch: number, attempt: attempt + 1, urls: urls}) if block_given?
43
+ result = submit_batch(api_key: api_key, urls: urls)
44
+ yield(result.merge(event: :attempt_finished, batch: number, attempt: attempt + 1, urls: urls)) if block_given?
45
+ retryable = result[:reason] == :unknown_delivery || [429, 500, 502, 503, 504].include?(result[:status_code])
46
+ break unless retryable && attempt + 1 < attempts
47
+
48
+ delay = result[:retry_after] || [2**attempt + rand, max_wait].min
49
+ break if delay > max_wait
50
+
51
+ sleep(delay)
52
+ end
53
+ result
40
54
  end
41
55
 
56
+ return {status: :skipped, reason: :no_urls} if results.empty?
57
+
42
58
  summarize_results(results)
43
59
  end
44
60
 
61
+ def plan
62
+ return enum_for(:plan) unless block_given?
63
+
64
+ cutoff = since_cutoff
65
+ batch_size = max_urls_per_request
66
+ missing_key = read_api_key.nil?
67
+ selected_count = 0
68
+ each_selection(cutoff: cutoff) do |entry, reason, selected|
69
+ selected_count += 1 if selected
70
+ yield({
71
+ loc: entry.loc,
72
+ lastmod: entry.lastmod,
73
+ source_sitemap: entry.source_sitemap,
74
+ index_filename: configuration.index_filename,
75
+ cutoff: cutoff&.iso8601,
76
+ reason: reason,
77
+ selected: selected,
78
+ batch: selected ? ((selected_count - 1) / batch_size) + 1 : nil,
79
+ missing_key: missing_key
80
+ })
81
+ end
82
+ end
83
+
45
84
  def write_key_file(key: index_now_configuration.key, filename: nil)
46
85
  key = normalized_configured_key(key)
47
86
  return if key.empty?
@@ -72,34 +111,26 @@ module Indexmap
72
111
  configuration.index_now
73
112
  end
74
113
 
75
- def sitemap_files
76
- files = super
77
- return files if files.one?
78
-
79
- child_files = files.reject { |file| file == configuration.index_filename }
80
- child_files.empty? ? files : child_files
81
- end
82
-
83
114
  def entries_to_ping
84
- cutoff = since_cutoff
85
- unless cutoff
86
- logger.debug("IndexNow: no cutoff provided, submitting all sitemap URLs.")
87
- return current_entries.values
88
- end
89
-
90
- logger.debug { "IndexNow: submitting sitemap URLs with lastmod >= #{cutoff.iso8601}." }
91
-
92
- current_entries.values.select do |entry|
93
- lastmod_after_cutoff?(entry, cutoff) || entry.lastmod.to_s.strip.empty?
94
- end
115
+ enum_for(:each_selection, cutoff: since_cutoff).lazy.filter_map do |entry, _reason, selected|
116
+ entry if selected
117
+ end.eager
95
118
  end
96
119
 
97
- def current_entries
98
- sitemap_files.each_with_object({}) do |sitemap_file, entries|
99
- Parser.new(source: sitemap_file, storage: storage).entries.each do |entry|
100
- next if entry.loc.to_s.strip.empty?
101
-
102
- entries[entry.loc] = entry
120
+ def each_selection(cutoff:)
121
+ sitemap_files.each do |filename|
122
+ validator = Validator.new(configuration: configuration, filename: filename, storage: storage)
123
+ validator.each_entry do |entry|
124
+ reason = if cutoff.nil?
125
+ :all
126
+ elsif entry.lastmod.to_s.strip.empty?
127
+ :missing_lastmod
128
+ elsif lastmod_after_cutoff?(entry, cutoff)
129
+ :recent
130
+ else
131
+ :before_cutoff
132
+ end
133
+ yield entry, reason, reason != :before_cutoff
103
134
  end
104
135
  end
105
136
  end
@@ -126,23 +157,20 @@ module Indexmap
126
157
  end
127
158
 
128
159
  def lastmod_after_cutoff?(entry, cutoff)
129
- lastmod = entry_lastmod(entry)
130
- return false unless lastmod
131
-
132
- lastmod >= cutoff
133
- end
134
-
135
- def entry_lastmod(entry)
136
- return if entry.lastmod.to_s.strip.empty?
137
-
138
- Time.iso8601(entry.lastmod.to_s).utc
139
- rescue ArgumentError
140
- logger.debug { "IndexNow: skipping invalid sitemap lastmod #{entry.lastmod.inspect} for #{entry.loc}" }
141
- nil
160
+ if entry.lastmod.to_s.match?(/\A\d{4}-\d{2}-\d{2}\z/)
161
+ Date.iso8601(entry.lastmod) >= cutoff.to_date
162
+ else
163
+ Time.iso8601(entry.lastmod).utc >= cutoff
164
+ end
142
165
  end
143
166
 
144
167
  def max_urls_per_request
145
- ENV.fetch("INDEXNOW_MAX_URLS_PER_REQUEST", index_now_configuration.max_urls_per_request).to_i
168
+ value = ENV.fetch("INDEXNOW_MAX_URLS_PER_REQUEST") { index_now_configuration.max_urls_per_request }
169
+ size = Integer(value.to_s, 10, exception: false)
170
+ unless size && (1..10_000).cover?(size)
171
+ raise ConfigurationError, "IndexNow batch size must be an integer from 1 through 10,000"
172
+ end
173
+ size
146
174
  end
147
175
 
148
176
  def submit_batch(api_key:, urls:)
@@ -152,20 +180,37 @@ module Indexmap
152
180
 
153
181
  response = index_now_connection.post("/indexnow") do |request|
154
182
  request.headers["Content-Type"] = "application/json"
155
- request.body = payload.to_json
183
+ request.body = JSON.generate(payload)
156
184
  end
157
185
 
158
186
  if response.success?
159
187
  logger.debug { "Successfully pinged IndexNow with #{urls.count} URLs." }
160
- {status: :submitted, url_count: urls.count}
188
+ {status: :submitted, url_count: urls.count, status_code: response.status,
189
+ receipt: (response.status == 202) ? :validation_pending : :received}
161
190
  else
162
- logger.debug { "Failed to ping IndexNow. Status: #{response.status}, Body: #{response.body}" }
163
- {status: :failed, url_count: urls.count, status_code: response.status, body: response.body}
191
+ logger.debug { "Failed to ping IndexNow. Status: #{response.status}" }
192
+ {status: :failed, url_count: urls.count, status_code: response.status,
193
+ body: response.body.to_s.encode("UTF-8", invalid: :replace, undef: :replace).gsub(api_key, "[FILTERED]").slice(0, 1024),
194
+ retry_after: retry_after(response.headers["retry-after"])}
164
195
  end
196
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed
197
+ {status: :failed, url_count: urls.count, reason: :unknown_delivery}
198
+ end
199
+
200
+ def retry_after(value)
201
+ return if value.to_s.empty?
202
+
203
+ seconds = Integer(value.to_s, exception: false)
204
+ seconds ||= Time.httpdate(value).utc - Time.now.utc
205
+ [seconds, 0].max
206
+ rescue ArgumentError
207
+ nil
165
208
  end
166
209
 
167
210
  def index_now_connection
168
211
  @index_now_connection ||= connection || Faraday.new(url: index_now_configuration.endpoint) do |faraday|
212
+ faraday.options.open_timeout = 5
213
+ faraday.options.timeout = 20
169
214
  faraday.request :json
170
215
  end
171
216
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "uri"
5
+
6
+ module Indexmap
7
+ class Publication
8
+ def self.immutable?(filename)
9
+ File.basename(filename).match?(/\Asitemap-[a-f0-9]{16}-[a-f0-9]{32}-[1-9]\d*-[1-9]\d*\.xml\z/)
10
+ end
11
+
12
+ def initialize(root:)
13
+ @root = root
14
+ @prefix = File.join(File.dirname(root), "sitemap-#{Digest::SHA256.hexdigest(root)[0, 16]}-").delete_prefix("./")
15
+ end
16
+
17
+ attr_reader :root, :prefix
18
+
19
+ def filename(generation:, section:, part:)
20
+ "#{prefix}#{generation}-#{section}-#{part}.xml"
21
+ end
22
+
23
+ def generation(filename)
24
+ filename[/\A#{Regexp.escape(prefix)}([a-f0-9]{32})-[1-9]\d*-[1-9]\d*\.xml\z/, 1]
25
+ end
26
+
27
+ def child?(filename)
28
+ !generation(filename).nil?
29
+ end
30
+
31
+ def archive(generation)
32
+ "#{prefix}#{generation}-index.xml"
33
+ end
34
+
35
+ def validate_files!(files)
36
+ children = files.reject { |filename| filename == root }
37
+ unless files.count(root) == 1 && files.uniq.size == files.size &&
38
+ children.all? { |filename| child?(filename) } &&
39
+ children.map { |filename| generation(filename) }.uniq.size <= 1
40
+ raise ConfigurationError, "Publication must contain its root and one immutable generation"
41
+ end
42
+ end
43
+
44
+ def current_generation(storage)
45
+ return unless storage.exist?(root)
46
+
47
+ metadata = storage.metadata(root)
48
+ return metadata[:generation] if metadata.key?(:generation)
49
+
50
+ result = nil
51
+ storage.open(root) do |io|
52
+ Nokogiri::XML::Reader.from_io(io, nil, nil, Nokogiri::XML::ParseOptions::NONET).each do |node|
53
+ if node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT && node.local_name == "loc"
54
+ element = Nokogiri::XML(node.outer_xml) { |options| options.strict.nonet }
55
+ path = URI.parse(element.root.text).path
56
+ candidate = File.join(File.dirname(root), File.basename(path)).delete_prefix("./")
57
+ result = generation(candidate)
58
+ break
59
+ end
60
+ end
61
+ end
62
+ result
63
+ end
64
+
65
+ def prune(storage, before:)
66
+ return [] unless storage.exist?(root)
67
+
68
+ current = current_generation(storage)
69
+ files = storage.list(prefix: prefix, suffix: ".xml")
70
+ deleted = []
71
+ files.each do |filename|
72
+ match = filename.match(/\A#{Regexp.escape(prefix)}([a-f0-9]{32})-index\.xml\z/)
73
+ next unless match && match[1] != current && storage.metadata(filename).fetch(:last_modified) < before
74
+
75
+ block_given? ? yield(filename) : storage.delete(filename)
76
+ deleted << filename
77
+ end
78
+ files.each do |filename|
79
+ version = generation(filename)
80
+ next unless version && version != current && !storage.exist?(archive(version))
81
+ next unless storage.metadata(filename).fetch(:last_modified) < before
82
+
83
+ block_given? ? yield(filename) : storage.delete(filename)
84
+ deleted << filename
85
+ end
86
+ deleted
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Indexmap
6
+ class Robots
7
+ attr_writer :indexable, :groups, :sitemaps
8
+
9
+ def initialize(configuration:)
10
+ @configuration = configuration
11
+ @indexable = false
12
+ @groups = [{user_agent: "*", allow: ["/"]}]
13
+ end
14
+
15
+ def body
16
+ return "User-agent: *\nDisallow: /\n" unless resolve(@indexable) == true
17
+
18
+ groups = Array(resolve(@groups))
19
+ raise ConfigurationError, "Robots groups must not be empty" if groups.empty?
20
+
21
+ lines = groups.flat_map do |group|
22
+ unknown = group.keys - %i[user_agent allow disallow]
23
+ raise ConfigurationError, "Unknown robots group keys: #{unknown.join(", ")}" unless unknown.empty?
24
+
25
+ agents = Array(group.fetch(:user_agent))
26
+ raise ConfigurationError, "Robots user agents must not be empty" if agents.empty?
27
+
28
+ agents.map { |agent| "User-agent: #{value(agent)}" } +
29
+ Array(group[:allow]).map { |path| "Allow: #{path_value(path)}" } +
30
+ Array(group[:disallow]).map { |path| "Disallow: #{path_value(path)}" } + [""]
31
+ end
32
+ sitemaps = resolve(@sitemaps) || [File.join(@configuration.base_url.to_s, @configuration.index_filename.to_s)]
33
+ Array(sitemaps).each do |location|
34
+ location = value(location)
35
+ uri = URI.parse(location)
36
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
37
+ raise ConfigurationError, "Robots sitemap must be an absolute HTTP(S) URL"
38
+ end
39
+ lines << "Sitemap: #{location}"
40
+ end
41
+ lines.join("\n") + "\n"
42
+ rescue URI::InvalidURIError
43
+ raise ConfigurationError, "Robots sitemap must be an absolute HTTP(S) URL"
44
+ end
45
+
46
+ private
47
+
48
+ def resolve(value)
49
+ value.respond_to?(:call) ? value.call : value
50
+ end
51
+
52
+ def value(input)
53
+ text = input.to_s
54
+ if text.empty? || text.match?(/[\x00-\x1f\x7f#]/)
55
+ raise ConfigurationError, "Robots values must be nonempty and contain no controls or comments"
56
+ end
57
+ text
58
+ end
59
+
60
+ def path_value(input)
61
+ path = value(input)
62
+ raise ConfigurationError, "Robots paths must start with /" unless path.start_with?("/")
63
+
64
+ path
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "indexmap"
4
+ require "action_controller"
5
+
6
+ class Indexmap::RobotsController < ActionController::Base
7
+ def show
8
+ body = Indexmap.configuration.robots.body
9
+ expires_in 1.hour, public: true
10
+ fresh_when etag: body, public: true, template: false
11
+ render plain: body, content_type: "text/plain" unless performed?
12
+ end
13
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "indexmap"
4
+ require "rails"
5
+ require "action_controller"
6
+
7
+ class Indexmap::SitemapsController < ActionController::Base
8
+ def show
9
+ configuration = Indexmap.configuration
10
+ storage = configuration.storage
11
+ unless storage.respond_to?(:metadata) && storage.respond_to?(:open)
12
+ raise Indexmap::ConfigurationError, "Sitemap delivery requires storage metadata and open capabilities"
13
+ end
14
+ path = request.path_parameters
15
+ filename = [path[:prefix], path.fetch(:filename)].compact.join("/")
16
+ allowed = configuration.output_names.any? do |name|
17
+ output = configuration.output_for(name)
18
+ root = output.index_filename
19
+ next true if filename == root
20
+ next false unless output.atomic
21
+
22
+ publication = Indexmap::Publication.new(root: root)
23
+ version = publication.generation(filename)
24
+ next false unless version
25
+ next true if storage.exist?(publication.archive(version))
26
+ next false unless storage.exist?(root)
27
+
28
+ metadata = storage.metadata(root)
29
+ active = Rails.cache.fetch(["indexmap-generation", storage.public_url(root), metadata.fetch(:etag)], expires_in: 1.hour) do
30
+ publication.current_generation(storage)
31
+ end
32
+ version == active
33
+ end
34
+ return head :not_found unless allowed && storage.exist?(filename)
35
+
36
+ metadata = storage.metadata(filename)
37
+ if Indexmap::Publication.immutable?(filename)
38
+ expires_in 1.year, public: true, immutable: true
39
+ else
40
+ expires_in 10.minutes, public: true, stale_while_revalidate: 1.hour
41
+ end
42
+ fresh_when etag: metadata.fetch(:etag), last_modified: metadata.fetch(:last_modified), public: true, template: false
43
+ return if performed?
44
+
45
+ response.content_type = "application/xml"
46
+ response_body = Enumerator.new do |stream|
47
+ storage.open(filename) do |io|
48
+ while (chunk = io.read(64 * 1024))
49
+ stream << chunk
50
+ end
51
+ end
52
+ end
53
+ self.response_body = response_body
54
+ rescue Errno::ENOENT
55
+ head :not_found
56
+ end
57
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "securerandom"
4
5
  require "uri"
5
6
 
6
7
  module Indexmap
@@ -17,6 +18,9 @@ module Indexmap
17
18
  end
18
19
 
19
20
  def write(filename, body, content_type: nil)
21
+ if Publication.immutable?(filename) && exist?(filename)
22
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
23
+ end
20
24
  attachment_content_type = content_type || default_content_type
21
25
  record = find_or_initialize(filename)
22
26
  record.save! unless record.persisted?
@@ -26,7 +30,7 @@ module Indexmap
26
30
  content_type: attachment_content_type
27
31
  )
28
32
 
29
- File.new(filename: filename, body: body.to_s, content_type: attachment_content_type)
33
+ Artifact.new(filename: filename, body: body.to_s, content_type: attachment_content_type)
30
34
  end
31
35
 
32
36
  def read(filename)
@@ -42,6 +46,8 @@ module Indexmap
42
46
 
43
47
  def list(prefix: nil, suffix: nil)
44
48
  relation = relation_for(prefix: prefix, suffix: suffix)
49
+ scope = "with_attached_#{attachment}"
50
+ relation = relation.public_send(scope) if relation.respond_to?(scope)
45
51
 
46
52
  relation.filter_map do |record|
47
53
  filename = record.public_send(filename_column).to_s
@@ -62,6 +68,102 @@ module Indexmap
62
68
  URI.join("#{public_url_base}/", filename).to_s
63
69
  end
64
70
 
71
+ def write_io(filename, io, content_type: nil)
72
+ if Publication.immutable?(filename) && exist?(filename)
73
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}"
74
+ end
75
+ record = find_or_initialize(filename)
76
+ record.save! unless record.persisted?
77
+ record.public_send(attachment).attach(io: io, filename: filename,
78
+ content_type: content_type || default_content_type)
79
+ end
80
+
81
+ def open(filename, &block)
82
+ blob_for(filename).open(&block)
83
+ end
84
+
85
+ def metadata(filename)
86
+ record = find_record(filename)
87
+ raise Errno::ENOENT, filename unless attached?(record)
88
+
89
+ blob = record.public_send(attachment).blob
90
+ {etag: blob.checksum, bytes: blob.byte_size, last_modified: record.updated_at,
91
+ generation: blob.metadata["indexmap_generation"]}
92
+ end
93
+
94
+ def revision(root)
95
+ exist?(root) ? blob_for(root).id.to_s : nil
96
+ end
97
+
98
+ def publish(source, files:, root:, expected:)
99
+ uploads = []
100
+ created_records = []
101
+ root_blob = nil
102
+ build = SecureRandom.hex(16)
103
+ unique = model.connection.indexes(model.table_name).any? do |index|
104
+ index.unique && index.columns == [filename_column.to_s] && index.where.nil?
105
+ end
106
+ raise ConfigurationError, "Atomic Active Storage requires a unique filename index" unless unique
107
+
108
+ check_revision!(root, expected)
109
+ publication = Publication.new(root: root)
110
+ publication.validate_files!(files)
111
+ children = files.reject { |filename| filename == root }
112
+ children.each do |filename|
113
+ raise ConfigurationError, "Immutable sitemap already exists: #{filename}" if exist?(filename)
114
+ end
115
+ record = model.create_or_find_by!(filename_column => root)
116
+ [*children, root].each do |filename|
117
+ source.open(filename) do |io|
118
+ check_revision!(root, expected)
119
+ key = SecureRandom.hex(28)
120
+ uploads << key
121
+ metadata = {indexmap_build: build}
122
+ metadata[:indexmap_generation] = children.empty? ? nil : publication.generation(children.first) if filename == root
123
+ blob = ::ActiveStorage::Blob.create_and_upload!(key: key, io: io, filename: ::File.basename(filename),
124
+ content_type: default_content_type, identify: false, metadata: metadata)
125
+ if filename == root
126
+ root_blob = blob
127
+ else
128
+ child = model.create!(filename_column => filename)
129
+ created_records << child.id
130
+ child.public_send(attachment).attach(blob)
131
+ end
132
+ end
133
+ end
134
+ record.with_lock do
135
+ current = record.public_send(attachment)
136
+ actual = current.attached? ? current.blob.id.to_s : nil
137
+ raise SupersededPublication, "Sitemap changed while building: #{root}" unless actual == expected
138
+ present = model.where(filename_column => children).joins(:"#{attachment}_attachment").distinct.count(filename_column)
139
+ unless present == children.size
140
+ raise SupersededPublication, "Sitemap children removed while building: #{root}"
141
+ end
142
+
143
+ if current.attached? && (version = publication.current_generation(self))
144
+ archived = model.create_or_find_by!(filename_column => publication.archive(version))
145
+ archived.public_send(attachment).attach(current.blob)
146
+ end
147
+ current.attach(root_blob)
148
+ end
149
+ ensure
150
+ # Also recognize a committed root when a host after_commit callback raises.
151
+ unless root_blob&.attachments&.exists?
152
+ cleanup_uploads(created_records || [], uploads || [], build: build)
153
+ end
154
+ end
155
+
156
+ def prune(root:, before:)
157
+ record = find_record(root)
158
+ return [] unless record
159
+
160
+ record.with_lock do
161
+ Publication.new(root: root).prune(self, before: before) do |filename|
162
+ find_record(filename).destroy!
163
+ end
164
+ end
165
+ end
166
+
65
167
  def inspect
66
168
  "#<#{self.class.name} model=#{model}>"
67
169
  end
@@ -70,6 +172,35 @@ module Indexmap
70
172
 
71
173
  attr_reader :model, :filename_column, :attachment, :default_content_type
72
174
 
175
+ def check_revision!(root, expected)
176
+ raise SupersededPublication, "Sitemap changed while building: #{root}" unless revision(root) == expected
177
+ end
178
+
179
+ def cleanup_uploads(records, keys, build:)
180
+ failures = []
181
+ records.each do |id|
182
+ model.find_by(model.primary_key => id)&.destroy!
183
+ rescue => error
184
+ failures << {artifact: "#{model.name}:#{id}", error: error}
185
+ end
186
+ keys.each do |key|
187
+ blob = ::ActiveStorage::Blob.find_by(key: key)
188
+ if blob && blob.metadata["indexmap_build"] == build && !blob.attachments.exists?
189
+ blob.purge
190
+ end
191
+ rescue => error
192
+ failures << {artifact: key, error: error}
193
+ end
194
+ raise PublicationCleanupError.new(failures) unless failures.empty?
195
+ end
196
+
197
+ def blob_for(filename)
198
+ record = find_record(filename)
199
+ raise Errno::ENOENT, filename unless attached?(record)
200
+
201
+ record.public_send(attachment).blob
202
+ end
203
+
73
204
  def find_or_initialize(filename)
74
205
  model.find_or_initialize_by(filename_column => filename)
75
206
  end