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

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