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

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