| @@ -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) { |