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