PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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
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 2.0.0, at includes/api/class-sitemap-endpoint.php

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