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

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