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

1,300 lines 44.5 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 * Persist a manual-generation auto-promotion into the stored settings.
259 *
260 * maybe_promote_to_index() may flip use_sitemap_index on and synthesize the
261 * segmented sitemap_urls for the current generation. On the automatic path
262 * generate_and_save() saves that resolved state; the manual generate route
263 * must do the same, or the next content-/settings-triggered regeneration
264 * (which reads stored settings) reverts the site to a single flat file.
265 *
266 * Only the two mode-defining keys are merged, so this partial generate
267 * payload never clobbers unrelated saved settings.
268 *
269 * @param array $options Options after maybe_promote_to_index().
270 * @return void
271 */
272 private function persist_promoted_mode(array $options): void {
273 // Only ever persist an auto-promotion (single -> index). Never turn index
274 // mode OFF here: a bare generate call passes an optional/partial payload
275 // that may omit use_sitemap_index, and disabling index mode is a settings
276 // change owned by the settings endpoint — the generate route must not
277 // clobber a saved index because the toggle happened to be absent.
278 if (empty($options['use_sitemap_index'])) {
279 return;
280 }
281
282 $saved = $this->sitemap_generator->get_settings('site');
283
284 // Already in index mode with the same children — nothing to persist.
285 if (!empty($saved['use_sitemap_index'])
286 && ($options['sitemap_urls'] ?? null) === ($saved['sitemap_urls'] ?? null)) {
287 return;
288 }
289
290 $saved['use_sitemap_index'] = true;
291 if (isset($options['sitemap_urls'])) {
292 $saved['sitemap_urls'] = $options['sitemap_urls'];
293 }
294 $this->sitemap_generator->save_settings('site', null, $saved);
295 }
296
297 /**
298 * Write the sitemap files when the site has none yet.
299 *
300 * The sitemap is served as a static file in the web root, so a site whose
301 * sitemap is enabled but never generated serves nothing at /sitemap.xml —
302 * WordPress core then claims that URL and redirects to wp-sitemap.xml.
303 * Turning the sitemap on therefore has to produce the file, which is what
304 * the Setup Wizard's "Save & Continue" relies on for its "View Sitemap"
305 * link. Only fills the gap: an existing file is left to the explicit
306 * "Generate" action so saving settings stays cheap on large sites.
307 *
308 * @since 1.17.0
309 * @param string $context_type Settings context type.
310 * @param int|null $context_id Settings context id.
311 * @return string Sitemap URL, or an empty string when nothing is published.
312 */
313 private function ensure_sitemap_file(string $context_type, ?int $context_id, bool &$generated_now = false): string {
314 $generated_now = false;
315
316 if ($context_type !== 'site') {
317 return '';
318 }
319
320 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
321
322 if (empty($settings['enabled'])) {
323 return '';
324 }
325
326 $sitemap_url = $this->sitemap_generator->get_primary_sitemap_url($settings);
327
328 if ($this->sitemap_generator->primary_sitemap_file_exists($settings)) {
329 return $sitemap_url;
330 }
331
332 // Never fail the settings save over generation: the settings are already
333 // persisted, and content changes or a manual Generate will retry.
334 try {
335 if (!$this->sitemap_generator->generate_and_save($settings)) {
336 return '';
337 }
338 $generated_now = true;
339 } catch (\Throwable $e) {
340 return '';
341 }
342
343 return $sitemap_url;
344 }
345
346 /**
347 * Generate XML sitemap
348 *
349 * @since 1.0.0
350 *
351 * @param WP_REST_Request $request Request object
352 * @return WP_REST_Response|WP_Error Response object or error
353 */
354 public function generate_sitemap(WP_REST_Request $request) {
355 try {
356 // Rate limiting: Max 3 generations per 5 minutes per user
357 if (!$this->check_rate_limit()) {
358 return new WP_Error(
359 'rate_limit_exceeded',
360 'Too many sitemap generation requests. Please wait before trying again.',
361 ['status' => 429]
362 );
363 }
364
365 // Concurrent generation protection
366 if (!$this->acquire_generation_lock()) {
367 return new WP_Error(
368 'generation_in_progress',
369 'Sitemap generation is already in progress. Please wait.',
370 ['status' => 409]
371 );
372 }
373
374 $options = $request->get_param('options') ?? [];
375 if (!is_array($options)) {
376 $options = [];
377 }
378 // sitemap_urls must be an array wherever it is counted/iterated below
379 // (and in the generator); drop a wrong-typed value so a malformed
380 // request yields normal output instead of an uncaught TypeError.
381 if (isset($options['sitemap_urls']) && !is_array($options['sitemap_urls'])) {
382 unset($options['sitemap_urls']);
383 }
384
385 // Resolve index-vs-single mode from the use_sitemap_index toggle
386 // (synthesizing child sitemaps when the toggle is on but none are
387 // configured, and auto-promoting an oversized single file), rather
388 // than deciding purely by how many sitemap_urls happen to be present.
389 $options = $this->sitemap_generator->maybe_promote_to_index($options);
390
391 // Persist the resolved index-mode decision so a later content- or
392 // settings-triggered regeneration (which reads stored settings)
393 // doesn't revert a manual auto-promotion back to a single flat file.
394 // generate_and_save() already does this on the automatic path; the
395 // manual generate route must match it.
396 $this->persist_promoted_mode($options);
397
398 // Check if an index (multiple sitemaps) is configured
399 if (!empty($options['use_sitemap_index']) || (!empty($options['sitemap_urls']) && count($options['sitemap_urls']) > 1)) {
400 // Generate multiple sitemaps
401 $results = $this->sitemap_generator->generate_multiple_sitemaps($options);
402
403 if (!$results['success']) {
404 return new WP_Error(
405 'sitemap_generation_failed',
406 'Failed to generate sitemaps: ' . implode(', ', $results['errors']),
407 ['status' => 500]
408 );
409 }
410
411 return new WP_REST_Response([
412 'success' => true,
413 'message' => 'Multiple sitemaps generated successfully',
414 'data' => [
415 'sitemaps_generated' => $results['sitemaps_generated'],
416 'total_sitemaps' => count($results['sitemaps_generated']),
417 'url_count' => $results['total_urls'],
418 'last_generated' => $this->record_generation()
419 ]
420 ]);
421 } else {
422 // Generate single sitemap (backward compatibility)
423 $sitemap_xml = $this->sitemap_generator->generate_sitemap($options);
424
425 // Save sitemap to file (optional)
426 $save_to_file = $request->get_param('save_to_file') ?? true;
427 $last_generated = '';
428 if ($save_to_file) {
429 $filename = 'sitemap.xml';
430 if (!empty($options['sitemap_urls'][0]['url'])) {
431 $filename = basename(wp_parse_url($options['sitemap_urls'][0]['url'], PHP_URL_PATH));
432 }
433 $this->save_sitemap_file($sitemap_xml, $filename);
434
435 // Regenerate the standalone local business sitemap on the
436 // single-sitemap path too (parity with Rank Math).
437 $this->sitemap_generator->regenerate_local_sitemap($options);
438
439 // Only record generation when the files were actually
440 // written — a preview (save_to_file=false) must not enable
441 // the "View Generated Sitemaps" links.
442 $last_generated = $this->record_generation();
443 }
444
445 return new WP_REST_Response([
446 'success' => true,
447 'data' => [
448 'sitemap_xml' => $sitemap_xml,
449 'sitemap_url' => home_url('/sitemap.xml'),
450 'generated_at' => gmdate('c'),
451 'url_count' => $this->count_urls_in_xml($sitemap_xml),
452 'last_generated' => $last_generated
453 ],
454 'message' => 'Sitemap generated successfully'
455 ]);
456 }
457
458 } catch (\Exception $e) {
459 $this->release_generation_lock();
460 return new WP_Error(
461 'generation_failed',
462 'Sitemap generation failed: ' . $e->getMessage(),
463 ['status' => 500]
464 );
465 } finally {
466 $this->release_generation_lock();
467 }
468 }
469
470 /**
471 * Validate sitemap
472 *
473 * @since 1.0.0
474 *
475 * @param WP_REST_Request $request Request object
476 * @return WP_REST_Response|WP_Error Response object or error
477 */
478 public function validate_sitemap(WP_REST_Request $request) {
479 try {
480 $sitemap_url = $request->get_param('sitemap_url') ?? home_url('/sitemap.xml');
481
482 // Validate sitemap URL
483 if (!filter_var($sitemap_url, FILTER_VALIDATE_URL)) {
484 return new WP_Error(
485 'invalid_url',
486 'Invalid sitemap URL provided',
487 ['status' => 400]
488 );
489 }
490
491 // Block SSRF: this endpoint fetches the URL server-side, so reject
492 // loopback/link-local/private hosts and non-http(s) schemes via
493 // WordPress's own validator (same guard used in class-schema-endpoint).
494 if (!wp_http_validate_url($sitemap_url)) {
495 return new WP_Error(
496 'invalid_url',
497 'The sitemap URL is not allowed.',
498 ['status' => 400]
499 );
500 }
501
502 // Perform validation
503 $validation_result = $this->perform_sitemap_validation($sitemap_url);
504
505 return new WP_REST_Response([
506 'success' => true,
507 'data' => $validation_result,
508 'message' => 'Sitemap validation completed'
509 ], 200);
510
511 } catch (\Exception $e) {
512 return new WP_Error(
513 'validation_failed',
514 'Sitemap validation failed: ' . $e->getMessage(),
515 ['status' => 500]
516 );
517 }
518 }
519
520 /**
521 * Get sitemap status
522 *
523 * @since 1.0.0
524 *
525 * @param WP_REST_Request $request Request object
526 * @return WP_REST_Response|WP_Error Response object or error
527 */
528 public function get_sitemap_status(WP_REST_Request $request) {
529 try {
530 // Get sitemap output data from generator
531 $status_data = $this->sitemap_generator->get_output_data('site', null);
532
533 // Add additional status information
534 $sitemap_file_path = ABSPATH . 'sitemap.xml';
535 $status_data['file_exists'] = file_exists($sitemap_file_path);
536 $status_data['file_size'] = $status_data['file_exists'] ? filesize($sitemap_file_path) : 0;
537 $status_data['file_modified'] = $status_data['file_exists'] ? gmdate('c', filemtime($sitemap_file_path)) : null;
538
539 return new WP_REST_Response([
540 'success' => true,
541 'data' => $status_data,
542 'message' => 'Sitemap status retrieved successfully'
543 ], 200);
544
545 } catch (\Exception $e) {
546 return new WP_Error(
547 'status_failed',
548 'Failed to get sitemap status: ' . $e->getMessage(),
549 ['status' => 500]
550 );
551 }
552 }
553
554 /**
555 * Submit sitemap to search engines
556 *
557 * @since 1.0.0
558 *
559 * @param WP_REST_Request $request Request object
560 * @return WP_REST_Response|WP_Error Response object or error
561 */
562 public function submit_sitemap(WP_REST_Request $request) {
563 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
564 // both now discover sitemaps via robots.txt on their own schedule. There
565 // is nothing to submit, so this is a no-op kept only so existing clients
566 // don't 404 (mirrors ping_search_engines()).
567 return new WP_REST_Response([
568 'success' => true,
569 'data' => [],
570 'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.',
571 ], 200);
572 }
573
574 /**
575 * Ping search engines about sitemap updates (unified method)
576 *
577 * @since 1.0.0
578 *
579 * @param WP_REST_Request $request Request object
580 * @return WP_REST_Response|WP_Error Response object or error
581 */
582 public function ping_search_engines(WP_REST_Request $request) {
583 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
584 // both now rely on the sitemap being referenced from robots.txt and pulled
585 // on their own schedule. There is nothing left to ping, so this endpoint is
586 // a no-op kept only so existing clients don't 404.
587 return new WP_REST_Response([
588 'success' => true,
589 'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.',
590 'engines' => [],
591 'timestamp' => gmdate('c')
592 ], 200);
593 }
594
595 /**
596 * Get sitemap statistics
597 *
598 * @since 1.0.0
599 *
600 * @param WP_REST_Request $request Request object
601 * @return WP_REST_Response|WP_Error Response object or error
602 */
603 public function get_sitemap_stats(WP_REST_Request $request) {
604 try {
605 $settings = $this->sitemap_generator->get_settings('site');
606
607 $stats = [
608 'total_urls' => $this->sitemap_generator->count_sitemap_urls($settings),
609 'post_count' => $settings['include_posts'] ? wp_count_posts('post')->publish : 0,
610 'page_count' => $settings['include_pages'] ? wp_count_posts('page')->publish : 0,
611 'category_count' => $settings['include_categories'] ? wp_count_terms('category') : 0,
612 'tag_count' => $settings['include_tags'] ? wp_count_terms('post_tag') : 0,
613 'last_generated' => $settings['last_generated'] ?? null,
614 'sitemap_enabled' => $settings['enabled'] ?? true
615 ];
616
617 return new WP_REST_Response([
618 'success' => true,
619 'data' => $stats,
620 'message' => 'Sitemap statistics retrieved successfully'
621 ], 200);
622
623 } catch (\Throwable $e) {
624 return new WP_Error(
625 'stats_failed',
626 'Failed to get sitemap statistics: ' . $e->getMessage(),
627 ['status' => 500]
628 );
629 }
630 }
631
632 /**
633 * Check read permissions
634 *
635 * @since 1.0.0
636 *
637 * @return bool Permission status
638 */
639 public function check_read_permissions(): bool {
640 return current_user_can('edit_posts');
641 }
642
643 /**
644 * Check manage permissions
645 *
646 * @since 1.0.0
647 *
648 * @return bool Permission status
649 */
650 public function check_manage_permissions(): bool {
651 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
652 }
653
654 /**
655 * Save sitemap to file
656 *
657 * @since 1.0.0
658 *
659 * @param string $sitemap_xml Sitemap XML content
660 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
661 * @return bool Success status
662 */
663 private function save_sitemap_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
664 // Clean filename and ensure it ends with .xml
665 $filename = sanitize_file_name($filename);
666 if (!str_ends_with($filename, '.xml')) {
667 $filename .= '.xml';
668 }
669
670 $sitemap_file_path = ABSPATH . $filename;
671
672 // Use WordPress filesystem API
673 global $wp_filesystem;
674 if (empty($wp_filesystem)) {
675 require_once ABSPATH . '/wp-admin/includes/file.php';
676 WP_Filesystem();
677 }
678
679 return $wp_filesystem->put_contents($sitemap_file_path, $sitemap_xml, FS_CHMOD_FILE);
680 }
681
682 /**
683 * Count URLs in sitemap XML content
684 *
685 * @since 1.0.0
686 *
687 * @param string $sitemap_xml Sitemap XML content
688 * @return int URL count
689 */
690 private function count_urls_in_xml(string $sitemap_xml): int {
691 return substr_count($sitemap_xml, '<url>');
692 }
693
694 /**
695 * Perform sitemap validation
696 *
697 * @since 1.0.0
698 *
699 * @param string $sitemap_url Sitemap URL to validate
700 * @return array Validation results
701 */
702 private function perform_sitemap_validation(string $sitemap_url): array {
703 $validation_result = [
704 'valid' => true,
705 'errors' => [],
706 'warnings' => [],
707 'url_count' => 0,
708 'file_size' => 0
709 ];
710
711 // Check if sitemap is accessible. wp_safe_remote_get() re-applies the
712 // reject-unsafe-URLs / external-host filters (incl. on redirects) so an
713 // internal host can't be reached even if it slipped past validation.
714 $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]);
715
716 if (is_wp_error($response)) {
717 $validation_result['valid'] = false;
718 $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message();
719 return $validation_result;
720 }
721
722 $status_code = wp_remote_retrieve_response_code($response);
723 if ($status_code !== 200) {
724 $validation_result['valid'] = false;
725 $validation_result['errors'][] = "Sitemap returned HTTP status code: {$status_code}";
726 return $validation_result;
727 }
728
729 $sitemap_content = wp_remote_retrieve_body($response);
730 $validation_result['file_size'] = strlen($sitemap_content);
731 $validation_result['url_count'] = $this->count_urls_in_xml($sitemap_content);
732
733 // Basic XML validation
734 libxml_use_internal_errors(true);
735 $xml = simplexml_load_string($sitemap_content);
736
737 if (false === $xml) {
738 $validation_result['valid'] = false;
739 $validation_result['errors'][] = 'Invalid XML format';
740
741 foreach (libxml_get_errors() as $error) {
742 $validation_result['errors'][] = trim($error->message);
743 }
744 }
745
746 // Check file size (should be under 50MB)
747 if ($validation_result['file_size'] > 50 * 1024 * 1024) {
748 $validation_result['warnings'][] = 'Sitemap file size exceeds 50MB limit';
749 }
750
751 // Check URL count (should be under 50,000)
752 if ($validation_result['url_count'] > 50000) {
753 $validation_result['warnings'][] = 'Sitemap contains more than 50,000 URLs';
754 }
755
756 return $validation_result;
757 }
758
759 /**
760 * Get arguments for generate endpoint
761 *
762 * @since 1.0.0
763 *
764 * @return array Arguments array
765 */
766 private function get_generate_args(): array {
767 return [
768 'options' => [
769 'required' => false,
770 'type' => 'object',
771 'description' => 'Sitemap generation options'
772 ],
773 'save_to_file' => [
774 'required' => false,
775 'type' => 'boolean',
776 'default' => true,
777 'description' => 'Save sitemap to file'
778 ]
779 ];
780 }
781
782 /**
783 * Get arguments for validate endpoint
784 *
785 * @since 1.0.0
786 *
787 * @return array Arguments array
788 */
789 private function get_validate_args(): array {
790 return [
791 'sitemap_url' => [
792 'required' => false,
793 'type' => 'string',
794 'format' => 'uri',
795 'default' => home_url('/sitemap.xml'),
796 'description' => 'Sitemap URL to validate'
797 ]
798 ];
799 }
800
801 /**
802 * Get arguments for submit endpoint
803 *
804 * @since 1.0.0
805 *
806 * @return array Arguments array
807 */
808 private function get_submit_args(): array {
809 return [
810 'search_engines' => [
811 'required' => false,
812 'type' => 'array',
813 'items' => [
814 'type' => 'string',
815 'enum' => ['google', 'bing']
816 ],
817 'default' => ['google', 'bing'],
818 'description' => 'Search engines to submit to'
819 ],
820 'sitemap_url' => [
821 'required' => false,
822 'type' => 'string',
823 'format' => 'uri',
824 'default' => home_url('/sitemap.xml'),
825 'description' => 'Sitemap URL to submit'
826 ]
827 ];
828 }
829
830 /**
831 * Get sitemap settings
832 *
833 * @since 1.0.0
834 *
835 * @param WP_REST_Request $request Request object
836 * @return WP_REST_Response|WP_Error Response object or error
837 */
838 public function get_sitemap_settings(WP_REST_Request $request) {
839 try {
840 $context_type = $request->get_param('context_type') ?? 'site';
841 $context_id = $request->get_param('context_id') ?? null;
842
843 // Get settings from Sitemap_Generator
844 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
845
846 return new WP_REST_Response([
847 'success' => true,
848 'data' => [
849 'settings' => $settings,
850 'context_type' => $context_type,
851 'context_id' => $context_id
852 ],
853 'message' => 'Sitemap settings retrieved successfully'
854 ], 200);
855
856 } catch (\Exception $e) {
857 return new WP_Error(
858 'settings_retrieval_failed',
859 'Failed to retrieve sitemap settings: ' . $e->getMessage(),
860 ['status' => 500]
861 );
862 }
863 }
864
865 /**
866 * Update sitemap settings
867 *
868 * @since 1.0.0
869 *
870 * @param WP_REST_Request $request Request object
871 * @return WP_REST_Response|WP_Error Response object or error
872 */
873 public function update_sitemap_settings(WP_REST_Request $request) {
874 try {
875 $settings = $request->get_param('settings') ?? [];
876 $context_type = $request->get_param('context_type') ?? 'site';
877 $context_id = $request->get_param('context_id') ?? null;
878
879 if (empty($settings)) {
880 return new WP_Error(
881 'missing_settings',
882 'Settings data is required',
883 ['status' => 400]
884 );
885 }
886
887 // Save settings using Sitemap_Generator
888 $success = $this->sitemap_generator->save_settings($context_type, $context_id, $settings);
889
890 if (!$success) {
891 return new WP_Error(
892 'settings_save_failed',
893 'Failed to save sitemap settings',
894 ['status' => 500]
895 );
896 }
897
898 $generated_now = false;
899 $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now);
900
901 // Rebuild the served sitemap so inclusion-rule changes take effect
902 // instead of waiting for a content edit (debounced against rapid
903 // successive saves). Skip when ensure_sitemap_file() just built a
904 // fresh file synchronously — otherwise we'd immediately schedule a
905 // second full generation of the same content.
906 if (!$generated_now) {
907 $this->sitemap_generator->schedule_regeneration();
908 }
909
910 return new WP_REST_Response([
911 'success' => true,
912 'data' => [
913 'settings' => $settings,
914 'context_type' => $context_type,
915 'context_id' => $context_id,
916 'sitemap_url' => $sitemap_url
917 ],
918 'message' => 'Sitemap settings saved successfully'
919 ], 200);
920
921 } catch (\Exception $e) {
922 return new WP_Error(
923 'settings_update_failed',
924 'Failed to update sitemap settings: ' . $e->getMessage(),
925 ['status' => 500]
926 );
927 }
928 }
929
930 /**
931 * Get arguments for settings endpoints
932 *
933 * @since 1.0.0
934 *
935 * @return array Arguments array
936 */
937 private function get_settings_args(): array {
938 return [
939 'settings' => [
940 'required' => true,
941 'type' => 'object',
942 'description' => 'Sitemap settings to save'
943 ],
944 'context_type' => [
945 'required' => false,
946 'type' => 'string',
947 'default' => 'site',
948 'description' => 'Context type for settings'
949 ],
950 'context_id' => [
951 'required' => false,
952 'type' => 'integer',
953 'description' => 'Context ID for settings'
954 ]
955 ];
956 }
957
958 /**
959 * Get custom post types for sitemap generation
960 *
961 * @since 1.0.0
962 *
963 * @param WP_REST_Request $request Request object
964 * @return WP_REST_Response|WP_Error Response object or error
965 */
966 public function get_custom_post_types(WP_REST_Request $request) {
967 try {
968 // Get all public custom post types (excluding built-in types)
969 $post_types = get_post_types([
970 'public' => true,
971 '_builtin' => false
972 ], 'objects');
973
974 $custom_post_types = [];
975 foreach ($post_types as $post_type) {
976 // Skip if it's a WooCommerce product (handled separately)
977 if ($post_type->name === 'product') {
978 continue;
979 }
980
981 $custom_post_types[] = [
982 'name' => $post_type->name,
983 'label' => $post_type->label,
984 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
985 'public' => $post_type->public,
986 'has_archive' => $post_type->has_archive,
987 'count' => wp_count_posts($post_type->name)->publish ?? 0
988 ];
989 }
990
991 return new WP_REST_Response([
992 'success' => true,
993 'data' => $custom_post_types,
994 'message' => 'Custom post types retrieved successfully'
995 ], 200);
996
997 } catch (\Exception $e) {
998 return new WP_Error(
999 'custom_post_types_failed',
1000 'Failed to get custom post types: ' . $e->getMessage(),
1001 ['status' => 500]
1002 );
1003 }
1004 }
1005
1006 /**
1007 * Get WooCommerce status for sitemap generation
1008 *
1009 * @since 1.0.0
1010 *
1011 * @param WP_REST_Request $request Request object
1012 * @return WP_REST_Response|WP_Error Response object or error
1013 */
1014 public function get_woocommerce_status(WP_REST_Request $request) {
1015 try {
1016 // Check if WooCommerce is active
1017 $is_woocommerce_active = class_exists('WooCommerce') && function_exists('WC');
1018
1019 // Check if product post type exists
1020 $product_post_type_exists = post_type_exists('product');
1021
1022 // Check if product category taxonomy exists
1023 $product_cat_taxonomy_exists = taxonomy_exists('product_cat');
1024
1025 $status = [
1026 'is_active' => $is_woocommerce_active,
1027 'product_post_type_exists' => $product_post_type_exists,
1028 'product_cat_taxonomy_exists' => $product_cat_taxonomy_exists,
1029 'product_count' => $product_post_type_exists ? wp_count_posts('product')->publish ?? 0 : 0,
1030 'product_category_count' => $product_cat_taxonomy_exists ? wp_count_terms('product_cat') : 0
1031 ];
1032
1033 return new WP_REST_Response([
1034 'success' => true,
1035 'data' => $status,
1036 'message' => 'WooCommerce status retrieved successfully'
1037 ], 200);
1038
1039 } catch (\Exception $e) {
1040 return new WP_Error(
1041 'woocommerce_status_failed',
1042 'Failed to get WooCommerce status: ' . $e->getMessage(),
1043 ['status' => 500]
1044 );
1045 }
1046 }
1047
1048 /**
1049 * Clean up old sitemap files
1050 *
1051 * @since 1.0.0
1052 *
1053 * @param WP_REST_Request $request Request object
1054 * @return WP_REST_Response|WP_Error Response object or error
1055 */
1056 public function cleanup_sitemap_files(WP_REST_Request $request) {
1057 try {
1058 // Scan filesystem for actual sitemap files instead of relying on configured URLs
1059 $cleaned_files = [];
1060 $failed_files = [];
1061
1062 // Common sitemap file patterns to look for
1063 $sitemap_patterns = [
1064 'sitemap*.xml',
1065 '*sitemap*.xml'
1066 ];
1067
1068 // Get all XML files in root directory that match sitemap patterns
1069 $sitemap_files = [];
1070 foreach ($sitemap_patterns as $pattern) {
1071 $files = glob(ABSPATH . $pattern);
1072 if ($files) {
1073 $sitemap_files = array_merge($sitemap_files, $files);
1074 }
1075 }
1076
1077 // Remove duplicates and filter to only sitemap-related files
1078 $sitemap_files = array_unique($sitemap_files);
1079
1080 foreach ($sitemap_files as $file_path) {
1081 $filename = basename($file_path);
1082
1083 // Skip if not a sitemap file (additional safety check)
1084 if (!$this->is_sitemap_file($filename)) {
1085 continue;
1086 }
1087
1088 // Only delete if file exists and is in root directory (security)
1089 $file_dir = trailingslashit(dirname($file_path));
1090 $root_dir = trailingslashit(ABSPATH);
1091
1092 if (file_exists($file_path) && $file_dir === $root_dir) {
1093 if (wp_delete_file($file_path)) {
1094 $cleaned_files[] = $filename;
1095 } else {
1096 $failed_files[] = $filename;
1097 }
1098 }
1099 }
1100
1101 return new WP_REST_Response([
1102 'success' => true,
1103 'data' => [
1104 'cleaned_files' => $cleaned_files,
1105 'failed_files' => $failed_files,
1106 'total_cleaned' => count($cleaned_files)
1107 ],
1108 'message' => sprintf(
1109 'Cleaned up %d sitemap file(s) successfully',
1110 count($cleaned_files)
1111 )
1112 ], 200);
1113
1114 } catch (\Exception $e) {
1115 return new WP_Error(
1116 'cleanup_failed',
1117 'Failed to clean up sitemap files: ' . $e->getMessage(),
1118 ['status' => 500]
1119 );
1120 }
1121 }
1122
1123 /**
1124 * Get sitemap URLs for robots.txt integration
1125 *
1126 * Returns enabled sitemap URLs from sitemap settings for automatic
1127 * inclusion in robots.txt file. This eliminates the need for manual
1128 * sitemap URL configuration in robots.txt settings.
1129 *
1130 * @since 1.0.0
1131 *
1132 * @param WP_REST_Request $request Request object
1133 * @return WP_REST_Response Response object
1134 */
1135 public function get_robots_sitemap_urls(WP_REST_Request $request): WP_REST_Response {
1136 try {
1137 // Get sitemap settings
1138 $settings = $this->sitemap_generator->get_settings('site');
1139
1140 // If sitemap is disabled, return empty array
1141 if (empty($settings['enabled'])) {
1142 return new WP_REST_Response([
1143 'success' => true,
1144 'data' => [
1145 'sitemap_urls' => [],
1146 'enabled' => false,
1147 'message' => __('Sitemap generation is disabled', 'thinkrank')
1148 ]
1149 ], 200);
1150 }
1151
1152 // Extract enabled sitemap URLs
1153 $sitemap_urls = [];
1154 $site_url = home_url();
1155
1156 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
1157 foreach ($settings['sitemap_urls'] as $sitemap) {
1158 if (!empty($sitemap['enabled']) && !empty($sitemap['url'])) {
1159 $sitemap_urls[] = [
1160 'url' => $sitemap['url'],
1161 'full_url' => $site_url . $sitemap['url'],
1162 'type' => $sitemap['type'] ?? 'general',
1163 'type_label' => $this->get_sitemap_type_label($sitemap['type'] ?? 'general')
1164 ];
1165 }
1166 }
1167 }
1168
1169 // Fallback to default sitemap if no URLs configured
1170 if (empty($sitemap_urls)) {
1171 $sitemap_urls[] = [
1172 'url' => '/sitemap.xml',
1173 'full_url' => $site_url . '/sitemap.xml',
1174 'type' => 'general',
1175 'type_label' => __('General', 'thinkrank')
1176 ];
1177 }
1178
1179 return new WP_REST_Response([
1180 'success' => true,
1181 'data' => [
1182 'sitemap_urls' => $sitemap_urls,
1183 'enabled' => true,
1184 'count' => count($sitemap_urls)
1185 ]
1186 ], 200);
1187
1188 } catch (\Exception $e) {
1189 return new WP_REST_Response([
1190 'success' => false,
1191 'error' => 'Failed to retrieve sitemap URLs: ' . $e->getMessage()
1192 ], 500);
1193 }
1194 }
1195
1196 /**
1197 * Get human-readable label for sitemap type
1198 *
1199 * @since 1.0.0
1200 *
1201 * @param string $type Sitemap type
1202 * @return string Human-readable label
1203 */
1204 private function get_sitemap_type_label(string $type): string {
1205 $labels = [
1206 'index' => __('Index', 'thinkrank'),
1207 'general' => __('General', 'thinkrank'),
1208 'posts' => __('Posts', 'thinkrank'),
1209 'pages' => __('Pages', 'thinkrank'),
1210 'categories' => __('Categories', 'thinkrank'),
1211 'tags' => __('Tags', 'thinkrank'),
1212 'products' => __('Products', 'thinkrank'),
1213 'wordpress' => __('WordPress Core', 'thinkrank'),
1214 'custom' => __('Custom', 'thinkrank')
1215 ];
1216
1217 return $labels[$type] ?? ucfirst($type);
1218 }
1219
1220 /**
1221 * Check rate limit for sitemap generation
1222 *
1223 * @since 1.0.0
1224 * @return bool True if within rate limit
1225 */
1226 private function check_rate_limit(): bool {
1227 $user_id = get_current_user_id();
1228 $rate_key = "thinkrank_sitemap_rate_{$user_id}";
1229
1230 $requests = get_transient($rate_key) ?: 0;
1231
1232 if ($requests >= 3) { // Max 3 requests per 5 minutes
1233 return false;
1234 }
1235
1236 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1237 return true;
1238 }
1239
1240 /**
1241 * Acquire generation lock to prevent concurrent generation
1242 *
1243 * @since 1.0.0
1244 * @return bool True if lock acquired
1245 */
1246 private function acquire_generation_lock(): bool {
1247 $lock_key = 'thinkrank_sitemap_generation_lock';
1248
1249 if (get_transient($lock_key)) {
1250 return false; // Generation already in progress
1251 }
1252
1253 set_transient($lock_key, time(), 5 * MINUTE_IN_SECONDS);
1254 return true;
1255 }
1256
1257 /**
1258 * Release generation lock
1259 *
1260 * @since 1.0.0
1261 * @return void
1262 */
1263 private function release_generation_lock(): void {
1264 delete_transient('thinkrank_sitemap_generation_lock');
1265 }
1266
1267 /**
1268 * Check if a filename is a sitemap file
1269 *
1270 * @since 1.0.0
1271 * @param string $filename Filename to check
1272 * @return bool True if it's a sitemap file
1273 */
1274 private function is_sitemap_file(string $filename): bool {
1275 // Must be XML file
1276 if (!str_ends_with($filename, '.xml')) {
1277 return false;
1278 }
1279
1280 // Must contain 'sitemap' in the name
1281 if (stripos($filename, 'sitemap') === false) {
1282 return false;
1283 }
1284
1285 // Exclude WordPress core files that aren't sitemaps
1286 $excluded_patterns = [
1287 'wp-sitemap-users-', // WordPress user sitemaps
1288 'wp-sitemap-taxonomies-', // WordPress taxonomy sitemaps
1289 ];
1290
1291 foreach ($excluded_patterns as $pattern) {
1292 if (stripos($filename, $pattern) !== false) {
1293 return false;
1294 }
1295 }
1296
1297 return true;
1298 }
1299 }
1300