PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-instant-indexing-manager.php +202 -2 1.26.0 → 2.13.0 View file →
@@ -62,8 +62,18 @@
62 62 public function init(): void {
63 63 add_action('transition_post_status', [$this, 'handle_post_transition'], 10, 3);
64 64 add_action('delete_post', [$this, 'handle_post_deletion'], 10, 2);
65 65
66 + // Serve the IndexNow key file from PHP when no physical file exists.
67 + // The key is normally written to the WordPress root, but on managed and
68 + // hardened hosting that root is read-only, the write was skipped in
69 + // silence, and every submission then came back 403 Forbidden — the one
70 + // status IndexNow returns when it cannot read the key at the advertised
71 + // keyLocation. Answering the request directly removes the filesystem
72 + // from the critical path entirely. A real file on disk still wins: the
73 + // web server serves it and this never runs.
74 + add_action('parse_request', [$this, 'maybe_serve_key_file']);
75 +
66 76 // Automatic submissions run out-of-band via WP-Cron so the editor's
67 77 // save/publish/delete request never blocks on the IndexNow HTTP call.
68 78 // Registered unconditionally (WP-Cron runs outside the admin context).
69 79 add_action(self::CRON_SUBMIT_HOOK, [$this, 'submit_urls_cron'], 10, 1);
@@ -80,8 +90,173 @@
80 90 }
81 91 }
82 92
83 93 /**
94 + * Serve `<key>.txt` at the site root when no physical file is present.
95 + *
96 + * IndexNow verifies ownership by fetching the key from `keyLocation` and
97 + * comparing it to the key in the payload; anything else is a 403. Writing
98 + * that file to ABSPATH fails on read-only roots, so this answers the
99 + * request from PHP instead. Runs on `parse_request` (before the main query)
100 + * because a missing `.txt` is routed to WordPress by the standard rewrite,
101 + * and only matches the site's own current key — never an arbitrary path.
102 + *
103 + * @since 1.27.0
104 + * @param \WP $wp Current WordPress environment instance.
105 + * @return void
106 + */
107 + public function maybe_serve_key_file($wp): void {
108 + if (is_admin()) {
109 + return;
110 + }
111 +
112 + $settings = get_option($this->option_name, []);
113 + if (empty($settings['enabled'])) {
114 + return;
115 + }
116 +
117 + $api_key = (string) ($settings['api_key'] ?? '');
118 + // The key is also a filename elsewhere, so it is always a plain hex
119 + // token; refuse to match on anything else rather than compare loosely.
120 + if (!preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
121 + return;
122 + }
123 +
124 + $path = (string) wp_parse_url(
125 + isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '',
126 + PHP_URL_PATH
127 + );
128 +
129 + // Compare against the path of the advertised keyLocation, so a site in
130 + // a subdirectory resolves exactly as it is announced to IndexNow.
131 + $expected = (string) wp_parse_url(self::key_location($api_key), PHP_URL_PATH);
132 + if ($expected === '' || untrailingslashit($path) !== untrailingslashit($expected)) {
133 + return;
134 + }
135 +
136 + status_header(200);
137 + header('Content-Type: text/plain; charset=utf-8');
138 + header('X-Robots-Tag: noindex');
139 + echo esc_html($api_key);
140 + exit;
141 + }
142 +
143 + /**
144 + * The public URL IndexNow is told to fetch the key from.
145 + *
146 + * Single source of truth: the submission payload, the settings screen and
147 + * the request matcher above all derive from this, so they cannot drift.
148 + *
149 + * @since 1.27.0
150 + * @param string $api_key Verification key.
151 + * @return string Absolute key file URL.
152 + */
153 + public static function key_location(string $api_key): string {
154 + // Matches the scheme the submitted URLs go out with, so IndexNow is
155 + // never told to verify ownership at an address on the other scheme.
156 + return Url_Scheme::apply(home_url('/' . $api_key . '.txt'));
157 + }
158 +
159 + /**
160 + * Actively verify that the advertised keyLocation is reachable and returns
161 + * the key — the general safety net for #247.
162 + *
163 + * IndexNow only ever answers 403 when it cannot read a matching key at
164 + * keyLocation, and that failure is otherwise silent until the first
165 + * submission. On a read-only root the physical `<key>.txt` is never written,
166 + * and the `parse_request` fallback only fires when the request actually
167 + * reaches WordPress — which it does not on an Apache-style host running
168 + * Plain permalinks. This does a one-shot loopback fetch of the exact URL we
169 + * announce to IndexNow so any unreachable-key configuration (that one
170 + * included) is caught on the settings screen instead of at first submit.
171 + *
172 + * @since 1.28.0
173 + * @return array{reachable:bool,code:int,url:string,reason:string}
174 + */
175 + public function verify_key_reachable(): array {
176 + $settings = get_option($this->option_name, []);
177 + $api_key = (string) ($settings['api_key'] ?? '');
178 + $url = $api_key !== '' ? self::key_location($api_key) : '';
179 +
180 + $result = [
181 + 'reachable' => false,
182 + 'code' => 0,
183 + 'url' => $url,
184 + 'reason' => '',
185 + ];
186 +
187 + if ($api_key === '' || !preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
188 + $result['reason'] = __('No valid IndexNow key is set yet.', 'thinkrank');
189 + return $result;
190 + }
191 +
192 + // Loopback fetch of our own key URL. sslverify is off because this is a
193 + // self-check against this very site (a self-signed/local cert must not
194 + // read as "unreachable"), mirroring how WP Site Health runs its loopback
195 + // probes.
196 + $response = wp_remote_get(
197 + $url,
198 + [
199 + 'timeout' => 7,
200 + 'sslverify' => false,
201 + // translators: this is a diagnostic self-request user agent.
202 + 'user-agent' => 'ThinkRank-IndexNow-KeyCheck/1.0',
203 + ]
204 + );
205 +
206 + if (is_wp_error($response)) {
207 + $result['reason'] = sprintf(
208 + /* translators: %s: HTTP error message. */
209 + __('Could not reach the key file from this server (%s). Search engines may still reach it; open it in a browser to confirm.', 'thinkrank'),
210 + $response->get_error_message()
211 + );
212 + return $result;
213 + }
214 +
215 + $result['code'] = (int) wp_remote_retrieve_response_code($response);
216 + $body = trim((string) wp_remote_retrieve_body($response));
217 +
218 + if ($result['code'] === 200 && hash_equals($api_key, $body)) {
219 + $result['reachable'] = true;
220 + return $result;
221 + }
222 +
223 + $result['reason'] = $this->key_unreachable_reason($result['code']);
224 + return $result;
225 + }
226 +
227 + /**
228 + * Build an actionable explanation when the key file is not reachable, naming
229 + * the specific #247 combination (read-only root + Plain permalinks) so the
230 + * user gets a fix instead of a silent, permanent 403.
231 + *
232 + * @since 1.28.0
233 + * @param int $code HTTP status observed for the key URL (0 when none).
234 + * @return string
235 + */
236 + private function key_unreachable_reason(int $code): string {
237 + $root_writable = wp_is_writable(ABSPATH);
238 + $pretty = (bool) get_option('permalink_structure');
239 +
240 + // The exact #247 trap: the file can't be written (read-only root) AND
241 + // the server only routes unknown paths to WordPress under pretty
242 + // permalinks, so the PHP fallback never runs either.
243 + if (!$root_writable && !$pretty) {
244 + return __('Your site root is read-only (so the key file can’t be written) and permalinks are set to “Plain” (so ThinkRank can’t serve the key dynamically). Fix either one: set Settings → Permalinks to any option other than “Plain”, or make the site root writable.', 'thinkrank');
245 + }
246 +
247 + if ($code === 404) {
248 + return __('The key file returned 404. If your site root is read-only, set Settings → Permalinks to any option other than “Plain” so ThinkRank can serve the key.', 'thinkrank');
249 + }
250 +
251 + return sprintf(
252 + /* translators: %d: HTTP status code returned by the key URL. */
253 + __('The key file could not be verified (HTTP %d). Open it in a browser — it should show the key and nothing else.', 'thinkrank'),
254 + $code
255 + );
256 + }
257 +
258 + /**
84 259 * Add Row Action Link
85 260 *
86 261 * @since 1.1.0
87 262 * @param array $actions Existing actions.
@@ -350,8 +525,20 @@
350 525 if (empty($urls)) {
351 526 return ['success' => false, 'message' => 'No URLs matched this site host', 'submitted_count' => 0];
352 527 }
353 528
529 + // Every submission path lands here, so this is where the site's scheme
530 + // preference is applied (#638). Submitting http URLs for a site served
531 + // over https asks search engines to index an address that redirects,
532 + // and it is the canonical mismatch all over again in the one place a
533 + // site owner cannot see it happening.
534 + $urls = array_values(array_unique(array_map(
535 + static function ($u): string {
536 + return Url_Scheme::apply((string) $u);
537 + },
538 + $urls
539 + )));
540 +
354 541 // Enforce the shared per-submission cap so every path (manual, bulk, MCP)
355 542 // behaves consistently.
356 543 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
357 544 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
@@ -358,14 +545,14 @@
358 545 }
359 546
360 547 $settings = get_option($this->option_name, []);
361 548 $api_key = $settings['api_key'] ?? '';
362 - $key_location = home_url('/' . $api_key . '.txt');
363 -
364 549 if (empty($api_key)) {
365 550 return ['success' => false, 'message' => 'API Key missing', 'submitted_count' => 0];
366 551 }
367 552
553 + $key_location = self::key_location($api_key);
554 +
368 555 $body = [
369 556 'host' => $host,
370 557 'key' => $api_key,
371 558 'keyLocation' => $key_location,
@@ -391,8 +578,21 @@
391 578 } else {
392 579 $response_code = (int) wp_remote_retrieve_response_code($response);
393 580 $response_message = wp_remote_retrieve_response_message($response);
394 581 $status = ($response_code >= 200 && $response_code < 300) ? 'success' : 'failed';
582 +
583 + // "403 Forbidden" is IndexNow's answer for exactly one problem —
584 + // it could not read a matching key at keyLocation — but the raw
585 + // status reads like a permissions error against the API and sent a
586 + // customer hunting through the wrong settings for days. Say what it
587 + // actually means, and name the URL to check.
588 + if ($response_code === 403) {
589 + $response_message = sprintf(
590 + /* translators: %s: public URL of the IndexNow key file. */
591 + __('Key file could not be verified. Search engines must be able to read your key at %s — open it in a browser: it should show the key and nothing else.', 'thinkrank'),
592 + $key_location
593 + );
594 + }
395 595 }
396 596
397 597 // Log each URL
398 598 foreach ($urls as $url) {