indexmap 0.4.1 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 513c19d20a8373690c66fa99a0251db934298589a19139c2b77654340ce7006a
4
- data.tar.gz: '087bd725e6ecaf81ff07510f78f7c5fa1d613730cd7b735c9f0ed60ecb3f4676'
3
+ metadata.gz: 9b0f3c8b9175d0c18c39e52b61b3934129dfa8b8b637d06ad3d0d9adc30eb995
4
+ data.tar.gz: 3baf06c566ce17d63e189aabfdd7f3e7b4047d5908fc6a58f3e017da5ecef1de
5
5
  SHA512:
6
- metadata.gz: 2f4065163991559ca5a1df38f1f01aafbdc539936f40aa81d78a3c5aa4abe34bdbc7cdb0df25d970a8a243e1b552c1946ec97c640f8d64350a46ef0d8de36006
7
- data.tar.gz: d0ebff2036cefb30689a22ebddf313c89ac45a6dda11277a4a5322b6c39c6257e6d05e3615e0167ca876f985b14dc5ac21eba096f947876ab72be32bc6a73c33
6
+ metadata.gz: 700e0cd5485cd433ceaa520a2d634eb4e5ccf93d4d8993224fae61acdf7dacd73c91029e24ff750cc58ce8704a5b00bb4ed66eff113d0693c46df75fc5aea4df
7
+ data.tar.gz: aa51396dad778e50b18a53606d316d5f9008ed358cecb7b75c0f7dc72f24d47a3959750102fdddafe50fbb31688256e16b07ec9c9c0ae53cb9431148593bb250
data/CHANGELOG.md CHANGED
@@ -5,12 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.1] - 2026-04-22
8
+ ## [0.4.2] - 2026-04-23
9
9
 
10
10
 
11
- ### Added
11
+ ### Fixed
12
12
 
13
- - add output to ping tasks (#6)
13
+ - harden sitemap pinging and indexnow key handling (#7)
14
14
 
15
15
 
16
16
 
@@ -38,7 +38,7 @@ module Indexmap
38
38
  sitemap_url = URI.join(host, File.basename(sitemap_file)).to_s
39
39
 
40
40
  unless authorized?
41
- logger.error("Google Search Console does not have access to the site: #{root_domain}")
41
+ logger.debug("Google Search Console does not have access to the site: #{root_domain}")
42
42
  return {
43
43
  status: :failed,
44
44
  reason: :unauthorized,
@@ -62,7 +62,7 @@ module Indexmap
62
62
  end
63
63
 
64
64
  def authorized?
65
- webmasters_service.list_sites.site_entry.any? { |site| site.site_url.include?(root_domain) }
65
+ @authorized ||= accessible_site_urls.include?(property_identifier)
66
66
  end
67
67
 
68
68
  def property_identifier
@@ -104,6 +104,10 @@ module Indexmap
104
104
  failures: failures
105
105
  }
106
106
  end
107
+
108
+ def accessible_site_urls
109
+ @accessible_site_urls ||= Array(webmasters_service.list_sites.site_entry).map(&:site_url)
110
+ end
107
111
  end
108
112
  end
109
113
  end
@@ -8,6 +8,8 @@ require "time"
8
8
  module Indexmap
9
9
  module Pinger
10
10
  class IndexNow < Base
11
+ KEY_FORMAT = /\A[a-f0-9]{32}\z/
12
+
11
13
  def initialize(configuration: Indexmap.configuration, connection: nil)
12
14
  super(configuration: configuration)
13
15
  @connection = connection
@@ -41,20 +43,21 @@ module Indexmap
41
43
  end
42
44
 
43
45
  def write_key_file(key: index_now_configuration.key, path: nil)
44
- key = key.to_s.strip
46
+ key = normalized_configured_key(key)
45
47
  return if key.empty?
46
48
 
47
49
  path ||= index_now_configuration.key_path(public_path: configuration.public_path, key: key)
48
50
  FileUtils.mkdir_p(path.dirname)
49
- File.write(path, "#{key}\n")
51
+ File.write(path, key)
50
52
  path
51
53
  end
52
54
 
53
55
  def ensure_key_file
54
- configured_key = index_now_configuration.key.to_s.strip
56
+ configured_key = normalized_configured_key(index_now_configuration.key)
55
57
  return write_key_file(key: configured_key) unless configured_key.empty?
56
58
 
57
- return existing_key_file if existing_key_file
59
+ existing_path = existing_key_file
60
+ return existing_path if existing_path
58
61
 
59
62
  key = generated_key
60
63
  write_key_file(key: key, path: configuration.public_path.join("#{key}.txt"))
@@ -143,6 +146,9 @@ module Indexmap
143
146
 
144
147
  def submit_batch(api_key:, urls:)
145
148
  payload = {host: hostname, key: api_key, urlList: urls}
149
+ location = key_location(api_key: api_key)
150
+ payload[:keyLocation] = location if location
151
+
146
152
  response = index_now_connection.post("/indexnow") do |request|
147
153
  request.headers["Content-Type"] = "application/json"
148
154
  request.body = payload.to_json
@@ -168,30 +174,50 @@ module Indexmap
168
174
  end
169
175
 
170
176
  def read_api_key
171
- configured_key = index_now_configuration.key.to_s.strip
177
+ configured_key = normalized_configured_key(index_now_configuration.key)
172
178
  return configured_key unless configured_key.empty?
173
179
 
174
- existing_key_file&.read&.strip
180
+ existing_key_file&.read
175
181
  end
176
182
 
177
183
  def existing_key_file
178
184
  configured_path = index_now_configuration.key_path(public_path: configuration.public_path)
179
185
  return configured_path if valid_key_file?(configured_path)
180
186
 
181
- configuration.public_path.glob("*.txt").find { |file| valid_key_file?(file) }
187
+ configuration.public_path.glob("*.txt").sort.find { |file| valid_key_file?(file) }
188
+ end
189
+
190
+ def key_location(api_key:)
191
+ path = index_now_configuration.key_path(public_path: configuration.public_path, key: api_key) || existing_key_file
192
+ return unless path
193
+
194
+ public_path = configuration.public_path.expand_path
195
+ key_path = path.expand_path
196
+ relative_path = key_path.relative_path_from(public_path)
197
+
198
+ URI.join("#{host}/", relative_path.to_s).to_s
199
+ rescue ArgumentError
200
+ nil
182
201
  end
183
202
 
184
203
  def valid_key_file?(path)
185
204
  return false unless path&.file?
186
205
 
187
206
  filename = path.basename(".txt").to_s
188
- return false unless filename.match?(/\A[a-zA-Z0-9-]{8,128}\z/)
207
+ return false unless filename.match?(KEY_FORMAT)
189
208
 
190
- path.read.strip == filename
209
+ path.read == filename
191
210
  end
192
211
 
193
212
  def generated_key
194
- SecureRandom.uuid
213
+ SecureRandom.hex(16)
214
+ end
215
+
216
+ def normalized_configured_key(value)
217
+ key = value.to_s.strip
218
+ return key if key.empty? || key.match?(KEY_FORMAT)
219
+
220
+ raise ConfigurationError, "IndexNow key must be a 32-character lowercase hexadecimal string"
195
221
  end
196
222
 
197
223
  def summarize_results(results)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Indexmap
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -82,7 +82,8 @@ module Indexmap
82
82
  sitemap_sections.each do |section|
83
83
  lines << " <sitemap>"
84
84
  lines << " <loc>#{escape(index_loc(section.filename))}</loc>"
85
- lines << " <lastmod>#{format_lastmod(section_lastmod(section))}</lastmod>"
85
+ lastmod = section_lastmod(section)
86
+ lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
86
87
  lines << " </sitemap>"
87
88
  end
88
89
 
@@ -102,7 +103,7 @@ module Indexmap
102
103
 
103
104
  def section_lastmod(section)
104
105
  timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
105
- timestamps.max || Time.now.utc
106
+ timestamps.max
106
107
  end
107
108
 
108
109
  def format_lastmod(value)
@@ -8,13 +8,15 @@ class IndexmapPingerGoogleTest < Minitest::Test
8
8
 
9
9
  class FakeWebmastersService
10
10
  attr_accessor :authorization
11
- attr_reader :submitted
11
+ attr_reader :submitted, :list_sites_calls
12
12
 
13
13
  def initialize(site_urls:)
14
14
  @site_urls = site_urls
15
+ @list_sites_calls = 0
15
16
  end
16
17
 
17
18
  def list_sites
19
+ @list_sites_calls += 1
18
20
  SiteList.new(@site_urls.map { |site_url| SiteEntry.new(site_url) })
19
21
  end
20
22
 
@@ -51,6 +53,7 @@ class IndexmapPingerGoogleTest < Minitest::Test
51
53
  assert_equal ["sc-domain:example.com", "https://www.example.com/sitemap.xml"], service.submitted
52
54
  assert_equal :submitted, result[:status]
53
55
  assert_equal 1, result[:sitemap_count]
56
+ assert_equal 1, service.list_sites_calls
54
57
  end
55
58
  end
56
59
 
@@ -114,4 +117,27 @@ class IndexmapPingerGoogleTest < Minitest::Test
114
117
  assert_nil service.submitted
115
118
  end
116
119
  end
120
+
121
+ def test_google_authorization_requires_exact_property_match
122
+ Dir.mktmpdir do |dir|
123
+ public_path = Pathname(dir)
124
+ public_path.join("sitemap.xml").write("<sitemapindex/>")
125
+
126
+ configuration = Indexmap::Configuration.new
127
+ configuration.base_url = "https://www.example.com"
128
+ configuration.public_path = public_path
129
+ configuration.google.credentials = "{\"type\":\"service_account\"}"
130
+
131
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:myexample.com"])
132
+ result = Indexmap::Pinger::Google.new(
133
+ configuration: configuration,
134
+ service: service,
135
+ credentials_builder: ->(**) { :fake_authorizer }
136
+ ).ping
137
+
138
+ assert_equal :failed, result[:status]
139
+ assert_equal :unauthorized, result[:failures].first[:reason]
140
+ assert_nil service.submitted
141
+ end
142
+ end
117
143
  end
@@ -3,17 +3,19 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapPingerIndexNowTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_writes_key_file_from_configuration
7
9
  Dir.mktmpdir do |dir|
8
10
  configuration = Indexmap::Configuration.new
9
11
  configuration.base_url = "https://www.example.com"
10
12
  configuration.public_path = Pathname(dir)
11
- configuration.index_now.key = "test-key"
13
+ configuration.index_now.key = VALID_KEY
12
14
 
13
15
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).write_key_file
14
16
 
15
- assert_equal Pathname(dir).join("test-key.txt"), path
16
- assert_equal "test-key\n", path.read
17
+ assert_equal Pathname(dir).join("#{VALID_KEY}.txt"), path
18
+ assert_equal VALID_KEY, path.read
17
19
  end
18
20
  end
19
21
 
@@ -25,16 +27,16 @@ class IndexmapPingerIndexNowTest < Minitest::Test
25
27
 
26
28
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
27
29
 
28
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, path.basename.to_s)
29
- assert_equal "#{path.basename(".txt")}\n", path.read
30
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
31
+ assert_equal path.basename(".txt").to_s, path.read
30
32
  end
31
33
  end
32
34
 
33
35
  def test_pings_using_existing_key_file_when_key_is_not_configured
34
36
  Dir.mktmpdir do |dir|
35
37
  public_path = Pathname(dir)
36
- key_path = public_path.join("test-key-123.txt")
37
- key_path.write("test-key-123\n")
38
+ key_path = public_path.join("#{VALID_KEY}.txt")
39
+ key_path.write(VALID_KEY)
38
40
  write_sitemap_files(
39
41
  public_path,
40
42
  marketing_lastmod: "2026-04-18T00:00:00Z",
@@ -52,7 +54,8 @@ class IndexmapPingerIndexNowTest < Minitest::Test
52
54
 
53
55
  assert_requested(:post, indexnow_url, times: 1) do |request|
54
56
  payload = JSON.parse(request.body)
55
- assert_equal "test-key-123", payload.fetch("key")
57
+ assert_equal VALID_KEY, payload.fetch("key")
58
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
56
59
  end
57
60
  assert_equal :submitted, result[:status]
58
61
  assert_equal 2, result[:url_count]
@@ -60,6 +63,24 @@ class IndexmapPingerIndexNowTest < Minitest::Test
60
63
  end
61
64
  end
62
65
 
66
+ def test_ignores_existing_key_file_with_trailing_newline
67
+ Dir.mktmpdir do |dir|
68
+ public_path = Pathname(dir)
69
+ invalid_key_path = public_path.join("1234567890abcdef1234567890abcdef.txt")
70
+ invalid_key_path.write("#{VALID_KEY}\n")
71
+
72
+ configuration = Indexmap::Configuration.new
73
+ configuration.base_url = "https://www.example.com"
74
+ configuration.public_path = public_path
75
+
76
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
77
+
78
+ refute_equal invalid_key_path, path
79
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
80
+ assert_equal path.basename(".txt").to_s, path.read
81
+ end
82
+ end
83
+
63
84
  def test_pings_all_sitemap_urls_when_no_cutoff_is_provided
64
85
  Dir.mktmpdir do |dir|
65
86
  public_path = Pathname(dir)
@@ -72,7 +93,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
72
93
  configuration = Indexmap::Configuration.new
73
94
  configuration.base_url = "https://www.example.com"
74
95
  configuration.public_path = public_path
75
- configuration.index_now.key = "test-key"
96
+ configuration.index_now.key = VALID_KEY
76
97
 
77
98
  indexnow_url = "https://api.indexnow.org/indexnow"
78
99
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
@@ -85,6 +106,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
85
106
  "https://www.example.com/pages/features",
86
107
  "https://www.example.com/insights/us/restaurants/overview"
87
108
  ].sort, payload.fetch("urlList").sort
109
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
88
110
  end
89
111
  assert_equal :submitted, result[:status]
90
112
  assert_equal 2, result[:url_count]
@@ -104,7 +126,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
104
126
  configuration = Indexmap::Configuration.new
105
127
  configuration.base_url = "https://www.example.com"
106
128
  configuration.public_path = public_path
107
- configuration.index_now.key = "test-key"
129
+ configuration.index_now.key = VALID_KEY
108
130
 
109
131
  indexnow_url = "https://api.indexnow.org/indexnow"
110
132
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
@@ -155,7 +177,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
155
177
  configuration = Indexmap::Configuration.new
156
178
  configuration.base_url = "https://www.example.com"
157
179
  configuration.public_path = public_path
158
- configuration.index_now.key = "test-key"
180
+ configuration.index_now.key = VALID_KEY
159
181
 
160
182
  with_env("INDEXNOW_DRY_RUN" => "1") do
161
183
  result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
@@ -179,7 +201,7 @@ class IndexmapPingerIndexNowTest < Minitest::Test
179
201
  configuration = Indexmap::Configuration.new
180
202
  configuration.base_url = "https://www.example.com"
181
203
  configuration.public_path = public_path
182
- configuration.index_now.key = "test-key"
204
+ configuration.index_now.key = VALID_KEY
183
205
 
184
206
  indexnow_url = "https://api.indexnow.org/indexnow"
185
207
  stub_request(:post, indexnow_url).to_return(status: 500, body: "boom", headers: {})
@@ -192,6 +214,37 @@ class IndexmapPingerIndexNowTest < Minitest::Test
192
214
  end
193
215
  end
194
216
 
217
+ def test_rejects_invalid_configured_key
218
+ Dir.mktmpdir do |dir|
219
+ configuration = Indexmap::Configuration.new
220
+ configuration.base_url = "https://www.example.com"
221
+ configuration.public_path = Pathname(dir)
222
+ configuration.index_now.key = "test-key"
223
+
224
+ error = assert_raises(Indexmap::ConfigurationError) do
225
+ Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
226
+ end
227
+
228
+ assert_equal "IndexNow key must be a 32-character lowercase hexadecimal string", error.message
229
+ end
230
+ end
231
+
232
+ def test_reuses_existing_key_file_deterministically
233
+ Dir.mktmpdir do |dir|
234
+ public_path = Pathname(dir)
235
+ public_path.join("ffffffffffffffffffffffffffffffff.txt").write("ffffffffffffffffffffffffffffffff")
236
+ public_path.join("00000000000000000000000000000000.txt").write("00000000000000000000000000000000")
237
+
238
+ configuration = Indexmap::Configuration.new
239
+ configuration.base_url = "https://www.example.com"
240
+ configuration.public_path = public_path
241
+
242
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
243
+
244
+ assert_equal public_path.join("00000000000000000000000000000000.txt"), path
245
+ end
246
+ end
247
+
195
248
  private
196
249
 
197
250
  def with_env(overrides)
@@ -3,6 +3,8 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapTaskRunnerTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_create_removes_existing_sitemap_files_writes_new_sitemap_and_key_file
7
9
  Dir.mktmpdir do |dir|
8
10
  public_path = Pathname(dir)
@@ -18,15 +20,15 @@ class IndexmapTaskRunnerTest < Minitest::Test
18
20
  entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
19
21
  )
20
22
  ]
21
- configuration.index_now.key = "test-key"
23
+ configuration.index_now.key = VALID_KEY
22
24
 
23
25
  result = Indexmap::TaskRunner.new(configuration: configuration).create
24
26
 
25
27
  assert_equal false, public_path.join("sitemap-pages.xml.gz").exist?
26
28
  assert_includes public_path.join("sitemap.xml").read, "<sitemapindex"
27
- assert_equal "test-key\n", public_path.join("test-key.txt").read
29
+ assert_equal VALID_KEY, public_path.join("#{VALID_KEY}.txt").read
28
30
  assert_equal [public_path.join("sitemap-pages.xml").to_s, public_path.join("sitemap.xml").to_s], result[:files]
29
- assert_equal public_path.join("test-key.txt"), result[:index_now_key_path]
31
+ assert_equal public_path.join("#{VALID_KEY}.txt"), result[:index_now_key_path]
30
32
  end
31
33
  end
32
34
 
@@ -56,8 +58,8 @@ class IndexmapTaskRunnerTest < Minitest::Test
56
58
 
57
59
  result = Indexmap::TaskRunner.new(configuration: configuration).write_index_now_key(generate_if_missing: true)
58
60
 
59
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, result.basename.to_s)
60
- assert_equal "#{result.basename(".txt")}\n", result.read
61
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, result.basename.to_s)
62
+ assert_equal result.basename(".txt").to_s, result.read
61
63
  end
62
64
  end
63
65
  end
@@ -77,4 +77,23 @@ class IndexmapWriterTest < Minitest::Test
77
77
  refute File.exist?(File.join(directory, "sitemap-pages.xml"))
78
78
  end
79
79
  end
80
+
81
+ def test_omits_sitemap_index_lastmod_when_sections_have_no_lastmod
82
+ Dir.mktmpdir do |directory|
83
+ Indexmap::Writer.new(
84
+ sections: [
85
+ Indexmap::Section.new(
86
+ filename: "sitemap-pages.xml",
87
+ entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
88
+ )
89
+ ],
90
+ public_path: directory,
91
+ base_url: "https://example.com"
92
+ ).write
93
+
94
+ index_xml = File.read(File.join(directory, "sitemap.xml"))
95
+
96
+ refute_includes index_xml, "<lastmod>"
97
+ end
98
+ end
80
99
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: indexmap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo