PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-sitemap-endpoint.php

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

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