| @@ -14,17 +14,29 @@ | ||
| 14 | 14 | declare(strict_types=1); |
| 15 | 15 | |
| 16 | 16 | namespace ThinkRank\API; |
| 17 | 17 | |
| 18 | +// Prevent direct access | |
| 19 | +if (!defined('ABSPATH')) { | |
| 20 | + exit; | |
| 21 | +} | |
| 22 | + | |
| 18 | 23 | use ThinkRank\SEO\Sitemap_Generator; |
| 19 | 24 | use ThinkRank\API\Traits\CSRF_Protection; |
| 25 | +use ThinkRank\API\Traits\Context_Authorization; | |
| 20 | 26 | use WP_REST_Controller; |
| 21 | 27 | use WP_REST_Request; |
| 22 | 28 | use WP_REST_Response; |
| 23 | 29 | use WP_Error; |
| 24 | 30 | |
| 31 | +// Prevent direct access | |
| 32 | +if (!defined('ABSPATH')) { | |
| 33 | + exit; | |
| 34 | +} | |
| 35 | + | |
| 25 | 36 | // Load CSRF Protection trait |
| 26 | 37 | require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php'; |
| 38 | +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php'; | |
| 27 | 39 | |
| 28 | 40 | /** |
| 29 | 41 | * Sitemap API Endpoints Class |
| 30 | 42 | * |
| @@ -35,8 +47,9 @@ | ||
| 35 | 47 | * @since 1.0.0 |
| 36 | 48 | */ |
| 37 | 49 | class Sitemap_Endpoint extends WP_REST_Controller { |
| 38 | 50 | use CSRF_Protection; |
| 51 | + use Context_Authorization; | |
| 39 | 52 | |
| 40 | 53 | /** |
| 41 | 54 | * Sitemap Generator instance |
| 42 | 55 | * |
| @@ -83,9 +96,9 @@ | ||
| 83 | 96 | [ |
| 84 | 97 | [ |
| 85 | 98 | 'methods' => 'POST', |
| 86 | 99 | 'callback' => [$this, 'generate_sitemap'], |
| 87 | - 'permission_callback' => [$this, 'check_csrf_permissions'], | |
| 100 | + 'permission_callback' => [$this, 'check_manage_permissions'], | |
| 88 | 101 | 'args' => $this->get_generate_args() |
| 89 | 102 | ] |
| 90 | 103 | ] |
| 91 | 104 | ); |
| @@ -124,9 +137,9 @@ | ||
| 124 | 137 | [ |
| 125 | 138 | [ |
| 126 | 139 | 'methods' => 'POST', |
| 127 | 140 | 'callback' => [$this, 'submit_sitemap'], |
| 128 | - 'permission_callback' => [$this, 'check_csrf_permissions'], | |
| 141 | + 'permission_callback' => [$this, 'check_manage_permissions'], | |
| 129 | 142 | 'args' => $this->get_submit_args() |
| 130 | 143 | ] |
| 131 | 144 | ] |
| 132 | 145 | ); |
| @@ -138,9 +151,9 @@ | ||
| 138 | 151 | [ |
| 139 | 152 | [ |
| 140 | 153 | 'methods' => 'POST', |
| 141 | 154 | 'callback' => [$this, 'ping_search_engines'], |
| 142 | - 'permission_callback' => [$this, 'check_csrf_permissions'] | |
| 155 | + 'permission_callback' => [$this, 'check_manage_permissions'] | |
| 143 | 156 | ] |
| 144 | 157 | ] |
| 145 | 158 | ); |
| 146 | 159 | |
| @@ -164,9 +177,10 @@ | ||
| 164 | 177 | [ |
| 165 | 178 | [ |
| 166 | 179 | 'methods' => 'GET', |
| 167 | 180 | 'callback' => [$this, 'get_sitemap_settings'], |
| 168 | - 'permission_callback' => [$this, 'check_read_permissions'] | |
| 181 | + 'permission_callback' => [$this, 'check_read_permissions'], | |
| 182 | + 'args' => $this->get_context_route_args() | |
| 169 | 183 | ], |
| 170 | 184 | [ |
| 171 | 185 | 'methods' => 'POST', |
| 172 | 186 | 'callback' => [$this, 'update_sitemap_settings'], |
| @@ -222,9 +236,9 @@ | ||
| 222 | 236 | [ |
| 223 | 237 | [ |
| 224 | 238 | 'methods' => 'POST', |
| 225 | 239 | 'callback' => [$this, 'cleanup_sitemap_files'], |
| 226 | - 'permission_callback' => [$this, 'check_csrf_permissions'], | |
| 240 | + 'permission_callback' => [$this, 'check_manage_permissions'], | |
| 227 | 241 | 'args' => [ |
| 228 | 242 | 'sitemap_urls' => [ |
| 229 | 243 | 'required' => false, |
| 230 | 244 | 'type' => 'array', |
| @@ -236,8 +250,144 @@ | ||
| 236 | 250 | ); |
| 237 | 251 | } |
| 238 | 252 | |
| 239 | 253 | /** |
| 254 | + * Record that a sitemap generation just ran. | |
| 255 | + * | |
| 256 | + * Persists the `last_generated` timestamp so the admin UI can distinguish | |
| 257 | + * generated sitemaps (whose files now exist on disk) from ones that are | |
| 258 | + * merely configured — the "View Generated Sitemaps" links stay disabled | |
| 259 | + * until this is set. | |
| 260 | + * | |
| 261 | + * @return string ISO-8601 timestamp stored as the `last_generated` setting. | |
| 262 | + */ | |
| 263 | + private function record_generation(): string { | |
| 264 | + $timestamp = gmdate('c'); | |
| 265 | + $settings = $this->sitemap_generator->get_settings('site'); | |
| 266 | + $settings['last_generated'] = $timestamp; | |
| 267 | + $this->sitemap_generator->save_settings('site', null, $settings); | |
| 268 | + | |
| 269 | + // This generation wrote the same files the outstanding automatic rebuild | |
| 270 | + // was queued to write, so clear its marker (and any recorded failure) | |
| 271 | + // instead of leaving a request-time takeover to repeat the work. | |
| 272 | + $this->sitemap_generator->mark_regeneration_complete(); | |
| 273 | + | |
| 274 | + return $timestamp; | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 278 | + * Record that the published sitemap files are gone. | |
| 279 | + * | |
| 280 | + * The inverse of {@see record_generation()}: clears `last_generated` so the | |
| 281 | + * admin's "View Generated Sitemaps" links go back to disabled instead of | |
| 282 | + * pointing at files that have just been deleted. | |
| 283 | + * | |
| 284 | + * @since 1.31.0 | |
| 285 | + * @return void | |
| 286 | + */ | |
| 287 | + private function clear_generation_record(): void { | |
| 288 | + $settings = $this->sitemap_generator->get_settings('site'); | |
| 289 | + if (empty($settings['last_generated'])) { | |
| 290 | + return; | |
| 291 | + } | |
| 292 | + | |
| 293 | + $settings['last_generated'] = ''; | |
| 294 | + $this->sitemap_generator->save_settings('site', null, $settings); | |
| 295 | + } | |
| 296 | + | |
| 297 | + /** | |
| 298 | + * Persist a manual-generation auto-promotion into the stored settings. | |
| 299 | + * | |
| 300 | + * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the | |
| 301 | + * segmented sitemap_urls for the current generation. On the automatic path | |
| 302 | + * generate_and_save() saves that resolved state; the manual generate route | |
| 303 | + * must do the same, or the next content-/settings-triggered regeneration | |
| 304 | + * (which reads stored settings) reverts the site to a single flat file. | |
| 305 | + * | |
| 306 | + * Only the two mode-defining keys are merged, so this partial generate | |
| 307 | + * payload never clobbers unrelated saved settings. | |
| 308 | + * | |
| 309 | + * @param array $options Options after maybe_promote_to_index(). | |
| 310 | + * @return void | |
| 311 | + */ | |
| 312 | + private function persist_promoted_mode(array $options): void { | |
| 313 | + $saved = $this->sitemap_generator->get_settings('site'); | |
| 314 | + | |
| 315 | + // Record the mode that was actually written, in both directions, so the | |
| 316 | + // stored settings and the files on disk cannot disagree. Persisting a | |
| 317 | + // demotion used to be unsafe because an absent use_sitemap_index was | |
| 318 | + // indistinguishable from an explicit "off", and treating it as off would | |
| 319 | + // clobber a saved index whenever the toggle merely happened to be | |
| 320 | + // missing. maybe_promote_to_index() now resolves an absent key from the | |
| 321 | + // saved settings before this runs, so whatever arrives here is the | |
| 322 | + // resolved decision rather than a gap in the payload. | |
| 323 | + $mode = !empty($options['use_sitemap_index']); | |
| 324 | + $urls = $options['sitemap_urls'] ?? ($saved['sitemap_urls'] ?? null); | |
| 325 | + | |
| 326 | + $mode_unchanged = $mode === !empty($saved['use_sitemap_index']); | |
| 327 | + $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null); | |
| 328 | + | |
| 329 | + if ($mode_unchanged && $urls_unchanged) { | |
| 330 | + return; | |
| 331 | + } | |
| 332 | + | |
| 333 | + $saved['use_sitemap_index'] = $mode; | |
| 334 | + if (isset($options['sitemap_urls'])) { | |
| 335 | + $saved['sitemap_urls'] = $options['sitemap_urls']; | |
| 336 | + } | |
| 337 | + $this->sitemap_generator->save_settings('site', null, $saved); | |
| 338 | + } | |
| 339 | + | |
| 340 | + /** | |
| 341 | + * Write the sitemap files when the site has none yet. | |
| 342 | + * | |
| 343 | + * The sitemap is served as a static file in the web root, so a site whose | |
| 344 | + * sitemap is enabled but never generated serves nothing at /sitemap.xml — | |
| 345 | + * WordPress core then claims that URL and redirects to wp-sitemap.xml. | |
| 346 | + * Turning the sitemap on therefore has to produce the file, which is what | |
| 347 | + * the Setup Wizard's "Save & Continue" relies on for its "View Sitemap" | |
| 348 | + * link. Only fills the gap: an existing file is left to the explicit | |
| 349 | + * "Generate" action so saving settings stays cheap on large sites. | |
| 350 | + * | |
| 351 | + * @since 1.17.0 | |
| 352 | + * @param string $context_type Settings context type. | |
| 353 | + * @param int|null $context_id Settings context id. | |
| 354 | + * @return string Sitemap URL, or an empty string when nothing is published. | |
| 355 | + */ | |
| 356 | + private function ensure_sitemap_file(string $context_type, ?int $context_id, bool &$generated_now = false): string { | |
| 357 | + $generated_now = false; | |
| 358 | + | |
| 359 | + if ($context_type !== 'site') { | |
| 360 | + return ''; | |
| 361 | + } | |
| 362 | + | |
| 363 | + $settings = $this->sitemap_generator->get_settings($context_type, $context_id); | |
| 364 | + | |
| 365 | + if (empty($settings['enabled'])) { | |
| 366 | + return ''; | |
| 367 | + } | |
| 368 | + | |
| 369 | + $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings); | |
| 370 | + | |
| 371 | + if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) { | |
| 372 | + return $sitemap_url; | |
| 373 | + } | |
| 374 | + | |
| 375 | + // Never fail the settings save over generation: the settings are already | |
| 376 | + // persisted, and content changes or a manual Generate will retry. | |
| 377 | + try { | |
| 378 | + if (!$this->sitemap_generator->generate_and_save($settings)) { | |
| 379 | + return ''; | |
| 380 | + } | |
| 381 | + $generated_now = true; | |
| 382 | + } catch (\Throwable $e) { | |
| 383 | + return ''; | |
| 384 | + } | |
| 385 | + | |
| 386 | + return $sitemap_url; | |
| 387 | + } | |
| 388 | + | |
| 389 | + /** | |
| 240 | 390 | * Generate XML sitemap |
| 241 | 391 | * |
| 242 | 392 | * @since 1.0.0 |
| 243 | 393 | * |
| @@ -264,11 +414,33 @@ | ||
| 264 | 414 | ); |
| 265 | 415 | } |
| 266 | 416 | |
| 267 | 417 | $options = $request->get_param('options') ?? []; |
| 418 | + if (!is_array($options)) { | |
| 419 | + $options = []; | |
| 420 | + } | |
| 421 | + // sitemap_urls must be an array wherever it is counted/iterated below | |
| 422 | + // (and in the generator); drop a wrong-typed value so a malformed | |
| 423 | + // request yields normal output instead of an uncaught TypeError. | |
| 424 | + if (isset($options['sitemap_urls']) && !is_array($options['sitemap_urls'])) { | |
| 425 | + unset($options['sitemap_urls']); | |
| 426 | + } | |
| 268 | 427 | |
| 269 | - // Check if multiple sitemaps are configured | |
| 270 | - if (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1) { | |
| 428 | + // Resolve index-vs-single mode from the use_sitemap_index toggle | |
| 429 | + // (synthesizing child sitemaps when the toggle is on but none are | |
| 430 | + // configured, and auto-promoting an oversized single file), rather | |
| 431 | + // than deciding purely by how many sitemap_urls happen to be present. | |
| 432 | + $options = $this->sitemap_generator->maybe_promote_to_index($options); | |
| 433 | + | |
| 434 | + // Persist the resolved index-mode decision so a later content- or | |
| 435 | + // settings-triggered regeneration (which reads stored settings) | |
| 436 | + // doesn't revert a manual auto-promotion back to a single flat file. | |
| 437 | + // generate_and_save() already does this on the automatic path; the | |
| 438 | + // manual generate route must match it. | |
| 439 | + $this->persist_promoted_mode($options); | |
| 440 | + | |
| 441 | + // Check if an index (multiple sitemaps) is configured | |
| 442 | + if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) { | |
| 271 | 443 | // Generate multiple sitemaps |
| 272 | 444 | $results = $this->sitemap_generator->generate_multiple_sitemaps($options); |
| 273 | 445 | |
| 274 | 446 | if (!$results['success']) { |
| @@ -284,9 +456,10 @@ | ||
| 284 | 456 | 'message' => 'Multiple sitemaps generated successfully', |
| 285 | 457 | 'data' => [ |
| 286 | 458 | 'sitemaps_generated' => $results['sitemaps_generated'], |
| 287 | 459 | 'total_sitemaps' => count($results['sitemaps_generated']), |
| 288 | - 'url_count' => $results['total_urls'] | |
| 460 | + 'url_count' => $results['total_urls'], | |
| 461 | + 'last_generated' => $this->record_generation() | |
| 289 | 462 | ] |
| 290 | 463 | ]); |
| 291 | 464 | } else { |
| 292 | 465 | // Generate single sitemap (backward compatibility) |
| @@ -293,14 +466,36 @@ | ||
| 293 | 466 | $sitemap_xml = $this->sitemap_generator->generate_sitemap($options); |
| 294 | 467 | |
| 295 | 468 | // Save sitemap to file (optional) |
| 296 | 469 | $save_to_file = $request->get_param('save_to_file') ?? true; |
| 470 | + $last_generated = ''; | |
| 297 | 471 | if ($save_to_file) { |
| 298 | 472 | $filename = 'sitemap.xml'; |
| 299 | 473 | if (!empty($options['sitemap_urls'][0]['url'])) { |
| 300 | 474 | $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH)); |
| 301 | 475 | } |
| 302 | - $this->save_sitemap_file($sitemap_xml, $filename); | |
| 476 | + // A failed write has to surface here the way the index | |
| 477 | + // branch surfaces one. Discarding it let record_generation() | |
| 478 | + // advance last_generated and clear the pending marker and | |
| 479 | + // the recorded failure, so an unwritable site root — the | |
| 480 | + // exact case this endpoint reports health for — came back | |
| 481 | + // as a healthy "Generated successfully". | |
| 482 | + if (!$this->save_sitemap_file($sitemap_xml, $filename)) { | |
| 483 | + return new WP_Error( | |
| 484 | + 'sitemap_generation_failed', | |
| 485 | + 'Failed to save sitemap: ' . $filename, | |
| 486 | + ['status' => 500] | |
| 487 | + ); | |
| 488 | + } | |
| 489 | + | |
| 490 | + // Regenerate the standalone local business sitemap on the | |
| 491 | + // single-sitemap path too (parity with Rank Math). | |
| 492 | + $this->sitemap_generator->regenerate_local_sitemap($options); | |
| 493 | + | |
| 494 | + // Only record generation when the files were actually | |
| 495 | + // written — a preview (save_to_file=false) must not enable | |
| 496 | + // the "View Generated Sitemaps" links. | |
| 497 | + $last_generated = $this->record_generation(); | |
| 303 | 498 | } |
| 304 | 499 | |
| 305 | 500 | return new WP_REST_Response([ |
| 306 | 501 | 'success' => true, |
| @@ -307,9 +502,10 @@ | ||
| 307 | 502 | 'data' => [ |
| 308 | 503 | 'sitemap_xml' => $sitemap_xml, |
| 309 | 504 | 'sitemap_url' => home_url('/sitemap.xml'), |
| 310 | 505 | 'generated_at' => gmdate('c'), |
| 311 | - 'url_count' => $this->count_urls_in_xml($sitemap_xml) | |
| 506 | + 'url_count' => $this->count_urls_in_xml($sitemap_xml), | |
| 507 | + 'last_generated' => $last_generated | |
| 312 | 508 | ], |
| 313 | 509 | 'message' => 'Sitemap generated successfully' |
| 314 | 510 | ]); |
| 315 | 511 | } |
| @@ -346,8 +542,19 @@ | ||
| 346 | 542 | ['status' => 400] |
| 347 | 543 | ); |
| 348 | 544 | } |
| 349 | 545 | |
| 546 | + // Block SSRF: this endpoint fetches the URL server-side, so reject | |
| 547 | + // loopback/link-local/private hosts and non-http(s) schemes via | |
| 548 | + // WordPress's own validator (same guard used in class-schema-endpoint). | |
| 549 | + if (!wp_http_validate_url($sitemap_url)) { | |
| 550 | + return new WP_Error( | |
| 551 | + 'invalid_url', | |
| 552 | + 'The sitemap URL is not allowed.', | |
| 553 | + ['status' => 400] | |
| 554 | + ); | |
| 555 | + } | |
| 556 | + | |
| 350 | 557 | // Perform validation |
| 351 | 558 | $validation_result = $this->perform_sitemap_validation($sitemap_url); |
| 352 | 559 | |
| 353 | 560 | return new WP_REST_Response([ |
| @@ -407,31 +614,17 @@ | ||
| 407 | 614 | * @param WP_REST_Request $request Request object |
| 408 | 615 | * @return WP_REST_Response|WP_Error Response object or error |
| 409 | 616 | */ |
| 410 | 617 | public function submit_sitemap(WP_REST_Request $request) { |
| 411 | - try { | |
| 412 | - $search_engines = $request->get_param('search_engines') ?? ['google', 'bing']; | |
| 413 | - $sitemap_url = $request->get_param('sitemap_url') ?? home_url('/sitemap.xml'); | |
| 414 | - | |
| 415 | - $submission_results = []; | |
| 416 | - | |
| 417 | - foreach ($search_engines as $engine) { | |
| 418 | - $submission_results[$engine] = $this->submit_to_search_engine($engine, $sitemap_url); | |
| 419 | - } | |
| 420 | - | |
| 421 | - return new WP_REST_Response([ | |
| 422 | - 'success' => true, | |
| 423 | - 'data' => $submission_results, | |
| 424 | - 'message' => 'Sitemap submission completed' | |
| 425 | - ], 200); | |
| 426 | - | |
| 427 | - } catch (\Exception $e) { | |
| 428 | - return new WP_Error( | |
| 429 | - 'submission_failed', | |
| 430 | - 'Sitemap submission failed: ' . $e->getMessage(), | |
| 431 | - ['status' => 500] | |
| 432 | - ); | |
| 433 | - } | |
| 618 | + // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit; | |
| 619 | + // both now discover sitemaps via robots.txt on their own schedule. There | |
| 620 | + // is nothing to submit, so this is a no-op kept only so existing clients | |
| 621 | + // don't 404 (mirrors ping_search_engines()). | |
| 622 | + return new WP_REST_Response([ | |
| 623 | + 'success' => true, | |
| 624 | + 'data' => [], | |
| 625 | + 'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.', | |
| 626 | + ], 200); | |
| 434 | 627 | } |
| 435 | 628 | |
| 436 | 629 | /** |
| 437 | 630 | * Ping search engines about sitemap updates (unified method) |
| @@ -441,39 +634,18 @@ | ||
| 441 | 634 | * @param WP_REST_Request $request Request object |
| 442 | 635 | * @return WP_REST_Response|WP_Error Response object or error |
| 443 | 636 | */ |
| 444 | 637 | public function ping_search_engines(WP_REST_Request $request) { |
| 445 | - try { | |
| 446 | - // Rate limiting for ping requests (max 5 per hour) | |
| 447 | - if (!$this->check_ping_rate_limit()) { | |
| 448 | - return new WP_Error( | |
| 449 | - 'ping_rate_limit_exceeded', | |
| 450 | - 'Too many ping requests. Please wait before trying again.', | |
| 451 | - ['status' => 429] | |
| 452 | - ); | |
| 453 | - } | |
| 454 | - // Use reflection to access the private ping method from sitemap generator | |
| 455 | - $reflection = new \ReflectionClass($this->sitemap_generator); | |
| 456 | - $ping_method = $reflection->getMethod('ping_search_engines'); | |
| 457 | - $ping_method->setAccessible(true); | |
| 458 | - | |
| 459 | - // Execute the same ping logic used by auto-ping (unified approach) | |
| 460 | - $ping_method->invoke($this->sitemap_generator); | |
| 461 | - | |
| 462 | - return new WP_REST_Response([ | |
| 463 | - 'success' => true, | |
| 464 | - 'message' => 'Search engines notified successfully', | |
| 465 | - 'engines' => ['google', 'bing'], | |
| 466 | - 'timestamp' => gmdate('c') | |
| 467 | - ], 200); | |
| 468 | - | |
| 469 | - } catch (\Exception $e) { | |
| 470 | - return new WP_Error( | |
| 471 | - 'ping_failed', | |
| 472 | - 'Failed to ping search engines: ' . $e->getMessage(), | |
| 473 | - ['status' => 500] | |
| 474 | - ); | |
| 475 | - } | |
| 638 | + // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit; | |
| 639 | + // both now rely on the sitemap being referenced from robots.txt and pulled | |
| 640 | + // on their own schedule. There is nothing left to ping, so this endpoint is | |
| 641 | + // a no-op kept only so existing clients don't 404. | |
| 642 | + return new WP_REST_Response([ | |
| 643 | + 'success' => true, | |
| 644 | + 'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.', | |
| 645 | + 'engines' => [], | |
| 646 | + 'timestamp' => gmdate('c') | |
| 647 | + ], 200); | |
| 476 | 648 | } |
| 477 | 649 | |
| 478 | 650 | /** |
| 479 | 651 | * Get sitemap statistics |
| @@ -502,9 +674,9 @@ | ||
| 502 | 674 | 'data' => $stats, |
| 503 | 675 | 'message' => 'Sitemap statistics retrieved successfully' |
| 504 | 676 | ], 200); |
| 505 | 677 | |
| 506 | - } catch (\Exception $e) { | |
| 678 | + } catch (\Throwable $e) { | |
| 507 | 679 | return new WP_Error( |
| 508 | 680 | 'stats_failed', |
| 509 | 681 | 'Failed to get sitemap statistics: ' . $e->getMessage(), |
| 510 | 682 | ['status' => 500] |
| @@ -523,16 +695,39 @@ | ||
| 523 | 695 | return current_user_can('edit_posts'); |
| 524 | 696 | } |
| 525 | 697 | |
| 526 | 698 | /** |
| 527 | - * Check manage permissions | |
| 699 | + * Check manage permissions for the state-changing routes. | |
| 528 | 700 | * |
| 701 | + * Every route using this callback is a POST that writes something — | |
| 702 | + * /generate, /submit, /ping, /settings, /cleanup — so it is nonce-gated as | |
| 703 | + * well as capability-gated, matching Schema_Endpoint, Setup_Wizard_Endpoint | |
| 704 | + * and Email_Report_Endpoint. The class already `use`d CSRF_Protection but | |
| 705 | + * never called it, leaving this controller the odd one out. | |
| 706 | + * | |
| 529 | 707 | * @since 1.0.0 |
| 530 | 708 | * |
| 531 | - * @return bool Permission status | |
| 709 | + * @param WP_REST_Request $request Request object | |
| 710 | + * @return bool|WP_Error Permission status | |
| 532 | 711 | */ |
| 533 | - public function check_manage_permissions(): bool { | |
| 534 | - return current_user_can('manage_options'); | |
| 712 | + public function check_manage_permissions(WP_REST_Request $request) { | |
| 713 | + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling')) { | |
| 714 | + return new WP_Error( | |
| 715 | + 'rest_forbidden', | |
| 716 | + __('You do not have permission to manage sitemaps.', 'thinkrank'), | |
| 717 | + ['status' => 403] | |
| 718 | + ); | |
| 719 | + } | |
| 720 | + | |
| 721 | + if (!$this->verify_request_nonce($request)) { | |
| 722 | + return new WP_Error( | |
| 723 | + 'rest_forbidden', | |
| 724 | + __('Invalid security token. Please refresh the page and try again.', 'thinkrank'), | |
| 725 | + ['status' => 403] | |
| 726 | + ); | |
| 727 | + } | |
| 728 | + | |
| 729 | + return true; | |
| 535 | 730 | } |
| 536 | 731 | |
| 537 | 732 | /** |
| 538 | 733 | * Save sitemap to file |
| @@ -590,10 +785,12 @@ | ||
| 590 | 785 | 'url_count' => 0, |
| 591 | 786 | 'file_size' => 0 |
| 592 | 787 | ]; |
| 593 | 788 | |
| 594 | - // Check if sitemap is accessible | |
| 595 | - $response = wp_remote_get($sitemap_url, ['timeout' => 30]); | |
| 789 | + // Check if sitemap is accessible. wp_safe_remote_get() re-applies the | |
| 790 | + // reject-unsafe-URLs / external-host filters (incl. on redirects) so an | |
| 791 | + // internal host can't be reached even if it slipped past validation. | |
| 792 | + $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]); | |
| 596 | 793 | |
| 597 | 794 | if (is_wp_error($response)) { |
| 598 | 795 | $validation_result['valid'] = false; |
| 599 | 796 | $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message(); |
| @@ -637,52 +834,8 @@ | ||
| 637 | 834 | return $validation_result; |
| 638 | 835 | } |
| 639 | 836 | |
| 640 | 837 | /** |
| 641 | - * Submit sitemap to search engine | |
| 642 | - * | |
| 643 | - * @since 1.0.0 | |
| 644 | - * | |
| 645 | - * @param string $engine Search engine name | |
| 646 | - * @param string $sitemap_url Sitemap URL | |
| 647 | - * @return array Submission result | |
| 648 | - */ | |
| 649 | - private function submit_to_search_engine(string $engine, string $sitemap_url): array { | |
| 650 | - $result = [ | |
| 651 | - 'success' => false, | |
| 652 | - 'message' => '', | |
| 653 | - 'submitted_at' => gmdate('c') | |
| 654 | - ]; | |
| 655 | - | |
| 656 | - $ping_urls = [ | |
| 657 | - 'google' => 'https://www.google.com/ping?sitemap=' . urlencode($sitemap_url), | |
| 658 | - 'bing' => 'https://www.bing.com/ping?sitemap=' . urlencode($sitemap_url) | |
| 659 | - ]; | |
| 660 | - | |
| 661 | - if (!isset($ping_urls[$engine])) { | |
| 662 | - $result['message'] = 'Unsupported search engine'; | |
| 663 | - return $result; | |
| 664 | - } | |
| 665 | - | |
| 666 | - $response = wp_remote_get($ping_urls[$engine], ['timeout' => 30]); | |
| 667 | - | |
| 668 | - if (is_wp_error($response)) { | |
| 669 | - $result['message'] = 'Submission failed: ' . $response->get_error_message(); | |
| 670 | - return $result; | |
| 671 | - } | |
| 672 | - | |
| 673 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 674 | - if ($status_code === 200) { | |
| 675 | - $result['success'] = true; | |
| 676 | - $result['message'] = 'Sitemap submitted successfully'; | |
| 677 | - } else { | |
| 678 | - $result['message'] = "Submission failed with HTTP status: {$status_code}"; | |
| 679 | - } | |
| 680 | - | |
| 681 | - return $result; | |
| 682 | - } | |
| 683 | - | |
| 684 | - /** | |
| 685 | 838 | * Get arguments for generate endpoint |
| 686 | 839 | * |
| 687 | 840 | * @since 1.0.0 |
| 688 | 841 | * |
| @@ -761,10 +914,15 @@ | ||
| 761 | 914 | * @return WP_REST_Response|WP_Error Response object or error |
| 762 | 915 | */ |
| 763 | 916 | public function get_sitemap_settings(WP_REST_Request $request) { |
| 764 | 917 | try { |
| 765 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 766 | - $context_id = $request->get_param('context_id') ?? null; | |
| 918 | + // SECURITY: the settings are stored per context, so the object has | |
| 919 | + // to be authorised before it is read (#385). | |
| 920 | + $context = $this->resolve_request_context($request); | |
| 921 | + if (is_wp_error($context)) { | |
| 922 | + return $context; | |
| 923 | + } | |
| 924 | + [$context_type, $context_id] = $context; | |
| 767 | 925 | |
| 768 | 926 | // Get settings from Sitemap_Generator |
| 769 | 927 | $settings = $this->sitemap_generator->get_settings($context_type, $context_id); |
| 770 | 928 | |
| @@ -772,9 +930,14 @@ | ||
| 772 | 930 | 'success' => true, |
| 773 | 931 | 'data' => [ |
| 774 | 932 | 'settings' => $settings, |
| 775 | 933 | 'context_type' => $context_type, |
| 776 | - 'context_id' => $context_id | |
| 934 | + 'context_id' => $context_id, | |
| 935 | + // Kept out of `settings` on purpose: this is generator state, | |
| 936 | + // not something the settings POST round-trips. | |
| 937 | + 'health' => $context_type === 'site' | |
| 938 | + ? $this->sitemap_generator->get_regeneration_health() | |
| 939 | + : null | |
| 777 | 940 | ], |
| 778 | 941 | 'message' => 'Sitemap settings retrieved successfully' |
| 779 | 942 | ], 200); |
| 780 | 943 | |
| @@ -797,11 +960,17 @@ | ||
| 797 | 960 | */ |
| 798 | 961 | public function update_sitemap_settings(WP_REST_Request $request) { |
| 799 | 962 | try { |
| 800 | 963 | $settings = $request->get_param('settings') ?? []; |
| 801 | - $context_type = $request->get_param('context_type') ?? 'site'; | |
| 802 | - $context_id = $request->get_param('context_id') ?? null; | |
| 803 | 964 | |
| 965 | + // SECURITY: this write is keyed by the context, so the object has to | |
| 966 | + // be authorised before anything is persisted (#385). | |
| 967 | + $context = $this->resolve_request_context($request); | |
| 968 | + if (is_wp_error($context)) { | |
| 969 | + return $context; | |
| 970 | + } | |
| 971 | + [$context_type, $context_id] = $context; | |
| 972 | + | |
| 804 | 973 | if (empty($settings)) { |
| 805 | 974 | return new WP_Error( |
| 806 | 975 | 'missing_settings', |
| 807 | 976 | 'Settings data is required', |
| @@ -819,14 +988,27 @@ | ||
| 819 | 988 | ['status' => 500] |
| 820 | 989 | ); |
| 821 | 990 | } |
| 822 | 991 | |
| 992 | + $generated_now = false; | |
| 993 | + $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now); | |
| 994 | + | |
| 995 | + // Rebuild the served sitemap so inclusion-rule changes take effect | |
| 996 | + // instead of waiting for a content edit (debounced against rapid | |
| 997 | + // successive saves). Skip when ensure_sitemap_file() just built a | |
| 998 | + // fresh file synchronously — otherwise we'd immediately schedule a | |
| 999 | + // second full generation of the same content. | |
| 1000 | + if (!$generated_now) { | |
| 1001 | + $this->sitemap_generator->schedule_regeneration(); | |
| 1002 | + } | |
| 1003 | + | |
| 823 | 1004 | return new WP_REST_Response([ |
| 824 | 1005 | 'success' => true, |
| 825 | 1006 | 'data' => [ |
| 826 | 1007 | 'settings' => $settings, |
| 827 | 1008 | 'context_type' => $context_type, |
| 828 | - 'context_id' => $context_id | |
| 1009 | + 'context_id' => $context_id, | |
| 1010 | + 'sitemap_url' => $sitemap_url | |
| 829 | 1011 | ], |
| 830 | 1012 | 'message' => 'Sitemap settings saved successfully' |
| 831 | 1013 | ], 200); |
| 832 | 1014 | |
| @@ -966,49 +1148,38 @@ | ||
| 966 | 1148 | * @return WP_REST_Response|WP_Error Response object or error |
| 967 | 1149 | */ |
| 968 | 1150 | public function cleanup_sitemap_files(WP_REST_Request $request) { |
| 969 | 1151 | try { |
| 970 | - // Scan filesystem for actual sitemap files instead of relying on configured URLs | |
| 971 | - $cleaned_files = []; | |
| 972 | - $failed_files = []; | |
| 1152 | + $settings = $this->sitemap_generator->get_settings('site'); | |
| 973 | 1153 | |
| 974 | - // Common sitemap file patterns to look for | |
| 975 | - $sitemap_patterns = [ | |
| 976 | - 'sitemap*.xml', | |
| 977 | - '*sitemap*.xml' | |
| 978 | - ]; | |
| 1154 | + // Delete only the files ThinkRank published. This used to glob | |
| 1155 | + // ABSPATH for 'sitemap*.xml' and '*sitemap*.xml' and delete anything | |
| 1156 | + // whose name contained "sitemap", which also swept up a physical | |
| 1157 | + // core wp-sitemap.xml and any other plugin's sitemap sitting in the | |
| 1158 | + // web root. delete_published_sitemaps() derives the name list from | |
| 1159 | + // our own stored sitemap_urls (honouring a custom url pattern) plus | |
| 1160 | + // the default names, and covers the -N pagination pages. | |
| 1161 | + $removed = $this->sitemap_generator->delete_published_sitemaps($settings); | |
| 1162 | + $cleaned_files = $removed['deleted']; | |
| 1163 | + $failed_files = $removed['failed']; | |
| 979 | 1164 | |
| 980 | - // Get all XML files in root directory that match sitemap patterns | |
| 981 | - $sitemap_files = []; | |
| 982 | - foreach ($sitemap_patterns as $pattern) { | |
| 983 | - $files = glob(ABSPATH . $pattern); | |
| 984 | - if ($files) { | |
| 985 | - $sitemap_files = array_merge($sitemap_files, $files); | |
| 986 | - } | |
| 1165 | + // Cleanup on its own used to leave the site with no sitemap at all | |
| 1166 | + // and nothing scheduled to rebuild one: the regeneration that is | |
| 1167 | + // meant to follow lives in the admin bundle, so a bare REST/MCP call | |
| 1168 | + // — or a generate that then hit the rate limit or lost the | |
| 1169 | + // generation lock — published nothing and 404'd indefinitely. Queue | |
| 1170 | + // the rebuild here so the recovery does not depend on the caller. | |
| 1171 | + $regeneration_scheduled = false; | |
| 1172 | + if (!empty($settings['enabled']) && $cleaned_files) { | |
| 1173 | + $this->sitemap_generator->schedule_regeneration(); | |
| 1174 | + $regeneration_scheduled = true; | |
| 987 | 1175 | } |
| 988 | 1176 | |
| 989 | - // Remove duplicates and filter to only sitemap-related files | |
| 990 | - $sitemap_files = array_unique($sitemap_files); | |
| 991 | - | |
| 992 | - foreach ($sitemap_files as $file_path) { | |
| 993 | - $filename = basename($file_path); | |
| 994 | - | |
| 995 | - // Skip if not a sitemap file (additional safety check) | |
| 996 | - if (!$this->is_sitemap_file($filename)) { | |
| 997 | - continue; | |
| 998 | - } | |
| 999 | - | |
| 1000 | - // Only delete if file exists and is in root directory (security) | |
| 1001 | - $file_dir = trailingslashit(dirname($file_path)); | |
| 1002 | - $root_dir = trailingslashit(ABSPATH); | |
| 1003 | - | |
| 1004 | - if (file_exists($file_path) && $file_dir === $root_dir) { | |
| 1005 | - if (wp_delete_file($file_path)) { | |
| 1006 | - $cleaned_files[] = $filename; | |
| 1007 | - } else { | |
| 1008 | - $failed_files[] = $filename; | |
| 1009 | - } | |
| 1010 | - } | |
| 1177 | + // The files are gone, so stop reporting them as generated — | |
| 1178 | + // otherwise the admin keeps offering "View Generated Sitemaps" | |
| 1179 | + // links to files that no longer exist. | |
| 1180 | + if ($cleaned_files) { | |
| 1181 | + $this->clear_generation_record(); | |
| 1011 | 1182 | } |
| 1012 | 1183 | |
| 1013 | 1184 | return new WP_REST_Response([ |
| 1014 | 1185 | 'success' => true, |
| @@ -1014,9 +1185,10 @@ | ||
| 1014 | 1185 | 'success' => true, |
| 1015 | 1186 | 'data' => [ |
| 1016 | 1187 | 'cleaned_files' => $cleaned_files, |
| 1017 | 1188 | 'failed_files' => $failed_files, |
| 1018 | - 'total_cleaned' => count($cleaned_files) | |
| 1189 | + 'total_cleaned' => count($cleaned_files), | |
| 1190 | + 'regeneration_scheduled' => $regeneration_scheduled | |
| 1019 | 1191 | ], |
| 1020 | 1192 | 'message' => sprintf( |
| 1021 | 1193 | 'Cleaned up %d sitemap file(s) successfully', |
| 1022 | 1194 | count($cleaned_files) |
| @@ -1155,9 +1327,9 @@ | ||
| 1155 | 1327 | * @since 1.0.0 |
| 1156 | 1328 | * @return bool True if lock acquired |
| 1157 | 1329 | */ |
| 1158 | 1330 | private function acquire_generation_lock(): bool { |
| 1159 | - $lock_key = 'thinkrank_sitemap_generation_lock'; | |
| 1331 | + $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT; | |
| 1160 | 1332 | |
| 1161 | 1333 | if (get_transient($lock_key)) { |
| 1162 | 1334 | return false; // Generation already in progress |
| 1163 | 1335 | } |
| @@ -1172,60 +1344,7 @@ | ||
| 1172 | 1344 | * @since 1.0.0 |
| 1173 | 1345 | * @return void |
| 1174 | 1346 | */ |
| 1175 | 1347 | private function release_generation_lock(): void { |
| 1176 | - delete_transient('thinkrank_sitemap_generation_lock'); | |
| 1177 | - } | |
| 1178 | - | |
| 1179 | - /** | |
| 1180 | - * Check rate limit for ping requests | |
| 1181 | - * | |
| 1182 | - * @since 1.0.0 | |
| 1183 | - * @return bool True if within rate limit | |
| 1184 | - */ | |
| 1185 | - private function check_ping_rate_limit(): bool { | |
| 1186 | - $user_id = get_current_user_id(); | |
| 1187 | - $rate_key = "thinkrank_ping_rate_{$user_id}"; | |
| 1188 | - | |
| 1189 | - $requests = get_transient($rate_key) ?: 0; | |
| 1190 | - | |
| 1191 | - if ($requests >= 5) { // Max 5 pings per hour | |
| 1192 | - return false; | |
| 1193 | - } | |
| 1194 | - | |
| 1195 | - set_transient($rate_key, $requests + 1, HOUR_IN_SECONDS); | |
| 1196 | - return true; | |
| 1197 | - } | |
| 1198 | - | |
| 1199 | - /** | |
| 1200 | - * Check if a filename is a sitemap file | |
| 1201 | - * | |
| 1202 | - * @since 1.0.0 | |
| 1203 | - * @param string $filename Filename to check | |
| 1204 | - * @return bool True if it's a sitemap file | |
| 1205 | - */ | |
| 1206 | - private function is_sitemap_file(string $filename): bool { | |
| 1207 | - // Must be XML file | |
| 1208 | - if (!str_ends_with($filename, '.xml')) { | |
| 1209 | - return false; | |
| 1210 | - } | |
| 1211 | - | |
| 1212 | - // Must contain 'sitemap' in the name | |
| 1213 | - if (stripos($filename, 'sitemap') === false) { | |
| 1214 | - return false; | |
| 1215 | - } | |
| 1216 | - | |
| 1217 | - // Exclude WordPress core files that aren't sitemaps | |
| 1218 | - $excluded_patterns = [ | |
| 1219 | - 'wp-sitemap-users-', // WordPress user sitemaps | |
| 1220 | - 'wp-sitemap-taxonomies-', // WordPress taxonomy sitemaps | |
| 1221 | - ]; | |
| 1222 | - | |
| 1223 | - foreach ($excluded_patterns as $pattern) { | |
| 1224 | - if (stripos($filename, $pattern) !== false) { | |
| 1225 | - return false; | |
| 1226 | - } | |
| 1227 | - } | |
| 1228 | - | |
| 1229 | - return true; | |
| 1348 | + delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT); | |
| 1230 | 1349 | } |
| 1231 | 1350 | } |