PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 57 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.14.2, at includes/api/class-sitemap-endpoint.php

1,444 lines 53.0 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. Url_Validator accepts an internationalised
614 // domain or a non-ASCII path (home_url() on an IDN site is one).
615 if (!\ThinkRank\Core\Url_Validator::is_valid($sitemap_url)) {
616 return new WP_Error(
617 'invalid_url',
618 'Invalid sitemap URL provided',
619 ['status' => 400]
620 );
621 }
622
623 // Everything from here on works on the ASCII form (punycode host,
624 // percent-encoded path). wp_http_validate_url() resolves the host
625 // with gethostbyname(), which cannot resolve a Unicode name, and
626 // the SSRF check must see exactly the URL that is fetched.
627 $sitemap_url = \ThinkRank\Core\Url_Validator::to_ascii((string) $sitemap_url);
628
629 // Block SSRF: this endpoint fetches the URL server-side, so reject
630 // loopback/link-local/private hosts and non-http(s) schemes via
631 // WordPress's own validator (same guard used in class-schema-endpoint).
632 if (!wp_http_validate_url($sitemap_url)) {
633 return new WP_Error(
634 'invalid_url',
635 'The sitemap URL is not allowed.',
636 ['status' => 400]
637 );
638 }
639
640 // Perform validation
641 $validation_result = $this->perform_sitemap_validation($sitemap_url);
642
643 return new WP_REST_Response([
644 'success' => true,
645 'data' => $validation_result,
646 'message' => 'Sitemap validation completed'
647 ], 200);
648
649 } catch (\Exception $e) {
650 return new WP_Error(
651 'validation_failed',
652 'Sitemap validation failed: ' . $e->getMessage(),
653 ['status' => 500]
654 );
655 }
656 }
657
658 /**
659 * Get sitemap status
660 *
661 * @since 1.0.0
662 *
663 * @param WP_REST_Request $request Request object
664 * @return WP_REST_Response|WP_Error Response object or error
665 */
666 public function get_sitemap_status(WP_REST_Request $request) {
667 try {
668 // Get sitemap output data from generator
669 $status_data = $this->sitemap_generator->get_output_data('site', null);
670
671 // Add additional status information
672 $sitemap_file_path = ABSPATH . 'sitemap.xml';
673 $status_data['file_exists'] = file_exists($sitemap_file_path);
674 $status_data['file_size'] = $status_data['file_exists'] ? filesize($sitemap_file_path) : 0;
675 $status_data['file_modified'] = $status_data['file_exists'] ? gmdate('c', filemtime($sitemap_file_path)) : null;
676
677 return new WP_REST_Response([
678 'success' => true,
679 'data' => $status_data,
680 'message' => 'Sitemap status retrieved successfully'
681 ], 200);
682
683 } catch (\Exception $e) {
684 return new WP_Error(
685 'status_failed',
686 'Failed to get sitemap status: ' . $e->getMessage(),
687 ['status' => 500]
688 );
689 }
690 }
691
692 /**
693 * Submit sitemap to search engines
694 *
695 * @since 1.0.0
696 *
697 * @param WP_REST_Request $request Request object
698 * @return WP_REST_Response|WP_Error Response object or error
699 */
700 public function submit_sitemap(WP_REST_Request $request) {
701 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
702 // both now discover sitemaps via robots.txt on their own schedule. There
703 // is nothing to submit, so this is a no-op kept only so existing clients
704 // don't 404 (mirrors ping_search_engines()).
705 return new WP_REST_Response([
706 'success' => true,
707 'data' => [],
708 'message' => 'Search engines no longer accept sitemap submission; sitemaps are discovered automatically via robots.txt.',
709 ], 200);
710 }
711
712 /**
713 * Ping search engines about sitemap updates (unified method)
714 *
715 * @since 1.0.0
716 *
717 * @param WP_REST_Request $request Request object
718 * @return WP_REST_Response|WP_Error Response object or error
719 */
720 public function ping_search_engines(WP_REST_Request $request) {
721 // Google removed its sitemap-ping endpoint in 2023 and Bing followed suit;
722 // both now rely on the sitemap being referenced from robots.txt and pulled
723 // on their own schedule. There is nothing left to ping, so this endpoint is
724 // a no-op kept only so existing clients don't 404.
725 return new WP_REST_Response([
726 'success' => true,
727 'message' => 'Search engines no longer support sitemap ping; sitemaps are discovered automatically via robots.txt.',
728 'engines' => [],
729 'timestamp' => gmdate('c')
730 ], 200);
731 }
732
733 /**
734 * Get sitemap statistics
735 *
736 * @since 1.0.0
737 *
738 * @param WP_REST_Request $request Request object
739 * @return WP_REST_Response|WP_Error Response object or error
740 */
741 public function get_sitemap_stats(WP_REST_Request $request) {
742 try {
743 $settings = $this->sitemap_generator->get_settings('site');
744
745 $stats = [
746 'total_urls' => $this->sitemap_generator->count_sitemap_urls($settings),
747 'post_count' => $settings['include_posts'] ? wp_count_posts('post')->publish : 0,
748 'page_count' => $settings['include_pages'] ? wp_count_posts('page')->publish : 0,
749 'category_count' => $settings['include_categories'] ? wp_count_terms('category') : 0,
750 'tag_count' => $settings['include_tags'] ? wp_count_terms('post_tag') : 0,
751 'last_generated' => $settings['last_generated'] ?? null,
752 'sitemap_enabled' => $settings['enabled'] ?? true
753 ];
754
755 return new WP_REST_Response([
756 'success' => true,
757 'data' => $stats,
758 'message' => 'Sitemap statistics retrieved successfully'
759 ], 200);
760
761 } catch (\Throwable $e) {
762 return new WP_Error(
763 'stats_failed',
764 'Failed to get sitemap statistics: ' . $e->getMessage(),
765 ['status' => 500]
766 );
767 }
768 }
769
770 /**
771 * Check read permissions
772 *
773 * @since 1.0.0
774 *
775 * @return bool Permission status
776 */
777 public function check_read_permissions(): bool {
778 return current_user_can('edit_posts');
779 }
780
781 /**
782 * Check manage permissions for the state-changing routes.
783 *
784 * Every route using this callback is a POST that writes something —
785 * /generate, /submit, /ping, /settings, /cleanup — so it is nonce-gated as
786 * well as capability-gated, matching Schema_Endpoint, Setup_Wizard_Endpoint
787 * and Email_Report_Endpoint. The class already `use`d CSRF_Protection but
788 * never called it, leaving this controller the odd one out.
789 *
790 * @since 1.0.0
791 *
792 * @param WP_REST_Request $request Request object
793 * @return bool|WP_Error Permission status
794 */
795 public function check_manage_permissions(WP_REST_Request $request) {
796 if (!\ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling')) {
797 return new WP_Error(
798 'rest_forbidden',
799 __('You do not have permission to manage sitemaps.', 'thinkrank'),
800 ['status' => 403]
801 );
802 }
803
804 if (!$this->verify_request_nonce($request)) {
805 return new WP_Error(
806 'rest_forbidden',
807 __('Invalid security token. Please refresh the page and try again.', 'thinkrank'),
808 ['status' => 403]
809 );
810 }
811
812 return true;
813 }
814
815 /**
816 * Save sitemap to file
817 *
818 * @since 1.0.0
819 *
820 * @param string $sitemap_xml Sitemap XML content
821 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
822 * @return bool Success status
823 */
824 private function save_sitemap_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
825 // Clean filename and ensure it ends with .xml
826 $filename = sanitize_file_name($filename);
827 if (!str_ends_with($filename, '.xml')) {
828 $filename .= '.xml';
829 }
830
831 $sitemap_file_path = ABSPATH . $filename;
832
833 // Use WordPress filesystem API
834 global $wp_filesystem;
835 if (empty($wp_filesystem)) {
836 require_once ABSPATH . '/wp-admin/includes/file.php';
837 WP_Filesystem();
838 }
839
840 return $wp_filesystem->put_contents($sitemap_file_path, $sitemap_xml, FS_CHMOD_FILE);
841 }
842
843 /**
844 * Count URLs in sitemap XML content
845 *
846 * @since 1.0.0
847 *
848 * @param string $sitemap_xml Sitemap XML content
849 * @return int URL count
850 */
851 private function count_urls_in_xml(string $sitemap_xml): int {
852 return substr_count($sitemap_xml, '<url>');
853 }
854
855 /**
856 * Perform sitemap validation
857 *
858 * @since 1.0.0
859 *
860 * @param string $sitemap_url Sitemap URL to validate
861 * @return array Validation results
862 */
863 private function perform_sitemap_validation(string $sitemap_url): array {
864 $validation_result = [
865 'valid' => true,
866 'errors' => [],
867 'warnings' => [],
868 'url_count' => 0,
869 'file_size' => 0
870 ];
871
872 // Check if sitemap is accessible. wp_safe_remote_get() re-applies the
873 // reject-unsafe-URLs / external-host filters (incl. on redirects) so an
874 // internal host can't be reached even if it slipped past validation.
875 $response = wp_safe_remote_get($sitemap_url, ['timeout' => 30]);
876
877 if (is_wp_error($response)) {
878 $validation_result['valid'] = false;
879 $validation_result['errors'][] = 'Sitemap is not accessible: ' . $response->get_error_message();
880 return $validation_result;
881 }
882
883 $status_code = wp_remote_retrieve_response_code($response);
884 if ($status_code !== 200) {
885 $validation_result['valid'] = false;
886 $validation_result['errors'][] = "Sitemap returned HTTP status code: {$status_code}";
887 return $validation_result;
888 }
889
890 $sitemap_content = wp_remote_retrieve_body($response);
891 $validation_result['file_size'] = strlen($sitemap_content);
892 $validation_result['url_count'] = $this->count_urls_in_xml($sitemap_content);
893
894 // Basic XML validation
895 libxml_use_internal_errors(true);
896 $xml = simplexml_load_string($sitemap_content);
897
898 if (false === $xml) {
899 $validation_result['valid'] = false;
900 $validation_result['errors'][] = 'Invalid XML format';
901
902 foreach (libxml_get_errors() as $error) {
903 $validation_result['errors'][] = trim($error->message);
904 }
905 }
906
907 // Check file size (should be under 50MB)
908 if ($validation_result['file_size'] > 50 * 1024 * 1024) {
909 $validation_result['warnings'][] = 'Sitemap file size exceeds 50MB limit';
910 }
911
912 // Check URL count (should be under 50,000)
913 if ($validation_result['url_count'] > 50000) {
914 $validation_result['warnings'][] = 'Sitemap contains more than 50,000 URLs';
915 }
916
917 return $validation_result;
918 }
919
920 /**
921 * Get arguments for generate endpoint
922 *
923 * @since 1.0.0
924 *
925 * @return array Arguments array
926 */
927 private function get_generate_args(): array {
928 return [
929 'options' => [
930 'required' => false,
931 'type' => 'object',
932 'description' => 'Sitemap generation options'
933 ],
934 'save_to_file' => [
935 'required' => false,
936 'type' => 'boolean',
937 'default' => true,
938 'description' => 'Save sitemap to file'
939 ]
940 ];
941 }
942
943 /**
944 * Get arguments for validate endpoint
945 *
946 * @since 1.0.0
947 *
948 * @return array Arguments array
949 */
950 private function get_validate_args(): array {
951 return [
952 'sitemap_url' => [
953 'required' => false,
954 'type' => 'string',
955 'format' => 'uri',
956 'default' => home_url('/sitemap.xml'),
957 'description' => 'Sitemap URL to validate'
958 ]
959 ];
960 }
961
962 /**
963 * Get arguments for submit endpoint
964 *
965 * @since 1.0.0
966 *
967 * @return array Arguments array
968 */
969 private function get_submit_args(): array {
970 return [
971 'search_engines' => [
972 'required' => false,
973 'type' => 'array',
974 'items' => [
975 'type' => 'string',
976 'enum' => ['google', 'bing']
977 ],
978 'default' => ['google', 'bing'],
979 'description' => 'Search engines to submit to'
980 ],
981 'sitemap_url' => [
982 'required' => false,
983 'type' => 'string',
984 'format' => 'uri',
985 'default' => home_url('/sitemap.xml'),
986 'description' => 'Sitemap URL to submit'
987 ]
988 ];
989 }
990
991 /**
992 * Get sitemap settings
993 *
994 * @since 1.0.0
995 *
996 * @param WP_REST_Request $request Request object
997 * @return WP_REST_Response|WP_Error Response object or error
998 */
999 public function get_sitemap_settings(WP_REST_Request $request) {
1000 try {
1001 // SECURITY: the settings are stored per context, so the object has
1002 // to be authorised before it is read (#385).
1003 $context = $this->resolve_request_context($request);
1004 if (is_wp_error($context)) {
1005 return $context;
1006 }
1007 [$context_type, $context_id] = $context;
1008
1009 // Get settings from Sitemap_Generator
1010 $settings = $this->sitemap_generator->get_settings($context_type, $context_id);
1011
1012 return new WP_REST_Response([
1013 'success' => true,
1014 'data' => [
1015 'settings' => $settings,
1016 'context_type' => $context_type,
1017 'context_id' => $context_id,
1018 // Kept out of `settings` on purpose: this is generator state,
1019 // not something the settings POST round-trips.
1020 'health' => $context_type === 'site'
1021 ? $this->sitemap_generator->get_regeneration_health()
1022 : null,
1023 // `delivery_mode` in `settings` may still be 'auto', which
1024 // only the server can resolve (it depends on whether the web
1025 // root is writable). The admin screen needs the answer, not
1026 // the question: a dynamic site publishes no file, so gating
1027 // its sitemap links on `last_generated` — which dynamic
1028 // delivery deliberately never sets — left every link
1029 // permanently disabled.
1030 'resolved_delivery_mode' => $context_type === 'site'
1031 ? $this->sitemap_generator->resolve_delivery_mode($settings)
1032 : null
1033 ],
1034 'message' => 'Sitemap settings retrieved successfully'
1035 ], 200);
1036
1037 } catch (\Exception $e) {
1038 return new WP_Error(
1039 'settings_retrieval_failed',
1040 'Failed to retrieve sitemap settings: ' . $e->getMessage(),
1041 ['status' => 500]
1042 );
1043 }
1044 }
1045
1046 /**
1047 * Update sitemap settings
1048 *
1049 * @since 1.0.0
1050 *
1051 * @param WP_REST_Request $request Request object
1052 * @return WP_REST_Response|WP_Error Response object or error
1053 */
1054 public function update_sitemap_settings(WP_REST_Request $request) {
1055 try {
1056 $settings = $request->get_param('settings') ?? [];
1057
1058 // SECURITY: this write is keyed by the context, so the object has to
1059 // be authorised before anything is persisted (#385).
1060 $context = $this->resolve_request_context($request);
1061 if (is_wp_error($context)) {
1062 return $context;
1063 }
1064 [$context_type, $context_id] = $context;
1065
1066 if (empty($settings)) {
1067 return new WP_Error(
1068 'missing_settings',
1069 'Settings data is required',
1070 ['status' => 400]
1071 );
1072 }
1073
1074 // Save settings using Sitemap_Generator
1075 $success = $this->sitemap_generator->save_settings($context_type, $context_id, $settings);
1076
1077 if (!$success) {
1078 return new WP_Error(
1079 'settings_save_failed',
1080 'Failed to save sitemap settings',
1081 ['status' => 500]
1082 );
1083 }
1084
1085 $generated_now = false;
1086 $sitemap_url = $this->ensure_sitemap_file($context_type, $context_id, $generated_now);
1087
1088 // Rebuild the served sitemap so inclusion-rule changes take effect
1089 // instead of waiting for a content edit (debounced against rapid
1090 // successive saves). Skip when ensure_sitemap_file() just built a
1091 // fresh file synchronously — otherwise we'd immediately schedule a
1092 // second full generation of the same content.
1093 if (!$generated_now) {
1094 $this->sitemap_generator->schedule_regeneration();
1095 }
1096
1097 return new WP_REST_Response([
1098 'success' => true,
1099 'data' => [
1100 'settings' => $settings,
1101 'context_type' => $context_type,
1102 'context_id' => $context_id,
1103 'sitemap_url' => $sitemap_url
1104 ],
1105 'message' => 'Sitemap settings saved successfully'
1106 ], 200);
1107
1108 } catch (\Exception $e) {
1109 return new WP_Error(
1110 'settings_update_failed',
1111 'Failed to update sitemap settings: ' . $e->getMessage(),
1112 ['status' => 500]
1113 );
1114 }
1115 }
1116
1117 /**
1118 * Get arguments for settings endpoints
1119 *
1120 * @since 1.0.0
1121 *
1122 * @return array Arguments array
1123 */
1124 private function get_settings_args(): array {
1125 return [
1126 'settings' => [
1127 'required' => true,
1128 'type' => 'object',
1129 'description' => 'Sitemap settings to save'
1130 ],
1131 'context_type' => [
1132 'required' => false,
1133 'type' => 'string',
1134 'default' => 'site',
1135 'description' => 'Context type for settings'
1136 ],
1137 'context_id' => [
1138 'required' => false,
1139 'type' => 'integer',
1140 'description' => 'Context ID for settings'
1141 ]
1142 ];
1143 }
1144
1145 /**
1146 * Get custom post types for sitemap generation
1147 *
1148 * @since 1.0.0
1149 *
1150 * @param WP_REST_Request $request Request object
1151 * @return WP_REST_Response|WP_Error Response object or error
1152 */
1153 public function get_custom_post_types(WP_REST_Request $request) {
1154 try {
1155 // Get all public custom post types (excluding built-in types)
1156 $post_types = get_post_types([
1157 'public' => true,
1158 '_builtin' => false
1159 ], 'objects');
1160
1161 $custom_post_types = [];
1162 foreach ($post_types as $post_type) {
1163 // Skip if it's a WooCommerce product (handled separately)
1164 if ($post_type->name === 'product') {
1165 continue;
1166 }
1167
1168 $custom_post_types[] = [
1169 'name' => $post_type->name,
1170 'label' => $post_type->label,
1171 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
1172 'public' => $post_type->public,
1173 'has_archive' => $post_type->has_archive,
1174 'count' => wp_count_posts($post_type->name)->publish ?? 0
1175 ];
1176 }
1177
1178 return new WP_REST_Response([
1179 'success' => true,
1180 'data' => $custom_post_types,
1181 'message' => 'Custom post types retrieved successfully'
1182 ], 200);
1183
1184 } catch (\Exception $e) {
1185 return new WP_Error(
1186 'custom_post_types_failed',
1187 'Failed to get custom post types: ' . $e->getMessage(),
1188 ['status' => 500]
1189 );
1190 }
1191 }
1192
1193 /**
1194 * Get WooCommerce status for sitemap generation
1195 *
1196 * @since 1.0.0
1197 *
1198 * @param WP_REST_Request $request Request object
1199 * @return WP_REST_Response|WP_Error Response object or error
1200 */
1201 public function get_woocommerce_status(WP_REST_Request $request) {
1202 try {
1203 // Check if WooCommerce is active
1204 $is_woocommerce_active = class_exists('WooCommerce') && function_exists('WC');
1205
1206 // Check if product post type exists
1207 $product_post_type_exists = post_type_exists('product');
1208
1209 // Check if product category taxonomy exists
1210 $product_cat_taxonomy_exists = taxonomy_exists('product_cat');
1211
1212 $status = [
1213 'is_active' => $is_woocommerce_active,
1214 'product_post_type_exists' => $product_post_type_exists,
1215 'product_cat_taxonomy_exists' => $product_cat_taxonomy_exists,
1216 'product_count' => $product_post_type_exists ? wp_count_posts('product')->publish ?? 0 : 0,
1217 'product_category_count' => $product_cat_taxonomy_exists ? wp_count_terms('product_cat') : 0
1218 ];
1219
1220 return new WP_REST_Response([
1221 'success' => true,
1222 'data' => $status,
1223 'message' => 'WooCommerce status retrieved successfully'
1224 ], 200);
1225
1226 } catch (\Exception $e) {
1227 return new WP_Error(
1228 'woocommerce_status_failed',
1229 'Failed to get WooCommerce status: ' . $e->getMessage(),
1230 ['status' => 500]
1231 );
1232 }
1233 }
1234
1235 /**
1236 * Clean up old sitemap files
1237 *
1238 * @since 1.0.0
1239 *
1240 * @param WP_REST_Request $request Request object
1241 * @return WP_REST_Response|WP_Error Response object or error
1242 */
1243 public function cleanup_sitemap_files(WP_REST_Request $request) {
1244 try {
1245 $settings = $this->sitemap_generator->get_settings('site');
1246
1247 // Delete only the files ThinkRank published. This used to glob
1248 // ABSPATH for 'sitemap*.xml' and '*sitemap*.xml' and delete anything
1249 // whose name contained "sitemap", which also swept up a physical
1250 // core wp-sitemap.xml and any other plugin's sitemap sitting in the
1251 // web root. delete_published_sitemaps() derives the name list from
1252 // our own stored sitemap_urls (honouring a custom url pattern) plus
1253 // the default names, and covers the -N pagination pages.
1254 $removed = $this->sitemap_generator->delete_published_sitemaps($settings);
1255 $cleaned_files = $removed['deleted'];
1256 $failed_files = $removed['failed'];
1257
1258 // Cleanup on its own used to leave the site with no sitemap at all
1259 // and nothing scheduled to rebuild one: the regeneration that is
1260 // meant to follow lives in the admin bundle, so a bare REST/MCP call
1261 // — or a generate that then hit the rate limit or lost the
1262 // generation lock — published nothing and 404'd indefinitely. Queue
1263 // the rebuild here so the recovery does not depend on the caller.
1264 $regeneration_scheduled = false;
1265 if (!empty($settings['enabled']) && $cleaned_files) {
1266 $this->sitemap_generator->schedule_regeneration();
1267 $regeneration_scheduled = true;
1268 }
1269
1270 // The files are gone, so stop reporting them as generated —
1271 // otherwise the admin keeps offering "View Generated Sitemaps"
1272 // links to files that no longer exist.
1273 if ($cleaned_files) {
1274 $this->clear_generation_record();
1275 }
1276
1277 return new WP_REST_Response([
1278 'success' => true,
1279 'data' => [
1280 'cleaned_files' => $cleaned_files,
1281 'failed_files' => $failed_files,
1282 'total_cleaned' => count($cleaned_files),
1283 'regeneration_scheduled' => $regeneration_scheduled
1284 ],
1285 'message' => sprintf(
1286 'Cleaned up %d sitemap file(s) successfully',
1287 count($cleaned_files)
1288 )
1289 ], 200);
1290
1291 } catch (\Exception $e) {
1292 return new WP_Error(
1293 'cleanup_failed',
1294 'Failed to clean up sitemap files: ' . $e->getMessage(),
1295 ['status' => 500]
1296 );
1297 }
1298 }
1299
1300 /**
1301 * Get sitemap URLs for robots.txt integration
1302 *
1303 * Returns enabled sitemap URLs from sitemap settings for automatic
1304 * inclusion in robots.txt file. This eliminates the need for manual
1305 * sitemap URL configuration in robots.txt settings.
1306 *
1307 * @since 1.0.0
1308 *
1309 * @param WP_REST_Request $request Request object
1310 * @return WP_REST_Response Response object
1311 */
1312 public function get_robots_sitemap_urls(WP_REST_Request $request): WP_REST_Response {
1313 try {
1314 // Get sitemap settings
1315 $settings = $this->sitemap_generator->get_settings('site');
1316
1317 // If sitemap is disabled, return empty array
1318 if (empty($settings['enabled'])) {
1319 return new WP_REST_Response([
1320 'success' => true,
1321 'data' => [
1322 'sitemap_urls' => [],
1323 'enabled' => false,
1324 'message' => __('Sitemap generation is disabled', 'thinkrank')
1325 ]
1326 ], 200);
1327 }
1328
1329 // Extract enabled sitemap URLs
1330 $sitemap_urls = [];
1331 $site_url = home_url();
1332
1333 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
1334 foreach ($settings['sitemap_urls'] as $sitemap) {
1335 if (!empty($sitemap['enabled']) && !empty($sitemap['url'])) {
1336 $sitemap_urls[] = [
1337 'url' => $sitemap['url'],
1338 'full_url' => $site_url . $sitemap['url'],
1339 'type' => $sitemap['type'] ?? 'general',
1340 'type_label' => $this->get_sitemap_type_label($sitemap['type'] ?? 'general')
1341 ];
1342 }
1343 }
1344 }
1345
1346 // Fallback to default sitemap if no URLs configured
1347 if (empty($sitemap_urls)) {
1348 $sitemap_urls[] = [
1349 'url' => '/sitemap.xml',
1350 'full_url' => $site_url . '/sitemap.xml',
1351 'type' => 'general',
1352 'type_label' => __('General', 'thinkrank')
1353 ];
1354 }
1355
1356 return new WP_REST_Response([
1357 'success' => true,
1358 'data' => [
1359 'sitemap_urls' => $sitemap_urls,
1360 'enabled' => true,
1361 'count' => count($sitemap_urls)
1362 ]
1363 ], 200);
1364
1365 } catch (\Exception $e) {
1366 return new WP_REST_Response([
1367 'success' => false,
1368 'error' => 'Failed to retrieve sitemap URLs: ' . $e->getMessage()
1369 ], 500);
1370 }
1371 }
1372
1373 /**
1374 * Get human-readable label for sitemap type
1375 *
1376 * @since 1.0.0
1377 *
1378 * @param string $type Sitemap type
1379 * @return string Human-readable label
1380 */
1381 private function get_sitemap_type_label(string $type): string {
1382 $labels = [
1383 'index' => __('Index', 'thinkrank'),
1384 'general' => __('General', 'thinkrank'),
1385 'posts' => __('Posts', 'thinkrank'),
1386 'pages' => __('Pages', 'thinkrank'),
1387 'categories' => __('Categories', 'thinkrank'),
1388 'tags' => __('Tags', 'thinkrank'),
1389 'products' => __('Products', 'thinkrank'),
1390 'wordpress' => __('WordPress Core', 'thinkrank'),
1391 'custom' => __('Custom', 'thinkrank')
1392 ];
1393
1394 return $labels[$type] ?? ucfirst($type);
1395 }
1396
1397 /**
1398 * Check rate limit for sitemap generation
1399 *
1400 * @since 1.0.0
1401 * @return bool True if within rate limit
1402 */
1403 private function check_rate_limit(): bool {
1404 $user_id = get_current_user_id();
1405 $rate_key = "thinkrank_sitemap_rate_{$user_id}";
1406
1407 $requests = get_transient($rate_key) ?: 0;
1408
1409 if ($requests >= 3) { // Max 3 requests per 5 minutes
1410 return false;
1411 }
1412
1413 set_transient($rate_key, $requests + 1, 5 * MINUTE_IN_SECONDS);
1414 return true;
1415 }
1416
1417 /**
1418 * Acquire generation lock to prevent concurrent generation
1419 *
1420 * @since 1.0.0
1421 * @return bool True if lock acquired
1422 */
1423 private function acquire_generation_lock(): bool {
1424 $lock_key = Sitemap_Generator::GENERATION_LOCK_TRANSIENT;
1425
1426 if (get_transient($lock_key)) {
1427 return false; // Generation already in progress
1428 }
1429
1430 set_transient($lock_key, time(), 5 * MINUTE_IN_SECONDS);
1431 return true;
1432 }
1433
1434 /**
1435 * Release generation lock
1436 *
1437 * @since 1.0.0
1438 * @return void
1439 */
1440 private function release_generation_lock(): void {
1441 delete_transient(Sitemap_Generator::GENERATION_LOCK_TRANSIENT);
1442 }
1443 }
1444