PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-sitemap-endpoint.php +333 -214 1.0.22.7.0 View file →
@@ -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 }