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 +170 -13 1.32.0 → 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 *
@@ -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'],
@@ -250,8 +264,14 @@
250 264 $timestamp = gmdate('c');
251 265 $settings = $this->sitemap_generator->get_settings('site');
252 266 $settings['last_generated'] = $timestamp;
253 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 +
254 274 return $timestamp;
255 275 }
256 276
257 277 /**
@@ -274,8 +294,24 @@
274 294 $this->sitemap_generator->save_settings('site', null, $settings);
275 295 }
276 296
277 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 + /**
278 314 * Persist a manual-generation auto-promotion into the stored settings.
279 315 *
280 316 * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
281 317 * segmented sitemap_urls for the current generation. On the automatic path
@@ -347,8 +383,17 @@
347 383 }
348 384
349 385 $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);
350 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 +
351 396 if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
352 397 return $sitemap_url;
353 398 }
354 399
@@ -417,8 +462,59 @@
417 462 // generate_and_save() already does this on the automatic path; the
418 463 // manual generate route must match it.
419 464 $this->persist_promoted_mode($options);
420 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 +
421 517 // Check if an index (multiple sitemaps) is configured
422 518 if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
423 519 // Generate multiple sitemaps
424 520 $results = $this->sitemap_generator->generate_multiple_sitemaps($options);
@@ -452,9 +548,21 @@
452 548 $filename = 'sitemap.xml';
453 549 if (!empty($options['sitemap_urls'][0]['url'])) {
454 550 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
455 551 }
456 - $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 + }
457 565
458 566 // Regenerate the standalone local business sitemap on the
459 567 // single-sitemap path too (parity with Rank Math).
460 568 $this->sitemap_generator->regenerate_local_sitemap($options);
@@ -663,16 +771,39 @@
663 771 return current_user_can('edit_posts');
664 772 }
665 773
666 774 /**
667 - * Check manage permissions
775 + * Check manage permissions for the state-changing routes.
668 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 + *
669 783 * @since 1.0.0
670 784 *
671 - * @return bool Permission status
785 + * @param WP_REST_Request $request Request object
786 + * @return bool|WP_Error Permission status
672 787 */
673 - public function check_manage_permissions(): bool {
674 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
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;
675 806 }
676 807
677 808 /**
678 809 * Save sitemap to file
@@ -859,10 +990,15 @@
859 990 * @return WP_REST_Response|WP_Error Response object or error
860 991 */
861 992 public function get_sitemap_settings(WP_REST_Request $request) {
862 993 try {
863 - $context_type = $request->get_param('context_type') ?? 'site';
864 - $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;
865 1001
866 1002 // Get settings from Sitemap_Generator
867 1003 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
868 1004
@@ -870,9 +1006,24 @@
870 1006 'success' => true,
871 1007 'data' => [
872 1008 'settings' => $settings,
873 1009 'context_type' => $context_type,
874 - '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
875 1026 ],
876 1027 'message' => 'Sitemap settings retrieved successfully'
877 1028 ], 200);
878 1029
@@ -895,11 +1046,17 @@
895 1046 */
896 1047 public function update_sitemap_settings(WP_REST_Request $request) {
897 1048 try {
898 1049 $settings = $request->get_param('settings') ?? [];
899 - $context_type = $request->get_param('context_type') ?? 'site';
900 - $context_id = $request->get_param('context_id') ?? null;
901 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 +
902 1059 if (empty($settings)) {
903 1060 return new WP_Error(
904 1061 'missing_settings',
905 1062 'Settings data is required',
@@ -1256,9 +1413,9 @@
1256 1413 * @since 1.0.0
1257 1414 * @return bool True if lock acquired
1258 1415 */
1259 1416 private function acquire_generation_lock(): bool {
1260 - $lock_key = 'thinkrank_sitemap_generation_lock';
1417 + $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1261 1418
1262 1419 if (get_transient($lock_key)) {
1263 1420 return false; // Generation already in progress
1264 1421 }
@@ -1273,7 +1430,7 @@
1273 1430 * @since 1.0.0
1274 1431 * @return void
1275 1432 */
1276 1433 private function release_generation_lock(): void {
1277 - delete_transient('thinkrank_sitemap_generation_lock');
1434 + delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1278 1435 }
1279 1436 }