PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 57 releases
← All changes | includes/api/class-sitemap-endpoint.php +243 -99 1.30.0 → 2.14.2 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,12 +264,54 @@
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 /**
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 + /**
258 314 * Persist a manual-generation auto-promotion into the stored settings.
259 315 *
260 316 * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
261 317 * segmented sitemap_urls for the current generation. On the automatic path
@@ -269,26 +325,29 @@
269 325 * @param array $options Options after maybe_promote_to_index().
270 326 * @return void
271 327 */
272 328 private function persist_promoted_mode(array $options): void {
273 - // Only ever persist an auto-promotion (single -> index). Never turn index
274 - // mode OFF here: a bare generate call passes an optional/partial payload
275 - // that may omit use_sitemap_index, and disabling index mode is a settings
276 - // change owned by the settings endpoint — the generate route must not
277 - // clobber a saved index because the toggle happened to be absent.
278 - if (empty($options['use_sitemap_index'])) {
279 - return;
280 - }
329 + $saved = $this->sitemap_generator->get_settings('site');
281 330
282 - $saved = $this->sitemap_generator->get_settings('site');
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);
283 341
284 - // Already in index mode with the same children — nothing to persist.
285 - if (!empty($saved['use_sitemap_index'])
286 - && ($options['sitemap_urls'] ?? null) === ($saved['sitemap_urls'] ?? null)) {
342 + $mode_unchanged = $mode === !empty($saved['use_sitemap_index']);
343 + $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null);
344 +
345 + if ($mode_unchanged && $urls_unchanged) {
287 346 return;
288 347 }
289 348
290 - $saved['use_sitemap_index'] = true;
349 + $saved['use_sitemap_index'] = $mode;
291 350 if (isset($options['sitemap_urls'])) {
292 351 $saved['sitemap_urls'] = $options['sitemap_urls'];
293 352 }
294 353 $this->sitemap_generator->save_settings('site', null, $saved);
@@ -324,8 +383,17 @@
324 383 }
325 384
326 385 $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);
327 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 +
328 396 if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
329 397 return $sitemap_url;
330 398 }
331 399
@@ -394,8 +462,59 @@
394 462 // generate_and_save() already does this on the automatic path; the
395 463 // manual generate route must match it.
396 464 $this->persist_promoted_mode($options);
397 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 +
398 517 // Check if an index (multiple sitemaps) is configured
399 518 if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
400 519 // Generate multiple sitemaps
401 520 $results = $this->sitemap_generator->generate_multiple_sitemaps($options);
@@ -429,9 +548,21 @@
429 548 $filename = 'sitemap.xml';
430 549 if (!empty($options['sitemap_urls'][0]['url'])) {
431 550 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
432 551 }
433 - $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 + }
434 565
435 566 // Regenerate the standalone local business sitemap on the
436 567 // single-sitemap path too (parity with Rank Math).
437 568 $this->sitemap_generator->regenerate_local_sitemap($options);
@@ -478,10 +609,11 @@
478 609 public function validate_sitemap(WP_REST_Request $request) {
479 610 try {
480 611 $sitemap_url = $request->get_param('sitemap_url') ?? home_url('/sitemap.xml');
481 612
482 - // Validate sitemap URL
483 - if (!filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
613 + // Validate sitemap URL. Url_Validator accepts an internationalised
614 + // domain or a non-ASCII path (home_url() on an IDN site is one).
615 + if (!\ThinkRank\Core\Url_Validator::is_valid($sitemap_url)) {
484 616 return new WP_Error(
485 617 'invalid_url',
486 618 'Invalid sitemap URL provided',
487 619 ['status' => 400]
@@ -487,8 +619,14 @@
487 619 ['status' => 400]
488 620 );
489 621 }
490 622
623 + // Everything from here on works on the ASCII form (punycode host,
624 + // percent-encoded path). wp_http_validate_url() resolves the host
625 + // with gethostbyname(), which cannot resolve a Unicode name, and
626 + // the SSRF check must see exactly the URL that is fetched.
627 + $sitemap_url = \ThinkRank\Core\Url_Validator::to_ascii((string) $sitemap_url);
628 +
491 629 // Block SSRF: this endpoint fetches the URL server-side, so reject
492 630 // loopback/link-local/private hosts and non-http(s) schemes via
493 631 // WordPress's own validator (same guard used in class-schema-endpoint).
494 632 if (!wp_http_validate_url($sitemap_url)) {
@@ -640,16 +778,39 @@
640 778 return current_user_can('edit_posts');
641 779 }
642 780
643 781 /**
644 - * Check manage permissions
782 + * Check manage permissions for the state-changing routes.
645 783 *
784 + * Every route using this callback is a POST that writes something —
785 + * /generate, /submit, /ping, /settings, /cleanup — so it is nonce-gated as
786 + * well as capability-gated, matching Schema_Endpoint, Setup_Wizard_Endpoint
787 + * and Email_Report_Endpoint. The class already `use`d CSRF_Protection but
788 + * never called it, leaving this controller the odd one out.
789 + *
646 790 * @since 1.0.0
647 791 *
648 - * @return bool Permission status
792 + * @param WP_REST_Request $request Request object
793 + * @return bool|WP_Error Permission status
649 794 */
650 - public function check_manage_permissions(): bool {
651 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
795 + public function check_manage_permissions(WP_REST_Request $request) {
796 + if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling')) {
797 + return new WP_Error(
798 + 'rest_forbidden',
799 + __('You do not have permission to manage sitemaps.', 'thinkrank'),
800 + ['status' => 403]
801 + );
802 + }
803 +
804 + if (!$this->verify_request_nonce($request)) {
805 + return new WP_Error(
806 + 'rest_forbidden',
807 + __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
808 + ['status' => 403]
809 + );
810 + }
811 +
812 + return true;
652 813 }
653 814
654 815 /**
655 816 * Save sitemap to file
@@ -836,10 +997,15 @@
836 997 * @return WP_REST_Response|WP_Error Response object or error
837 998 */
838 999 public function get_sitemap_settings(WP_REST_Request $request) {
839 1000 try {
840 - $context_type = $request->get_param('context_type') ?? 'site';
841 - $context_id = $request->get_param('context_id') ?? null;
1001 + // SECURITY: the settings are stored per context, so the object has
1002 + // to be authorised before it is read (#385).
1003 + $context = $this->resolve_request_context($request);
1004 + if (is_wp_error($context)) {
1005 + return $context;
1006 + }
1007 + [$context_type, $context_id] = $context;
842 1008
843 1009 // Get settings from Sitemap_Generator
844 1010 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
845 1011
@@ -847,9 +1013,24 @@
847 1013 'success' => true,
848 1014 'data' => [
849 1015 'settings' => $settings,
850 1016 'context_type' => $context_type,
851 - 'context_id' => $context_id
1017 + 'context_id' => $context_id,
1018 + // Kept out of `settings` on purpose: this is generator state,
1019 + // not something the settings POST round-trips.
1020 + 'health' => $context_type === 'site'
1021 + ? $this->sitemap_generator->get_regeneration_health()
1022 + : null,
1023 + // `delivery_mode` in `settings` may still be 'auto', which
1024 + // only the server can resolve (it depends on whether the web
1025 + // root is writable). The admin screen needs the answer, not
1026 + // the question: a dynamic site publishes no file, so gating
1027 + // its sitemap links on `last_generated` — which dynamic
1028 + // delivery deliberately never sets — left every link
1029 + // permanently disabled.
1030 + 'resolved_delivery_mode' => $context_type === 'site'
1031 + ? $this->sitemap_generator->resolve_delivery_mode($settings)
1032 + : null
852 1033 ],
853 1034 'message' => 'Sitemap settings retrieved successfully'
854 1035 ], 200);
855 1036
@@ -872,11 +1053,17 @@
872 1053 */
873 1054 public function update_sitemap_settings(WP_REST_Request $request) {
874 1055 try {
875 1056 $settings = $request->get_param('settings') ?? [];
876 - $context_type = $request->get_param('context_type') ?? 'site';
877 - $context_id = $request->get_param('context_id') ?? null;
878 1057
1058 + // SECURITY: this write is keyed by the context, so the object has to
1059 + // be authorised before anything is persisted (#385).
1060 + $context = $this->resolve_request_context($request);
1061 + if (is_wp_error($context)) {
1062 + return $context;
1063 + }
1064 + [$context_type, $context_id] = $context;
1065 +
879 1066 if (empty($settings)) {
880 1067 return new WP_Error(
881 1068 'missing_settings',
882 1069 'Settings data is required',
@@ -1054,49 +1241,38 @@
1054 1241 * @return WP_REST_Response|WP_Error Response object or error
1055 1242 */
1056 1243 public function cleanup_sitemap_files(WP_REST_Request $request) {
1057 1244 try {
1058 - // Scan filesystem for actual sitemap files instead of relying on configured URLs
1059 - $cleaned_files = [];
1060 - $failed_files = [];
1245 + $settings = $this->sitemap_generator->get_settings('site');
1061 1246
1062 - // Common sitemap file patterns to look for
1063 - $sitemap_patterns = [
1064 - 'sitemap*.xml',
1065 - '*sitemap*.xml'
1066 - ];
1247 + // Delete only the files ThinkRank published. This used to glob
1248 + // ABSPATH for 'sitemap*.xml' and '*sitemap*.xml' and delete anything
1249 + // whose name contained "sitemap", which also swept up a physical
1250 + // core wp-sitemap.xml and any other plugin's sitemap sitting in the
1251 + // web root. delete_published_sitemaps() derives the name list from
1252 + // our own stored sitemap_urls (honouring a custom url pattern) plus
1253 + // the default names, and covers the -N pagination pages.
1254 + $removed = $this->sitemap_generator->delete_published_sitemaps($settings);
1255 + $cleaned_files = $removed['deleted'];
1256 + $failed_files = $removed['failed'];
1067 1257
1068 - // Get all XML files in root directory that match sitemap patterns
1069 - $sitemap_files = [];
1070 - foreach ($sitemap_patterns as $pattern) {
1071 - $files = glob(ABSPATH . $pattern);
1072 - if ($files) {
1073 - $sitemap_files = array_merge($sitemap_files, $files);
1074 - }
1258 + // Cleanup on its own used to leave the site with no sitemap at all
1259 + // and nothing scheduled to rebuild one: the regeneration that is
1260 + // meant to follow lives in the admin bundle, so a bare REST/MCP call
1261 + // — or a generate that then hit the rate limit or lost the
1262 + // generation lock — published nothing and 404'd indefinitely. Queue
1263 + // the rebuild here so the recovery does not depend on the caller.
1264 + $regeneration_scheduled = false;
1265 + if (!empty($settings['enabled']) && $cleaned_files) {
1266 + $this->sitemap_generator->schedule_regeneration();
1267 + $regeneration_scheduled = true;
1075 1268 }
1076 1269
1077 - // Remove duplicates and filter to only sitemap-related files
1078 - $sitemap_files = array_unique($sitemap_files);
1079 -
1080 - foreach ($sitemap_files as $file_path) {
1081 - $filename = basename($file_path);
1082 -
1083 - // Skip if not a sitemap file (additional safety check)
1084 - if (!$this->is_sitemap_file($filename)) {
1085 - continue;
1086 - }
1087 -
1088 - // Only delete if file exists and is in root directory (security)
1089 - $file_dir = trailingslashit(dirname($file_path));
1090 - $root_dir = trailingslashit(ABSPATH);
1091 -
1092 - if (file_exists($file_path) && $file_dir === $root_dir) {
1093 - if (wp_delete_file($file_path)) {
1094 - $cleaned_files[] = $filename;
1095 - } else {
1096 - $failed_files[] = $filename;
1097 - }
1098 - }
1270 + // The files are gone, so stop reporting them as generated —
1271 + // otherwise the admin keeps offering "View Generated Sitemaps"
1272 + // links to files that no longer exist.
1273 + if ($cleaned_files) {
1274 + $this->clear_generation_record();
1099 1275 }
1100 1276
1101 1277 return new WP_REST_Response([
1102 1278 'success' => true,
@@ -1102,9 +1278,10 @@
1102 1278 'success' => true,
1103 1279 'data' => [
1104 1280 'cleaned_files' => $cleaned_files,
1105 1281 'failed_files' => $failed_files,
1106 - 'total_cleaned' => count($cleaned_files)
1282 + 'total_cleaned' => count($cleaned_files),
1283 + 'regeneration_scheduled' => $regeneration_scheduled
1107 1284 ],
1108 1285 'message' => sprintf(
1109 1286 'Cleaned up %d sitemap file(s) successfully',
1110 1287 count($cleaned_files)
@@ -1243,9 +1420,9 @@
1243 1420 * @since 1.0.0
1244 1421 * @return bool True if lock acquired
1245 1422 */
1246 1423 private function acquire_generation_lock(): bool {
1247 - $lock_key = 'thinkrank_sitemap_generation_lock';
1424 + $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1248 1425
1249 1426 if (get_transient($lock_key)) {
1250 1427 return false; // Generation already in progress
1251 1428 }
@@ -1260,40 +1437,7 @@
1260 1437 * @since 1.0.0
1261 1438 * @return void
1262 1439 */
1263 1440 private function release_generation_lock(): void {
1264 - delete_transient('thinkrank_sitemap_generation_lock');
1265 - }
1266 -
1267 - /**
1268 - * Check if a filename is a sitemap file
1269 - *
1270 - * @since 1.0.0
1271 - * @param string $filename Filename to check
1272 - * @return bool True if it's a sitemap file
1273 - */
1274 - private function is_sitemap_file(string $filename): bool {
1275 - // Must be XML file
1276 - if (!str_ends_with($filename, '.xml')) {
1277 - return false;
1278 - }
1279 -
1280 - // Must contain 'sitemap' in the name
1281 - if (stripos($filename, 'sitemap') === false) {
1282 - return false;
1283 - }
1284 -
1285 - // Exclude WordPress core files that aren't sitemaps
1286 - $excluded_patterns = [
1287 - 'wp-sitemap-users-', // WordPress user sitemaps
1288 - 'wp-sitemap-taxonomies-', // WordPress taxonomy sitemaps
1289 - ];
1290 -
1291 - foreach ($excluded_patterns as $pattern) {
1292 - if (stripos($filename, $pattern) !== false) {
1293 - return false;
1294 - }
1295 - }
1296 -
1297 - return true;
1441 + delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1298 1442 }
1299 1443 }