PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 releases
thinkrank / includes / api / class-sitemap-endpoint.php

class-sitemap-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/api/class-sitemap-endpoint.php

1,351 lines 47.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitemap API Endpoints Class
4 *
5 * REST API endpoints for XML sitemap management including generation,
6 * validation, status monitoring, and search engine submission with
7 * proper authentication and comprehensive error handling.
8 *
9 * @package ThinkRank
10 * @subpackage API
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\API;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 use ThinkRank\SEO\Sitemap_Generator;
24 use ThinkRank\API\Traits\CSRF_Protection;
25 use ThinkRank\API\Traits\Context_Authorization;
26 use WP_REST_Controller;
27 use WP_REST_Request;
28 use WP_REST_Response;
29 use WP_Error;
30
31 // Prevent direct access
32 if (!defined('ABSPATH')) {
33 exit;
34 }
35
36 // Load CSRF Protection trait
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';
39
40 /**
41 * Sitemap API Endpoints Class
42 *
43 * Provides REST API endpoints for sitemap operations including
44 * XML generation, validation, status monitoring, and search engine
45 * submission with proper authentication and validation.
46 *
47 * @since 1.0.0
48 */
49 class Sitemap_Endpoint extends WP_REST_Controller {
50 use CSRF_Protection;
51 use Context_Authorization;
52
53 /**
54 * Sitemap Generator instance
55 *
56 * @since 1.0.0
57 * @var Sitemap_Generator
58 */
59 private Sitemap_Generator $sitemap_generator;
60
61 /**
62 * API namespace
63 *
64 * @since 1.0.0
65 * @var string
66 */
67 protected $namespace = 'thinkrank/v1';
68
69 /**
70 * API resource base
71 *
72 * @since 1.0.0
73 * @var string
74 */
75 protected $rest_base = 'sitemap';
76
77 /**
78 * Constructor
79 *
80 * @since 1.0.0
81 */
82 public function __construct() {
83 $this->sitemap_generator = new Sitemap_Generator();
84 }
85
86 /**
87 * Register API routes
88 *
89 * @since 1.0.0
90 */
91 public function register_routes(): void {
92 // Generate XML sitemap
93 register_rest_route(
94 $this->namespace,
95 '/' . $this->rest_base . '/generate',
96 [
97 [
98 'methods' => 'POST',
99 'callback' => [$this, 'generate_sitemap'],
100 'permission_callback' => [$this, 'check_manage_permissions'],
101 'args' => $this->get_generate_args()
102 ]
103 ]
104 );
105
106 // Validate sitemap (read-only operation, no CSRF needed)
107 register_rest_route(
108 $this->namespace,
109 '/' . $this->rest_base . '/validate',
110 [
111 [
112 'methods' => 'POST',
113 'callback' => [$this, 'validate_sitemap'],
114 'permission_callback' => [$this, 'check_read_permissions'],
115 'args' => $this->get_validate_args()
116 ]
117 ]
118 );
119
120 // Get sitemap status
121 register_rest_route(
122 $this->namespace,
123 '/' . $this->rest_base . '/status',
124 [
125 [
126 'methods' => 'GET',
127 'callback' => [$this, 'get_sitemap_status'],
128 'permission_callback' => [$this, 'check_read_permissions']
129 ]
130 ]
131 );
132
133 // Submit sitemap to search engines
134 register_rest_route(
135 $this->namespace,
136 '/' . $this->rest_base . '/submit',
137 [
138 [
139 'methods' => 'POST',
140 'callback' => [$this, 'submit_sitemap'],
141 'permission_callback' => [$this, 'check_manage_permissions'],
142 'args' => $this->get_submit_args()
143 ]
144 ]
145 );
146
147 // Ping search engines (unified endpoint for manual ping button)
148 register_rest_route(
149 $this->namespace,
150 '/' . $this->rest_base . '/ping',
151 [
152 [
153 'methods' => 'POST',
154 'callback' => [$this, 'ping_search_engines'],
155 'permission_callback' => [$this, 'check_manage_permissions']
156 ]
157 ]
158 );
159
160 // Get sitemap statistics
161 register_rest_route(
162 $this->namespace,
163 '/' . $this->rest_base . '/stats',
164 [
165 [
166 'methods' => 'GET',
167 'callback' => [$this, 'get_sitemap_stats'],
168 'permission_callback' => [$this, 'check_read_permissions']
169 ]
170 ]
171 );
172
173 // Sitemap settings management (following Site Identity pattern)
174 register_rest_route(
175 $this->namespace,
176 '/' . $this->rest_base . '/settings',
177 [
178 [
179 'methods' => 'GET',
180 'callback' => [$this, 'get_sitemap_settings'],
181 'permission_callback' => [$this, 'check_read_permissions'],
182 'args' => $this->get_context_route_args()
183 ],
184 [
185 'methods' => 'POST',
186 'callback' => [$this, 'update_sitemap_settings'],
187 'permission_callback' => [$this, 'check_manage_permissions'],
188 'args' => $this->get_settings_args()
189 ]
190 ]
191 );
192
193 // Get custom post types
194 register_rest_route(
195 $this->namespace,
196 '/' . $this->rest_base . '/custom-post-types',
197 [
198 [
199 'methods' => 'GET',
200 'callback' => [$this, 'get_custom_post_types'],
201 'permission_callback' => [$this, 'check_read_permissions']
202 ]
203 ]
204 );
205
206 // Get sitemap URLs for robots.txt integration
207 register_rest_route(
208 $this->namespace,
209 '/' . $this->rest_base . '/robots-urls',
210 [
211 [
212 'methods' => 'GET',
213 'callback' => [$this, 'get_robots_sitemap_urls'],
214 'permission_callback' => [$this, 'check_read_permissions']
215 ]
216 ]
217 );
218
219 // Get WooCommerce status
220 register_rest_route(
221 $this->namespace,
222 '/' . $this->rest_base . '/woocommerce-status',
223 [
224 [
225 'methods' => 'GET',
226 'callback' => [$this, 'get_woocommerce_status'],
227 'permission_callback' => [$this, 'check_read_permissions']
228 ]
229 ]
230 );
231
232 // Cleanup old sitemap files
233 register_rest_route(
234 $this->namespace,
235 '/' . $this->rest_base . '/cleanup',
236 [
237 [
238 'methods' => 'POST',
239 'callback' => [$this, 'cleanup_sitemap_files'],
240 'permission_callback' => [$this, 'check_manage_permissions'],
241 'args' => [
242 'sitemap_urls' => [
243 'required' => false,
244 'type' => 'array',
245 'description' => 'Optional array of specific sitemap URLs to clean up. If not provided, scans filesystem automatically.'
246 ]
247 ]
248 ]
249 ]
250 );
251 }
252
253 /**
254 * Record that a sitemap generation just ran.
255 *
256 * Persists the `last_generated` timestamp so the admin UI can distinguish
257 * generated sitemaps (whose files now exist on disk) from ones that are
258 * merely configured — the "View Generated Sitemaps" links stay disabled
259 * until this is set.
260 *
261 * @return string ISO-8601 timestamp stored as the `last_generated` setting.
262 */
263 private function record_generation(): string {
264 $timestamp = gmdate('c');
265 $settings = $this->sitemap_generator->get_settings('site');
266 $settings['last_generated'] = $timestamp;
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
274 return $timestamp;
275 }
276
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 * Persist a manual-generation auto-promotion into the stored settings.
299 *
300 * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
301 * segmented sitemap_urls for the current generation. On the automatic path
302 * generate_and_save() saves that resolved state; the manual generate route
303 * must do the same, or the next content-/settings-triggered regeneration
304 * (which reads stored settings) reverts the site to a single flat file.
305 *
306 * Only the two mode-defining keys are merged, so this partial generate
307 * payload never clobbers unrelated saved settings.
308 *
309 * @param array $options Options after maybe_promote_to_index().
310 * @return void
311 */
312 private function persist_promoted_mode(array $options): void {
313 $saved = $this->sitemap_generator->get_settings('site');
314
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);
325
326 $mode_unchanged = $mode === !empty($saved['use_sitemap_index']);
327 $urls_unchanged = $urls === ($saved['sitemap_urls'] ?? null);
328
329 if ($mode_unchanged && $urls_unchanged) {
330 return;
331 }
332
333 $saved['use_sitemap_index'] = $mode;
334 if (isset($options['sitemap_urls'])) {
335 $saved['sitemap_urls'] = $options['sitemap_urls'];
336 }
337 $this->sitemap_generator->save_settings('site', null, $saved);
338 }
339
340 /**
341 * Write the sitemap files when the site has none yet.
342 *
343 * The sitemap is served as a static file in the web root, so a site whose
344 * sitemap is enabled but never generated serves nothing at /sitemap.xml —
345 * WordPress core then claims that URL and redirects to wp-sitemap.xml.
346 * Turning the sitemap on therefore has to produce the file, which is what
347 * the Setup Wizard's "Save & Continue" relies on for its "View Sitemap"
348 * link. Only fills the gap: an existing file is left to the explicit
349 * "Generate" action so saving settings stays cheap on large sites.
350 *
351 * @since 1.17.0
352 * @param string $context_type Settings context type.
353 * @param int|null $context_id Settings context id.
354 * @return string Sitemap URL, or an empty string when nothing is published.
355 */
356 private function ensure_sitemap_file(string $context_type, ?int $context_id, bool &$generated_now = false): string {
357 $generated_now = false;
358
359 if ($context_type !== 'site') {
360 return '';
361 }
362
363 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
364
365 if (empty($settings['enabled'])) {
366 return '';
367 }
368
369 $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);
370
371 if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
372 return $sitemap_url;
373 }
374
375 // Never fail the settings save over generation: the settings are already
376 // persisted, and content changes or a manual Generate will retry.
377 try {
378 if (!$this->sitemap_generator->generate_and_save($settings)) {
379 return '';
380 }
381 $generated_now = true;
382 } catch (\Throwable $e) {
383 return '';
384 }
385
386 return $sitemap_url;
387 }
388
389 /**
390 * Generate XML sitemap
391 *
392 * @since 1.0.0
393 *
394 * @param WP_REST_Request $request Request object
395 * @return WP_REST_Response|WP_Error Response object or error
396 */
397 public function generate_sitemap(WP_REST_Request $request) {
398 try {
399 // Rate limiting: Max 3 generations per 5 minutes per user
400 if (!$this->check_rate_limit()) {
401 return new WP_Error(
402 'rate_limit_exceeded',
403 'Too many sitemap generation requests. Please wait before trying again.',
404 ['status' => 429]
405 );
406 }
407
408 // Concurrent generation protection
409 if (!$this->acquire_generation_lock()) {
410 return new WP_Error(
411 'generation_in_progress',
412 'Sitemap generation is already in progress. Please wait.',
413 ['status' => 409]
414 );
415 }
416
417 $options = $request->get_param('options') ?? [];
418 if (!is_array($options)) {
419 $options = [];
420 }
421 // sitemap_urls must be an array wherever it is counted/iterated below
422 // (and in the generator); drop a wrong-typed value so a malformed
423 // request yields normal output instead of an uncaught TypeError.
424 if (isset($options['sitemap_urls']) && !is_array($options['sitemap_urls'])) {
425 unset($options['sitemap_urls']);
426 }
427
428 // Resolve index-vs-single mode from the use_sitemap_index toggle
429 // (synthesizing child sitemaps when the toggle is on but none are
430 // configured, and auto-promoting an oversized single file), rather
431 // than deciding purely by how many sitemap_urls happen to be present.
432 $options = $this->sitemap_generator->maybe_promote_to_index($options);
433
434 // Persist the resolved index-mode decision so a later content- or
435 // settings-triggered regeneration (which reads stored settings)
436 // doesn't revert a manual auto-promotion back to a single flat file.
437 // generate_and_save() already does this on the automatic path; the
438 // manual generate route must match it.
439 $this->persist_promoted_mode($options);
440
441 // Check if an index (multiple sitemaps) is configured
442 if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
443 // Generate multiple sitemaps
444 $results = $this->sitemap_generator->generate_multiple_sitemaps($options);
445
446 if (!$results['success']) {
447 return new WP_Error(
448 'sitemap_generation_failed',
449 'Failed to generate sitemaps: ' . implode(', ', $results['errors']),
450 ['status' => 500]
451 );
452 }
453
454 return new WP_REST_Response([
455 'success' => true,
456 'message' => 'Multiple sitemaps generated successfully',
457 'data' => [
458 'sitemaps_generated' => $results['sitemaps_generated'],
459 'total_sitemaps' => count($results['sitemaps_generated']),
460 'url_count' => $results['total_urls'],
461 'last_generated' => $this->record_generation()
462 ]
463 ]);
464 } else {
465 // Generate single sitemap (backward compatibility)
466 $sitemap_xml = $this->sitemap_generator->generate_sitemap($options);
467
468 // Save sitemap to file (optional)
469 $save_to_file = $request->get_param('save_to_file') ?? true;
470 $last_generated = '';
471 if ($save_to_file) {
472 $filename = 'sitemap.xml';
473 if (!empty($options['sitemap_urls'][0]['url'])) {
474 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
475 }
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 }
489
490 // Regenerate the standalone local business sitemap on the
491 // single-sitemap path too (parity with Rank Math).
492 $this->sitemap_generator->regenerate_local_sitemap($options);
493
494 // Only record generation when the files were actually
495 // written — a preview (save_to_file=false) must not enable
496 // the "View Generated Sitemaps" links.
497 $last_generated = $this->record_generation();
498 }
499
500 return new WP_REST_Response([
501 'success' => true,
502 'data' => [
503 'sitemap_xml' => $sitemap_xml,
504 'sitemap_url' => home_url('/sitemap.xml'),
505 'generated_at' => gmdate('c'),
506 'url_count' => $this->count_urls_in_xml($sitemap_xml),
507 'last_generated' => $last_generated
508 ],
509 'message' => 'Sitemap generated successfully'
510 ]);
511 }
512
513 } catch (\Exception $e) {
514 $this->release_generation_lock();
515 return new WP_Error(
516 'generation_failed',
517 'Sitemap generation failed: ' . $e->getMessage(),
518 ['status' => 500]
519 );
520 } finally {
521 $this->release_generation_lock();
522 }
523 }
524
525 /**
526 * Validate sitemap
527 *
528 * @since 1.0.0
529 *
530 * @param WP_REST_Request $request Request object
531 * @return WP_REST_Response|WP_Error Response object or error
532 */
533 public function validate_sitemap(WP_REST_Request $request) {
534 try {
535 $sitemap_url = $request->get_param('sitemap_url') ?? home_url('/sitemap.xml');
536
537 // Validate sitemap URL
538 if (!filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
539 return new WP_Error(
540 'invalid_url',
541 'Invalid sitemap URL provided',
542 ['status' => 400]
543 );
544 }
545
546 // Block SSRF: this endpoint fetches the URL server-side, so reject
547 // loopback/link-local/private hosts and non-http(s) schemes via
548 // WordPress's own validator (same guard used in class-schema-endpoint).
549 if (!wp_http_validate_url($sitemap_url)) {
550 return new WP_Error(
551 'invalid_url',
552 'The sitemap URL is not allowed.',
553 ['status' => 400]
554 );
555 }
556
557 // Perform validation
558 $validation_result = $this->perform_sitemap_validation($sitemap_url);
559
560 return new WP_REST_Response([
561 'success' => true,
562 'data' => $validation_result,
563 'message' => 'Sitemap validation completed'
564 ], 200);
565
566 } catch (\Exception $e) {
567 return new WP_Error(
568 'validation_failed',
569 'Sitemap validation failed: ' . $e->getMessage(),
570 ['status' => 500]
571 );
572 }
573 }
574
575 /**
576 * Get sitemap status
577 *
578 * @since 1.0.0
579 *
580 * @param WP_REST_Request $request Request object
581 * @return WP_REST_Response|WP_Error Response object or error
582 */
583 public function get_sitemap_status(WP_REST_Request $request) {
584 try {
585 // Get sitemap output data from generator
586 $status_data = $this->sitemap_generator->get_output_data('site', null);
587
588 // Add additional status information
589 $sitemap_file_path = ABSPATH . 'sitemap.xml';
590 $status_data['file_exists'] = file_exists($sitemap_file_path);
591 $status_data['file_size'] = $status_data['file_exists'] ? filesize($sitemap_file_path) : 0;
592 $status_data['file_modified'] = $status_data['file_exists'] ? gmdate('c', filemtime($sitemap_file_path)) : null;
593
594 return new WP_REST_Response([
595 'success' => true,
596 'data' => $status_data,
597 'message' => 'Sitemap status retrieved successfully'
598 ], 200);
599
600 } catch (\Exception $e) {
601 return new WP_Error(
602 'status_failed',
603 'Failed to get sitemap status: ' . $e->getMessage(),
604 ['status' => 500]
605 );
606 }
607 }
608
609 /**
610 * Submit sitemap to search engines
611 *
612 * @since 1.0.0
613 *
614 * @param WP_REST_Request $request Request object
615 * @return WP_REST_Response|WP_Error Response object or error
616 */
617 public function submit_sitemap(WP_REST_Request $request) {
618 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
619 // both now discover sitemaps via robots.txt on their own schedule. There
620 // is nothing to submit, so this is a no-op kept only so existing clients
621 // don't 404 (mirrors ping_search_engines()).
622 return new WP_REST_Response([
623 'success' => true,
624 'data' => [],
625 'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.',
626 ], 200);
627 }
628
629 /**
630 * Ping search engines about sitemap updates (unified method)
631 *
632 * @since 1.0.0
633 *
634 * @param WP_REST_Request $request Request object
635 * @return WP_REST_Response|WP_Error Response object or error
636 */
637 public function ping_search_engines(WP_REST_Request $request) {
638 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
639 // both now rely on the sitemap being referenced from robots.txt and pulled
640 // on their own schedule. There is nothing left to ping, so this endpoint is
641 // a no-op kept only so existing clients don't 404.
642 return new WP_REST_Response([
643 'success' => true,
644 'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.',
645 'engines' => [],
646 'timestamp' => gmdate('c')
647 ], 200);
648 }
649
650 /**
651 * Get sitemap statistics
652 *
653 * @since 1.0.0
654 *
655 * @param WP_REST_Request $request Request object
656 * @return WP_REST_Response|WP_Error Response object or error
657 */
658 public function get_sitemap_stats(WP_REST_Request $request) {
659 try {
660 $settings = $this->sitemap_generator->get_settings('site');
661
662 $stats = [
663 'total_urls' => $this->sitemap_generator->count_sitemap_urls($settings),
664 'post_count' => $settings['include_posts'] ? wp_count_posts('post')->publish : 0,
665 'page_count' => $settings['include_pages'] ? wp_count_posts('page')->publish : 0,
666 'category_count' => $settings['include_categories'] ? wp_count_terms('category') : 0,
667 'tag_count' => $settings['include_tags'] ? wp_count_terms('post_tag') : 0,
668 'last_generated' => $settings['last_generated'] ?? null,
669 'sitemap_enabled' => $settings['enabled'] ?? true
670 ];
671
672 return new WP_REST_Response([
673 'success' => true,
674 'data' => $stats,
675 'message' => 'Sitemap statistics retrieved successfully'
676 ], 200);
677
678 } catch (\Throwable $e) {
679 return new WP_Error(
680 'stats_failed',
681 'Failed to get sitemap statistics: ' . $e->getMessage(),
682 ['status' => 500]
683 );
684 }
685 }
686
687 /**
688 * Check read permissions
689 *
690 * @since 1.0.0
691 *
692 * @return bool Permission status
693 */
694 public function check_read_permissions(): bool {
695 return current_user_can('edit_posts');
696 }
697
698 /**
699 * Check manage permissions for the state-changing routes.
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 *
707 * @since 1.0.0
708 *
709 * @param WP_REST_Request $request Request object
710 * @return bool|WP_Error Permission status
711 */
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;
730 }
731
732 /**
733 * Save sitemap to file
734 *
735 * @since 1.0.0
736 *
737 * @param string $sitemap_xml Sitemap XML content
738 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
739 * @return bool Success status
740 */
741 private function save_sitemap_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
742 // Clean filename and ensure it ends with .xml
743 $filename = sanitize_file_name($filename);
744 if (!str_ends_with($filename, '.xml')) {
745 $filename .= '.xml';
746 }
747
748 $sitemap_file_path = ABSPATH . $filename;
749
750 // Use WordPress filesystem API
751 global $wp_filesystem;
752 if (empty($wp_filesystem)) {
753 require_once ABSPATH . '/wp-admin/includes/file.php';
754 WP_Filesystem();
755 }
756
757 return $wp_filesystem->put_contents($sitemap_file_path, $sitemap_xml, FS_CHMOD_FILE);
758 }
759
760 /**
761 * Count URLs in sitemap XML content
762 *
763 * @since 1.0.0
764 *
765 * @param string $sitemap_xml Sitemap XML content
766 * @return int URL count
767 */
768 private function count_urls_in_xml(string $sitemap_xml): int {
769 return substr_count($sitemap_xml, '<url>');
770 }
771
772 /**
773 * Perform sitemap validation
774 *
775 * @since 1.0.0
776 *
777 * @param string $sitemap_url Sitemap URL to validate
778 * @return array Validation results
779 */
780 private function perform_sitemap_validation(string $sitemap_url): array {
781 $validation_result = [
782 'valid' => true,
783 'errors' => [],
784 'warnings' => [],
785 'url_count' => 0,
786 'file_size' => 0
787 ];
788
789 // Check if sitemap is accessible. wp_safe_remote_get() re-applies the
790 // reject-unsafe-URLs / external-host filters (incl. on redirects) so an
791 // internal host can't be reached even if it slipped past validation.
792 $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]);
793
794 if (is_wp_error($response)) {
795 $validation_result['valid'] = false;
796 $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message();
797 return $validation_result;
798 }
799
800 $status_code = wp_remote_retrieve_response_code($response);
801 if ($status_code !== 200) {
802 $validation_result['valid'] = false;
803 $validation_result['errors'][] = "Sitemap returned HTTP status code: {$status_code}";
804 return $validation_result;
805 }
806
807 $sitemap_content = wp_remote_retrieve_body($response);
808 $validation_result['file_size'] = strlen($sitemap_content);
809 $validation_result['url_count'] = $this->count_urls_in_xml($sitemap_content);
810
811 // Basic XML validation
812 libxml_use_internal_errors(true);
813 $xml = simplexml_load_string($sitemap_content);
814
815 if (false === $xml) {
816 $validation_result['valid'] = false;
817 $validation_result['errors'][] = 'Invalid XML format';
818
819 foreach (libxml_get_errors() as $error) {
820 $validation_result['errors'][] = trim($error->message);
821 }
822 }
823
824 // Check file size (should be under 50MB)
825 if ($validation_result['file_size'] > 50 * 1024 * 1024) {
826 $validation_result['warnings'][] = 'Sitemap file size exceeds 50MB limit';
827 }
828
829 // Check URL count (should be under 50,000)
830 if ($validation_result['url_count'] > 50000) {
831 $validation_result['warnings'][] = 'Sitemap contains more than 50,000 URLs';
832 }
833
834 return $validation_result;
835 }
836
837 /**
838 * Get arguments for generate endpoint
839 *
840 * @since 1.0.0
841 *
842 * @return array Arguments array
843 */
844 private function get_generate_args(): array {
845 return [
846 'options' => [
847 'required' => false,
848 'type' => 'object',
849 'description' => 'Sitemap generation options'
850 ],
851 'save_to_file' => [
852 'required' => false,
853 'type' => 'boolean',
854 'default' => true,
855 'description' => 'Save sitemap to file'
856 ]
857 ];
858 }
859
860 /**
861 * Get arguments for validate endpoint
862 *
863 * @since 1.0.0
864 *
865 * @return array Arguments array
866 */
867 private function get_validate_args(): array {
868 return [
869 'sitemap_url' => [
870 'required' => false,
871 'type' => 'string',
872 'format' => 'uri',
873 'default' => home_url('/sitemap.xml'),
874 'description' => 'Sitemap URL to validate'
875 ]
876 ];
877 }
878
879 /**
880 * Get arguments for submit endpoint
881 *
882 * @since 1.0.0
883 *
884 * @return array Arguments array
885 */
886 private function get_submit_args(): array {
887 return [
888 'search_engines' => [
889 'required' => false,
890 'type' => 'array',
891 'items' => [
892 'type' => 'string',
893 'enum' => ['google', 'bing']
894 ],
895 'default' => ['google', 'bing'],
896 'description' => 'Search engines to submit to'
897 ],
898 'sitemap_url' => [
899 'required' => false,
900 'type' => 'string',
901 'format' => 'uri',
902 'default' => home_url('/sitemap.xml'),
903 'description' => 'Sitemap URL to submit'
904 ]
905 ];
906 }
907
908 /**
909 * Get sitemap settings
910 *
911 * @since 1.0.0
912 *
913 * @param WP_REST_Request $request Request object
914 * @return WP_REST_Response|WP_Error Response object or error
915 */
916 public function get_sitemap_settings(WP_REST_Request $request) {
917 try {
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;
925
926 // Get settings from Sitemap_Generator
927 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
928
929 return new WP_REST_Response([
930 'success' => true,
931 'data' => [
932 'settings' => $settings,
933 'context_type' => $context_type,
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
940 ],
941 'message' => 'Sitemap settings retrieved successfully'
942 ], 200);
943
944 } catch (\Exception $e) {
945 return new WP_Error(
946 'settings_retrieval_failed',
947 'Failed to retrieve sitemap settings: ' . $e->getMessage(),
948 ['status' => 500]
949 );
950 }
951 }
952
953 /**
954 * Update sitemap settings
955 *
956 * @since 1.0.0
957 *
958 * @param WP_REST_Request $request Request object
959 * @return WP_REST_Response|WP_Error Response object or error
960 */
961 public function update_sitemap_settings(WP_REST_Request $request) {
962 try {
963 $settings = $request->get_param('settings') ?? [];
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
973 if (empty($settings)) {
974 return new WP_Error(
975 'missing_settings',
976 'Settings data is required',
977 ['status' => 400]
978 );
979 }
980
981 // Save settings using Sitemap_Generator
982 $success = $this->sitemap_generator->save_settings($context_type, $context_id, $settings);
983
984 if (!$success) {
985 return new WP_Error(
986 'settings_save_failed',
987 'Failed to save sitemap settings',
988 ['status' => 500]
989 );
990 }
991
992 $generated_now = false;
993 $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now);
994
995 // Rebuild the served sitemap so inclusion-rule changes take effect
996 // instead of waiting for a content edit (debounced against rapid
997 // successive saves). Skip when ensure_sitemap_file() just built a
998 // fresh file synchronously — otherwise we'd immediately schedule a
999 // second full generation of the same content.
1000 if (!$generated_now) {
1001 $this->sitemap_generator->schedule_regeneration();
1002 }
1003
1004 return new WP_REST_Response([
1005 'success' => true,
1006 'data' => [
1007 'settings' => $settings,
1008 'context_type' => $context_type,
1009 'context_id' => $context_id,
1010 'sitemap_url' => $sitemap_url
1011 ],
1012 'message' => 'Sitemap settings saved successfully'
1013 ], 200);
1014
1015 } catch (\Exception $e) {
1016 return new WP_Error(
1017 'settings_update_failed',
1018 'Failed to update sitemap settings: ' . $e->getMessage(),
1019 ['status' => 500]
1020 );
1021 }
1022 }
1023
1024 /**
1025 * Get arguments for settings endpoints
1026 *
1027 * @since 1.0.0
1028 *
1029 * @return array Arguments array
1030 */
1031 private function get_settings_args(): array {
1032 return [
1033 'settings' => [
1034 'required' => true,
1035 'type' => 'object',
1036 'description' => 'Sitemap settings to save'
1037 ],
1038 'context_type' => [
1039 'required' => false,
1040 'type' => 'string',
1041 'default' => 'site',
1042 'description' => 'Context type for settings'
1043 ],
1044 'context_id' => [
1045 'required' => false,
1046 'type' => 'integer',
1047 'description' => 'Context ID for settings'
1048 ]
1049 ];
1050 }
1051
1052 /**
1053 * Get custom post types for sitemap generation
1054 *
1055 * @since 1.0.0
1056 *
1057 * @param WP_REST_Request $request Request object
1058 * @return WP_REST_Response|WP_Error Response object or error
1059 */
1060 public function get_custom_post_types(WP_REST_Request $request) {
1061 try {
1062 // Get all public custom post types (excluding built-in types)
1063 $post_types = get_post_types([
1064 'public' => true,
1065 '_builtin' => false
1066 ], 'objects');
1067
1068 $custom_post_types = [];
1069 foreach ($post_types as $post_type) {
1070 // Skip if it's a WooCommerce product (handled separately)
1071 if ($post_type->name === 'product') {
1072 continue;
1073 }
1074
1075 $custom_post_types[] = [
1076 'name' => $post_type->name,
1077 'label' => $post_type->label,
1078 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
1079 'public' => $post_type->public,
1080 'has_archive' => $post_type->has_archive,
1081 'count' => wp_count_posts($post_type->name)->publish ?? 0
1082 ];
1083 }
1084
1085 return new WP_REST_Response([
1086 'success' => true,
1087 'data' => $custom_post_types,
1088 'message' => 'Custom post types retrieved successfully'
1089 ], 200);
1090
1091 } catch (\Exception $e) {
1092 return new WP_Error(
1093 'custom_post_types_failed',
1094 'Failed to get custom post types: ' . $e->getMessage(),
1095 ['status' => 500]
1096 );
1097 }
1098 }
1099
1100 /**
1101 * Get WooCommerce status for sitemap generation
1102 *
1103 * @since 1.0.0
1104 *
1105 * @param WP_REST_Request $request Request object
1106 * @return WP_REST_Response|WP_Error Response object or error
1107 */
1108 public function get_woocommerce_status(WP_REST_Request $request) {
1109 try {
1110 // Check if WooCommerce is active
1111 $is_woocommerce_active = class_exists('WooCommerce') && function_exists('WC');
1112
1113 // Check if product post type exists
1114 $product_post_type_exists = post_type_exists('product');
1115
1116 // Check if product category taxonomy exists
1117 $product_cat_taxonomy_exists = taxonomy_exists('product_cat');
1118
1119 $status = [
1120 'is_active' => $is_woocommerce_active,
1121 'product_post_type_exists' => $product_post_type_exists,
1122 'product_cat_taxonomy_exists' => $product_cat_taxonomy_exists,
1123 'product_count' => $product_post_type_exists ? wp_count_posts('product')->publish ?? 0 : 0,
1124 'product_category_count' => $product_cat_taxonomy_exists ? wp_count_terms('product_cat') : 0
1125 ];
1126
1127 return new WP_REST_Response([
1128 'success' => true,
1129 'data' => $status,
1130 'message' => 'WooCommerce status retrieved successfully'
1131 ], 200);
1132
1133 } catch (\Exception $e) {
1134 return new WP_Error(
1135 'woocommerce_status_failed',
1136 'Failed to get WooCommerce status: ' . $e->getMessage(),
1137 ['status' => 500]
1138 );
1139 }
1140 }
1141
1142 /**
1143 * Clean up old sitemap files
1144 *
1145 * @since 1.0.0
1146 *
1147 * @param WP_REST_Request $request Request object
1148 * @return WP_REST_Response|WP_Error Response object or error
1149 */
1150 public function cleanup_sitemap_files(WP_REST_Request $request) {
1151 try {
1152 $settings = $this->sitemap_generator->get_settings('site');
1153
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'];
1164
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;
1175 }
1176
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();
1182 }
1183
1184 return new WP_REST_Response([
1185 'success' => true,
1186 'data' => [
1187 'cleaned_files' => $cleaned_files,
1188 'failed_files' => $failed_files,
1189 'total_cleaned' => count($cleaned_files),
1190 'regeneration_scheduled' => $regeneration_scheduled
1191 ],
1192 'message' => sprintf(
1193 'Cleaned up %d sitemap file(s) successfully',
1194 count($cleaned_files)
1195 )
1196 ], 200);
1197
1198 } catch (\Exception $e) {
1199 return new WP_Error(
1200 'cleanup_failed',
1201 'Failed to clean up sitemap files: ' . $e->getMessage(),
1202 ['status' => 500]
1203 );
1204 }
1205 }
1206
1207 /**
1208 * Get sitemap URLs for robots.txt integration
1209 *
1210 * Returns enabled sitemap URLs from sitemap settings for automatic
1211 * inclusion in robots.txt file. This eliminates the need for manual
1212 * sitemap URL configuration in robots.txt settings.
1213 *
1214 * @since 1.0.0
1215 *
1216 * @param WP_REST_Request $request Request object
1217 * @return WP_REST_Response Response object
1218 */
1219 public function get_robots_sitemap_urls(WP_REST_Request $request): WP_REST_Response {
1220 try {
1221 // Get sitemap settings
1222 $settings = $this->sitemap_generator->get_settings('site');
1223
1224 // If sitemap is disabled, return empty array
1225 if (empty($settings['enabled'])) {
1226 return new WP_REST_Response([
1227 'success' => true,
1228 'data' => [
1229 'sitemap_urls' => [],
1230 'enabled' => false,
1231 'message' => __('Sitemap generation is disabled', 'thinkrank')
1232 ]
1233 ], 200);
1234 }
1235
1236 // Extract enabled sitemap URLs
1237 $sitemap_urls = [];
1238 $site_url = home_url();
1239
1240 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
1241 foreach ($settings['sitemap_urls'] as $sitemap) {
1242 if (!empty($sitemap['enabled']) && !empty($sitemap['url'])) {
1243 $sitemap_urls[] = [
1244 'url' => $sitemap['url'],
1245 'full_url' => $site_url . $sitemap['url'],
1246 'type' => $sitemap['type'] ?? 'general',
1247 'type_label' => $this->get_sitemap_type_label($sitemap['type'] ?? 'general')
1248 ];
1249 }
1250 }
1251 }
1252
1253 // Fallback to default sitemap if no URLs configured
1254 if (empty($sitemap_urls)) {
1255 $sitemap_urls[] = [
1256 'url' => '/sitemap.xml',
1257 'full_url' => $site_url . '/sitemap.xml',
1258 'type' => 'general',
1259 'type_label' => __('General', 'thinkrank')
1260 ];
1261 }
1262
1263 return new WP_REST_Response([
1264 'success' => true,
1265 'data' => [
1266 'sitemap_urls' => $sitemap_urls,
1267 'enabled' => true,
1268 'count' => count($sitemap_urls)
1269 ]
1270 ], 200);
1271
1272 } catch (\Exception $e) {
1273 return new WP_REST_Response([
1274 'success' => false,
1275 'error' => 'Failed to retrieve sitemap URLs: ' . $e->getMessage()
1276 ], 500);
1277 }
1278 }
1279
1280 /**
1281 * Get human-readable label for sitemap type
1282 *
1283 * @since 1.0.0
1284 *
1285 * @param string $type Sitemap type
1286 * @return string Human-readable label
1287 */
1288 private function get_sitemap_type_label(string $type): string {
1289 $labels = [
1290 'index' => __('Index', 'thinkrank'),
1291 'general' => __('General', 'thinkrank'),
1292 'posts' => __('Posts', 'thinkrank'),
1293 'pages' => __('Pages', 'thinkrank'),
1294 'categories' => __('Categories', 'thinkrank'),
1295 'tags' => __('Tags', 'thinkrank'),
1296 'products' => __('Products', 'thinkrank'),
1297 'wordpress' => __('WordPress Core', 'thinkrank'),
1298 'custom' => __('Custom', 'thinkrank')
1299 ];
1300
1301 return $labels[$type] ?? ucfirst($type);
1302 }
1303
1304 /**
1305 * Check rate limit for sitemap generation
1306 *
1307 * @since 1.0.0
1308 * @return bool True if within rate limit
1309 */
1310 private function check_rate_limit(): bool {
1311 $user_id = get_current_user_id();
1312 $rate_key = "thinkrank_sitemap_rate_{$user_id}";
1313
1314 $requests = get_transient($rate_key) ?: 0;
1315
1316 if ($requests >= 3) { // Max 3 requests per 5 minutes
1317 return false;
1318 }
1319
1320 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1321 return true;
1322 }
1323
1324 /**
1325 * Acquire generation lock to prevent concurrent generation
1326 *
1327 * @since 1.0.0
1328 * @return bool True if lock acquired
1329 */
1330 private function acquire_generation_lock(): bool {
1331 $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1332
1333 if (get_transient($lock_key)) {
1334 return false; // Generation already in progress
1335 }
1336
1337 set_transient($lock_key, time(), 5 * MINUTE_IN_SECONDS);
1338 return true;
1339 }
1340
1341 /**
1342 * Release generation lock
1343 *
1344 * @since 1.0.0
1345 * @return void
1346 */
1347 private function release_generation_lock(): void {
1348 delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1349 }
1350 }
1351