PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-sitemap-endpoint.php +419 -214 1.0.1 → 2.10.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,169 @@
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 + * Stored sitemap settings with this request's options laid over them.
299 + *
300 + * The generate payload is partial — the admin screen posts the sitemap
301 + * shape, not the whole settings record — so reading `delivery_mode` straight
302 + * off it would resolve to `auto` on every ordinary request and defeat an
303 + * explicit choice. Merging keeps a mode sent in the payload authoritative
304 + * while falling back to what is saved.
305 + *
306 + * @param array $options Request options.
307 + * @return array Effective settings for this generation.
308 + */
309 + private function effective_settings(array $options): array {
310 + return array_merge($this->sitemap_generator->get_settings('site'), $options);
311 + }
312 +
313 + /**
314 + * Persist a manual-generation auto-promotion into the stored settings.
315 + *
316 + * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
317 + * segmented sitemap_urls for the current generation. On the automatic path
318 + * generate_and_save() saves that resolved state; the manual generate route
319 + * must do the same, or the next content-/settings-triggered regeneration
320 + * (which reads stored settings) reverts the site to a single flat file.
321 + *
322 + * Only the two mode-defining keys are merged, so this partial generate
323 + * payload never clobbers unrelated saved settings.
324 + *
325 + * @param array $options Options after maybe_promote_to_index().
326 + * @return void
327 + */
328 + private function persist_promoted_mode(array $options): void {
329 + $saved = $this->sitemap_generator->get_settings('site');
330 +
331 + // Record the mode that was actually written, in both directions, so the
332 + // stored settings and the files on disk cannot disagree. Persisting a
333 + // demotion used to be unsafe because an absent use_sitemap_index was
334 + // indistinguishable from an explicit "off", and treating it as off would
335 + // clobber a saved index whenever the toggle merely happened to be
336 + // missing. maybe_promote_to_index() now resolves an absent key from the
337 + // saved settings before this runs, so whatever arrives here is the
338 + // resolved decision rather than a gap in the payload.
339 + $mode = !empty($options['use_sitemap_index']);
340 + $urls = $options['sitemap_urls'] ?? ($saved['sitemap_urls'] ?? null);
341 +
342 + $mode_unchanged = $mode === !empty($saved['use_sitemap_index']);
343 + $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null);
344 +
345 + if ($mode_unchanged && $urls_unchanged) {
346 + return;
347 + }
348 +
349 + $saved['use_sitemap_index'] = $mode;
350 + if (isset($options['sitemap_urls'])) {
351 + $saved['sitemap_urls'] = $options['sitemap_urls'];
352 + }
353 + $this->sitemap_generator->save_settings('site', null, $saved);
354 + }
355 +
356 + /**
357 + * Write the sitemap files when the site has none yet.
358 + *
359 + * The sitemap is served as a static file in the web root, so a site whose
360 + * sitemap is enabled but never generated serves nothing at /sitemap.xml —
361 + * WordPress core then claims that URL and redirects to wp-sitemap.xml.
362 + * Turning the sitemap on therefore has to produce the file, which is what
363 + * the Setup Wizard's "Save & Continue" relies on for its "View Sitemap"
364 + * link. Only fills the gap: an existing file is left to the explicit
365 + * "Generate" action so saving settings stays cheap on large sites.
366 + *
367 + * @since 1.17.0
368 + * @param string $context_type Settings context type.
369 + * @param int|null $context_id Settings context id.
370 + * @return string Sitemap URL, or an empty string when nothing is published.
371 + */
372 + private function ensure_sitemap_file(string $context_type, ?int $context_id, bool &$generated_now = false): string {
373 + $generated_now = false;
374 +
375 + if ($context_type !== 'site') {
376 + return '';
377 + }
378 +
379 + $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
380 +
381 + if (empty($settings['enabled'])) {
382 + return '';
383 + }
384 +
385 + $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);
386 +
387 + // Dynamic delivery publishes no file, so there is nothing to ensure and
388 + // nothing to look for on disk. Dropping the rendered documents is what
389 + // makes the saved settings take effect on the next request (#752).
390 + if ('dynamic' === $this->sitemap_generator->resolve_delivery_mode($settings)) {
391 + $this->sitemap_generator->flush_dynamic_cache();
392 +
393 + return $sitemap_url;
394 + }
395 +
396 + if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
397 + return $sitemap_url;
398 + }
399 +
400 + // Never fail the settings save over generation: the settings are already
401 + // persisted, and content changes or a manual Generate will retry.
402 + try {
403 + if (!$this->sitemap_generator->generate_and_save($settings)) {
404 + return '';
405 + }
406 + $generated_now = true;
407 + } catch (\Throwable $e) {
408 + return '';
409 + }
410 +
411 + return $sitemap_url;
412 + }
413 +
414 + /**
240 415 * Generate XML sitemap
241 416 *
242 417 * @since 1.0.0
243 418 *
@@ -264,11 +439,84 @@
264 439 );
265 440 }
266 441
267 442 $options = $request->get_param('options') ?? [];
443 + if (!is_array($options)) {
444 + $options = [];
445 + }
446 + // sitemap_urls must be an array wherever it is counted/iterated below
447 + // (and in the generator); drop a wrong-typed value so a malformed
448 + // request yields normal output instead of an uncaught TypeError.
449 + if (isset($options['sitemap_urls']) && !is_array($options['sitemap_urls'])) {
450 + unset($options['sitemap_urls']);
451 + }
268 452
269 - // Check if multiple sitemaps are configured
270 - if (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1) {
453 + // Resolve index-vs-single mode from the use_sitemap_index toggle
454 + // (synthesizing child sitemaps when the toggle is on but none are
455 + // configured, and auto-promoting an oversized single file), rather
456 + // than deciding purely by how many sitemap_urls happen to be present.
457 + $options = $this->sitemap_generator->maybe_promote_to_index($options);
458 +
459 + // Persist the resolved index-mode decision so a later content- or
460 + // settings-triggered regeneration (which reads stored settings)
461 + // doesn't revert a manual auto-promotion back to a single flat file.
462 + // generate_and_save() already does this on the automatic path; the
463 + // manual generate route must match it.
464 + $this->persist_promoted_mode($options);
465 +
466 + // Dynamic delivery answers the sitemap URLs from PHP, so there is
467 + // nothing to write. Every other sitemap write path already returns
468 + // early here (class-sitemap-generator.php:2189 and :2313); this one
469 + // did not, which broke the feature from both directions: on a
470 + // read-only root — the case dynamic delivery exists for — the button
471 + // reported 500 "Failed to save sitemap: sitemap.xml" while the URL
472 + // was serving correctly, and on a writable root with dynamic chosen
473 + // explicitly it wrote files the web server then served in place of
474 + // the dynamic route.
475 + //
476 + // Regenerating here means dropping the rendered documents so the
477 + // next request rebuilds them, and clearing any file left behind by
478 + // an earlier static generation for the same reason.
479 + if ('dynamic' === $this->sitemap_generator->resolve_delivery_mode($this->effective_settings($options))) {
480 + $this->sitemap_generator->flush_dynamic_cache();
481 +
482 + $removal = $this->sitemap_generator->delete_published_sitemaps();
483 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
484 +
485 + // A stale file shadows the dynamic route, so this is a real
486 + // failure rather than a tidy-up that did not matter. Worded by
487 + // the generator so this and its own rebuild paths cannot
488 + // describe the same stuck files differently (#764).
489 + if (!empty($stuck)) {
490 + return new WP_Error(
491 + 'sitemap_stale_files',
492 + $this->sitemap_generator->stuck_files_message($stuck),
493 + ['status' => 500]
494 + );
495 + }
496 +
497 + // last_generated deliberately stays untouched: it means "these
498 + // files are on disk", and primary_sitemap_file_exists() callers
499 + // rely on that. clear_generation_record() drops a value left
500 + // over from a previous static generation, so the admin stops
501 + // linking to files that no longer exist.
502 + $this->clear_generation_record();
503 + $this->sitemap_generator->mark_regeneration_complete();
504 +
505 + return new WP_REST_Response([
506 + 'success' => true,
507 + 'data' => [
508 + 'delivery_mode' => 'dynamic',
509 + 'sitemap_url' => $this->sitemap_generator->get_primary_sitemap_url(),
510 + 'generated_at' => gmdate('c'),
511 + 'last_generated' => '',
512 + ],
513 + 'message' => __('Sitemap refreshed. WordPress serves it directly, so no files were written.', 'thinkrank'),
514 + ]);
515 + }
516 +
517 + // Check if an index (multiple sitemaps) is configured
518 + if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
271 519 // Generate multiple sitemaps
272 520 $results = $this->sitemap_generator->generate_multiple_sitemaps($options);
273 521
274 522 if (!$results['success']) {
@@ -284,9 +532,10 @@
284 532 'message' => 'Multiple sitemaps generated successfully',
285 533 'data' => [
286 534 'sitemaps_generated' => $results['sitemaps_generated'],
287 535 'total_sitemaps' => count($results['sitemaps_generated']),
288 - 'url_count' => $results['total_urls']
536 + 'url_count' => $results['total_urls'],
537 + 'last_generated' => $this->record_generation()
289 538 ]
290 539 ]);
291 540 } else {
292 541 // Generate single sitemap (backward compatibility)
@@ -293,14 +542,36 @@
293 542 $sitemap_xml = $this->sitemap_generator->generate_sitemap($options);
294 543
295 544 // Save sitemap to file (optional)
296 545 $save_to_file = $request->get_param('save_to_file') ?? true;
546 + $last_generated = '';
297 547 if ($save_to_file) {
298 548 $filename = 'sitemap.xml';
299 549 if (!empty($options['sitemap_urls'][0]['url'])) {
300 550 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
301 551 }
302 - $this->save_sitemap_file($sitemap_xml, $filename);
552 + // A failed write has to surface here the way the index
553 + // branch surfaces one. Discarding it let record_generation()
554 + // advance last_generated and clear the pending marker and
555 + // the recorded failure, so an unwritable site root — the
556 + // exact case this endpoint reports health for — came back
557 + // as a healthy "Generated successfully".
558 + if (!$this->save_sitemap_file($sitemap_xml, $filename)) {
559 + return new WP_Error(
560 + 'sitemap_generation_failed',
561 + 'Failed to save sitemap: ' . $filename,
562 + ['status' => 500]
563 + );
564 + }
565 +
566 + // Regenerate the standalone local business sitemap on the
567 + // single-sitemap path too (parity with Rank Math).
568 + $this->sitemap_generator->regenerate_local_sitemap($options);
569 +
570 + // Only record generation when the files were actually
571 + // written — a preview (save_to_file=false) must not enable
572 + // the "View Generated Sitemaps" links.
573 + $last_generated = $this->record_generation();
303 574 }
304 575
305 576 return new WP_REST_Response([
306 577 'success' => true,
@@ -307,9 +578,10 @@
307 578 'data' => [
308 579 'sitemap_xml' => $sitemap_xml,
309 580 'sitemap_url' => home_url('/sitemap.xml'),
310 581 'generated_at' => gmdate('c'),
311 - 'url_count' => $this->count_urls_in_xml($sitemap_xml)
582 + 'url_count' => $this->count_urls_in_xml($sitemap_xml),
583 + 'last_generated' => $last_generated
312 584 ],
313 585 'message' => 'Sitemap generated successfully'
314 586 ]);
315 587 }
@@ -346,8 +618,19 @@
346 618 ['status' => 400]
347 619 );
348 620 }
349 621
622 + // Block SSRF: this endpoint fetches the URL server-side, so reject
623 + // loopback/link-local/private hosts and non-http(s) schemes via
624 + // WordPress's own validator (same guard used in class-schema-endpoint).
625 + if (!wp_http_validate_url($sitemap_url)) {
626 + return new WP_Error(
627 + 'invalid_url',
628 + 'The sitemap URL is not allowed.',
629 + ['status' => 400]
630 + );
631 + }
632 +
350 633 // Perform validation
351 634 $validation_result = $this->perform_sitemap_validation($sitemap_url);
352 635
353 636 return new WP_REST_Response([
@@ -407,31 +690,17 @@
407 690 * @param WP_REST_Request $request Request object
408 691 * @return WP_REST_Response|WP_Error Response object or error
409 692 */
410 693 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 - }
694 + // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
695 + // both now discover sitemaps via robots.txt on their own schedule. There
696 + // is nothing to submit, so this is a no-op kept only so existing clients
697 + // don't 404 (mirrors ping_search_engines()).
698 + return new WP_REST_Response([
699 + 'success' => true,
700 + 'data' => [],
701 + 'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.',
702 + ], 200);
434 703 }
435 704
436 705 /**
437 706 * Ping search engines about sitemap updates (unified method)
@@ -441,39 +710,18 @@
441 710 * @param WP_REST_Request $request Request object
442 711 * @return WP_REST_Response|WP_Error Response object or error
443 712 */
444 713 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 - }
714 + // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
715 + // both now rely on the sitemap being referenced from robots.txt and pulled
716 + // on their own schedule. There is nothing left to ping, so this endpoint is
717 + // a no-op kept only so existing clients don't 404.
718 + return new WP_REST_Response([
719 + 'success' => true,
720 + 'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.',
721 + 'engines' => [],
722 + 'timestamp' => gmdate('c')
723 + ], 200);
476 724 }
477 725
478 726 /**
479 727 * Get sitemap statistics
@@ -502,9 +750,9 @@
502 750 'data' => $stats,
503 751 'message' => 'Sitemap statistics retrieved successfully'
504 752 ], 200);
505 753
506 - } catch (\Exception $e) {
754 + } catch (\Throwable $e) {
507 755 return new WP_Error(
508 756 'stats_failed',
509 757 'Failed to get sitemap statistics: ' . $e->getMessage(),
510 758 ['status' => 500]
@@ -523,16 +771,39 @@
523 771 return current_user_can('edit_posts');
524 772 }
525 773
526 774 /**
527 - * Check manage permissions
775 + * Check manage permissions for the state-changing routes.
528 776 *
777 + * Every route using this callback is a POST that writes something —
778 + * /generate, /submit, /ping, /settings, /cleanup — so it is nonce-gated as
779 + * well as capability-gated, matching Schema_Endpoint, Setup_Wizard_Endpoint
780 + * and Email_Report_Endpoint. The class already `use`d CSRF_Protection but
781 + * never called it, leaving this controller the odd one out.
782 + *
529 783 * @since 1.0.0
530 784 *
531 - * @return bool Permission status
785 + * @param WP_REST_Request $request Request object
786 + * @return bool|WP_Error Permission status
532 787 */
533 - public function check_manage_permissions(): bool {
534 - return current_user_can('manage_options');
788 + public function check_manage_permissions(WP_REST_Request $request) {
789 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling')) {
790 + return new WP_Error(
791 + 'rest_forbidden',
792 + __('You do not have permission to manage sitemaps.', 'thinkrank'),
793 + ['status' => 403]
794 + );
795 + }
796 +
797 + if (!$this->verify_request_nonce($request)) {
798 + return new WP_Error(
799 + 'rest_forbidden',
800 + __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
801 + ['status' => 403]
802 + );
803 + }
804 +
805 + return true;
535 806 }
536 807
537 808 /**
538 809 * Save sitemap to file
@@ -590,10 +861,12 @@
590 861 'url_count' => 0,
591 862 'file_size' => 0
592 863 ];
593 864
594 - // Check if sitemap is accessible
595 - $response = wp_remote_get($sitemap_url, ['timeout' => 30]);
865 + // Check if sitemap is accessible. wp_safe_remote_get() re-applies the
866 + // reject-unsafe-URLs / external-host filters (incl. on redirects) so an
867 + // internal host can't be reached even if it slipped past validation.
868 + $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]);
596 869
597 870 if (is_wp_error($response)) {
598 871 $validation_result['valid'] = false;
599 872 $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message();
@@ -637,52 +910,8 @@
637 910 return $validation_result;
638 911 }
639 912
640 913 /**
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 914 * Get arguments for generate endpoint
686 915 *
687 916 * @since 1.0.0
688 917 *
@@ -761,10 +990,15 @@
761 990 * @return WP_REST_Response|WP_Error Response object or error
762 991 */
763 992 public function get_sitemap_settings(WP_REST_Request $request) {
764 993 try {
765 - $context_type = $request->get_param('context_type') ?? 'site';
766 - $context_id = $request->get_param('context_id') ?? null;
994 + // SECURITY: the settings are stored per context, so the object has
995 + // to be authorised before it is read (#385).
996 + $context = $this->resolve_request_context($request);
997 + if (is_wp_error($context)) {
998 + return $context;
999 + }
1000 + [$context_type, $context_id] = $context;
767 1001
768 1002 // Get settings from Sitemap_Generator
769 1003 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
770 1004
@@ -772,9 +1006,24 @@
772 1006 'success' => true,
773 1007 'data' => [
774 1008 'settings' => $settings,
775 1009 'context_type' => $context_type,
776 - 'context_id' => $context_id
1010 + 'context_id' => $context_id,
1011 + // Kept out of `settings` on purpose: this is generator state,
1012 + // not something the settings POST round-trips.
1013 + 'health' => $context_type === 'site'
1014 + ? $this->sitemap_generator->get_regeneration_health()
1015 + : null,
1016 + // `delivery_mode` in `settings` may still be 'auto', which
1017 + // only the server can resolve (it depends on whether the web
1018 + // root is writable). The admin screen needs the answer, not
1019 + // the question: a dynamic site publishes no file, so gating
1020 + // its sitemap links on `last_generated` — which dynamic
1021 + // delivery deliberately never sets — left every link
1022 + // permanently disabled.
1023 + 'resolved_delivery_mode' => $context_type === 'site'
1024 + ? $this->sitemap_generator->resolve_delivery_mode($settings)
1025 + : null
777 1026 ],
778 1027 'message' => 'Sitemap settings retrieved successfully'
779 1028 ], 200);
780 1029
@@ -797,11 +1046,17 @@
797 1046 */
798 1047 public function update_sitemap_settings(WP_REST_Request $request) {
799 1048 try {
800 1049 $settings = $request->get_param('settings') ?? [];
801 - $context_type = $request->get_param('context_type') ?? 'site';
802 - $context_id = $request->get_param('context_id') ?? null;
803 1050
1051 + // SECURITY: this write is keyed by the context, so the object has to
1052 + // be authorised before anything is persisted (#385).
1053 + $context = $this->resolve_request_context($request);
1054 + if (is_wp_error($context)) {
1055 + return $context;
1056 + }
1057 + [$context_type, $context_id] = $context;
1058 +
804 1059 if (empty($settings)) {
805 1060 return new WP_Error(
806 1061 'missing_settings',
807 1062 'Settings data is required',
@@ -819,14 +1074,27 @@
819 1074 ['status' => 500]
820 1075 );
821 1076 }
822 1077
1078 + $generated_now = false;
1079 + $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now);
1080 +
1081 + // Rebuild the served sitemap so inclusion-rule changes take effect
1082 + // instead of waiting for a content edit (debounced against rapid
1083 + // successive saves). Skip when ensure_sitemap_file() just built a
1084 + // fresh file synchronously — otherwise we'd immediately schedule a
1085 + // second full generation of the same content.
1086 + if (!$generated_now) {
1087 + $this->sitemap_generator->schedule_regeneration();
1088 + }
1089 +
823 1090 return new WP_REST_Response([
824 1091 'success' => true,
825 1092 'data' => [
826 1093 'settings' => $settings,
827 1094 'context_type' => $context_type,
828 - 'context_id' => $context_id
1095 + 'context_id' => $context_id,
1096 + 'sitemap_url' => $sitemap_url
829 1097 ],
830 1098 'message' => 'Sitemap settings saved successfully'
831 1099 ], 200);
832 1100
@@ -966,49 +1234,38 @@
966 1234 * @return WP_REST_Response|WP_Error Response object or error
967 1235 */
968 1236 public function cleanup_sitemap_files(WP_REST_Request $request) {
969 1237 try {
970 - // Scan filesystem for actual sitemap files instead of relying on configured URLs
971 - $cleaned_files = [];
972 - $failed_files = [];
1238 + $settings = $this->sitemap_generator->get_settings('site');
973 1239
974 - // Common sitemap file patterns to look for
975 - $sitemap_patterns = [
976 - 'sitemap*.xml',
977 - '*sitemap*.xml'
978 - ];
1240 + // Delete only the files ThinkRank published. This used to glob
1241 + // ABSPATH for 'sitemap*.xml' and '*sitemap*.xml' and delete anything
1242 + // whose name contained "sitemap", which also swept up a physical
1243 + // core wp-sitemap.xml and any other plugin's sitemap sitting in the
1244 + // web root. delete_published_sitemaps() derives the name list from
1245 + // our own stored sitemap_urls (honouring a custom url pattern) plus
1246 + // the default names, and covers the -N pagination pages.
1247 + $removed = $this->sitemap_generator->delete_published_sitemaps($settings);
1248 + $cleaned_files = $removed['deleted'];
1249 + $failed_files = $removed['failed'];
979 1250
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 - }
1251 + // Cleanup on its own used to leave the site with no sitemap at all
1252 + // and nothing scheduled to rebuild one: the regeneration that is
1253 + // meant to follow lives in the admin bundle, so a bare REST/MCP call
1254 + // — or a generate that then hit the rate limit or lost the
1255 + // generation lock — published nothing and 404'd indefinitely. Queue
1256 + // the rebuild here so the recovery does not depend on the caller.
1257 + $regeneration_scheduled = false;
1258 + if (!empty($settings['enabled']) && $cleaned_files) {
1259 + $this->sitemap_generator->schedule_regeneration();
1260 + $regeneration_scheduled = true;
987 1261 }
988 1262
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 - }
1263 + // The files are gone, so stop reporting them as generated —
1264 + // otherwise the admin keeps offering "View Generated Sitemaps"
1265 + // links to files that no longer exist.
1266 + if ($cleaned_files) {
1267 + $this->clear_generation_record();
1011 1268 }
1012 1269
1013 1270 return new WP_REST_Response([
1014 1271 'success' => true,
@@ -1014,9 +1271,10 @@
1014 1271 'success' => true,
1015 1272 'data' => [
1016 1273 'cleaned_files' => $cleaned_files,
1017 1274 'failed_files' => $failed_files,
1018 - 'total_cleaned' => count($cleaned_files)
1275 + 'total_cleaned' => count($cleaned_files),
1276 + 'regeneration_scheduled' => $regeneration_scheduled
1019 1277 ],
1020 1278 'message' => sprintf(
1021 1279 'Cleaned up %d sitemap file(s) successfully',
1022 1280 count($cleaned_files)
@@ -1155,9 +1413,9 @@
1155 1413 * @since 1.0.0
1156 1414 * @return bool True if lock acquired
1157 1415 */
1158 1416 private function acquire_generation_lock(): bool {
1159 - $lock_key = 'thinkrank_sitemap_generation_lock';
1417 + $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1160 1418
1161 1419 if (get_transient($lock_key)) {
1162 1420 return false; // Generation already in progress
1163 1421 }
@@ -1172,60 +1430,7 @@
1172 1430 * @since 1.0.0
1173 1431 * @return void
1174 1432 */
1175 1433 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;
1434 + delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1230 1435 }
1231 1436 }