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 +148 -97 1.25.02.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 *
@@ -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,38 @@
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 + /**
258 298 * Persist a manual-generation auto-promotion into the stored settings.
259 299 *
260 300 * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
261 301 * segmented sitemap_urls for the current generation. On the automatic path
@@ -269,26 +309,29 @@
269 309 * @param array $options Options after maybe_promote_to_index().
270 310 * @return void
271 311 */
272 312 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 - }
313 + $saved = $this->sitemap_generator->get_settings('site');
281 314
282 - $saved = $this->sitemap_generator->get_settings('site');
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);
283 325
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)) {
326 + $mode_unchanged = $mode === !empty($saved['use_sitemap_index']);
327 + $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null);
328 +
329 + if ($mode_unchanged && $urls_unchanged) {
287 330 return;
288 331 }
289 332
290 - $saved['use_sitemap_index'] = true;
333 + $saved['use_sitemap_index'] = $mode;
291 334 if (isset($options['sitemap_urls'])) {
292 335 $saved['sitemap_urls'] = $options['sitemap_urls'];
293 336 }
294 337 $this->sitemap_generator->save_settings('site', null, $saved);
@@ -429,9 +472,21 @@
429 472 $filename = 'sitemap.xml';
430 473 if (!empty($options['sitemap_urls'][0]['url'])) {
431 474 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
432 475 }
433 - $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 + }
434 489
435 490 // Regenerate the standalone local business sitemap on the
436 491 // single-sitemap path too (parity with Rank Math).
437 492 $this->sitemap_generator->regenerate_local_sitemap($options);
@@ -640,16 +695,39 @@
640 695 return current_user_can('edit_posts');
641 696 }
642 697
643 698 /**
644 - * Check manage permissions
699 + * Check manage permissions for the state-changing routes.
645 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 + *
646 707 * @since 1.0.0
647 708 *
648 - * @return bool Permission status
709 + * @param WP_REST_Request $request Request object
710 + * @return bool|WP_Error Permission status
649 711 */
650 - public function check_manage_permissions(): bool {
651 - return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
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;
652 730 }
653 731
654 732 /**
655 733 * Save sitemap to file
@@ -836,10 +914,15 @@
836 914 * @return WP_REST_Response|WP_Error Response object or error
837 915 */
838 916 public function get_sitemap_settings(WP_REST_Request $request) {
839 917 try {
840 - $context_type = $request->get_param('context_type') ?? 'site';
841 - $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;
842 925
843 926 // Get settings from Sitemap_Generator
844 927 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
845 928
@@ -847,9 +930,14 @@
847 930 'success' => true,
848 931 'data' => [
849 932 'settings' => $settings,
850 933 'context_type' => $context_type,
851 - '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
852 940 ],
853 941 'message' => 'Sitemap settings retrieved successfully'
854 942 ], 200);
855 943
@@ -872,11 +960,17 @@
872 960 */
873 961 public function update_sitemap_settings(WP_REST_Request $request) {
874 962 try {
875 963 $settings = $request->get_param('settings') ?? [];
876 - $context_type = $request->get_param('context_type') ?? 'site';
877 - $context_id = $request->get_param('context_id') ?? null;
878 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 +
879 973 if (empty($settings)) {
880 974 return new WP_Error(
881 975 'missing_settings',
882 976 'Settings data is required',
@@ -1054,49 +1148,38 @@
1054 1148 * @return WP_REST_Response|WP_Error Response object or error
1055 1149 */
1056 1150 public function cleanup_sitemap_files(WP_REST_Request $request) {
1057 1151 try {
1058 - // Scan filesystem for actual sitemap files instead of relying on configured URLs
1059 - $cleaned_files = [];
1060 - $failed_files = [];
1152 + $settings = $this->sitemap_generator->get_settings('site');
1061 1153
1062 - // Common sitemap file patterns to look for
1063 - $sitemap_patterns = [
1064 - 'sitemap*.xml',
1065 - '*sitemap*.xml'
1066 - ];
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'];
1067 1164
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 - }
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;
1075 1175 }
1076 1176
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 - }
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();
1099 1182 }
1100 1183
1101 1184 return new WP_REST_Response([
1102 1185 'success' => true,
@@ -1102,9 +1185,10 @@
1102 1185 'success' => true,
1103 1186 'data' => [
1104 1187 'cleaned_files' => $cleaned_files,
1105 1188 'failed_files' => $failed_files,
1106 - 'total_cleaned' => count($cleaned_files)
1189 + 'total_cleaned' => count($cleaned_files),
1190 + 'regeneration_scheduled' => $regeneration_scheduled
1107 1191 ],
1108 1192 'message' => sprintf(
1109 1193 'Cleaned up %d sitemap file(s) successfully',
1110 1194 count($cleaned_files)
@@ -1243,9 +1327,9 @@
1243 1327 * @since 1.0.0
1244 1328 * @return bool True if lock acquired
1245 1329 */
1246 1330 private function acquire_generation_lock(): bool {
1247 - $lock_key = 'thinkrank_sitemap_generation_lock';
1331 + $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1248 1332
1249 1333 if (get_transient($lock_key)) {
1250 1334 return false; // Generation already in progress
1251 1335 }
@@ -1260,40 +1344,7 @@
1260 1344 * @since 1.0.0
1261 1345 * @return void
1262 1346 */
1263 1347 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;
1348 + delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1298 1349 }
1299 1350 }