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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +287 -35
- data/UPGRADE.md +361 -0
- data/docs/architecture.md +55 -0
- data/docs/deployment.md +223 -0
- data/docs/domain-terms.md +27 -0
- data/docs/dynamic-sitemaps.md +302 -0
- data/docs/search-engine-ping.md +368 -0
- data/docs/storage.md +94 -0
- data/lib/generators/indexmap/install_generator.rb +1 -1
- data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
- data/lib/indexmap/artifact.rb +9 -0
- data/lib/indexmap/configuration.rb +12 -1
- data/lib/indexmap/creator.rb +45 -42
- data/lib/indexmap/index_now_configuration.rb +3 -1
- data/lib/indexmap/output.rb +19 -5
- data/lib/indexmap/parser.rb +112 -52
- data/lib/indexmap/pinger/base.rb +6 -5
- data/lib/indexmap/pinger/google.rb +12 -8
- data/lib/indexmap/pinger/index_now.rb +97 -52
- data/lib/indexmap/publication.rb +89 -0
- data/lib/indexmap/robots.rb +67 -0
- data/lib/indexmap/robots_controller.rb +13 -0
- data/lib/indexmap/sitemaps_controller.rb +57 -0
- data/lib/indexmap/storage/active_storage.rb +132 -1
- data/lib/indexmap/storage/filesystem.rb +182 -11
- data/lib/indexmap/storage/memory.rb +1 -1
- data/lib/indexmap/task_runner.rb +21 -28
- data/lib/indexmap/validator/duplicates.rb +83 -0
- data/lib/indexmap/validator.rb +171 -59
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap/writer.rb +89 -34
- data/lib/indexmap.rb +14 -1
- data/lib/tasks/indexmap_tasks.rake +21 -2
- data/test/indexmap/correctness_test.rb +153 -0
- data/test/indexmap/install_generator_test.rb +36 -4
- data/test/indexmap/parser_test.rb +77 -0
- data/test/indexmap/pinger/google_test.rb +22 -0
- data/test/indexmap/pinger/index_now_test.rb +168 -0
- data/test/indexmap/publication_test.rb +280 -0
- data/test/indexmap/robots_test.rb +73 -0
- data/test/indexmap/sitemaps_controller_test.rb +50 -0
- data/test/indexmap/storage_test.rb +27 -0
- data/test/indexmap/task_runner_test.rb +24 -0
- data/test/indexmap/validator_test.rb +14 -0
- data/test/indexmap/writer_test.rb +42 -0
- data/test/integration/queue_checks.rb +244 -0
- data/test/release_task_test.rb +153 -0
- metadata +22 -5
- 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)
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
98
|
-
sitemap_files.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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"
|
|
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
|
|
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}
|
|
163
|
-
{status: :failed, url_count: urls.count, status_code: response.status,
|
|
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
|
-
|
|
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
|