| @@ -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 | } |