PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-otto-cache-manager.php +216 -37 2.6.5 → 2.7.2 View file →
@@ -80,9 +80,10 @@
80 80 } else {
81 81 echo '<p style="color: var(--dashboard-text-secondary);">ℹ️ No cache plugins detected.</p>';
82 82 }
83 83 } catch (Exception $e) {
84 - echo '<p style="color: var(--dashboard-error);">⚠️ Error: ' . esc_html($e->getMessage()) . '</p>';
84 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
85 + echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
85 86 }
86 87 } else {
87 88 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
88 89 }
@@ -97,16 +98,16 @@
97 98 <p class="description" style="margin-top: 10px; color: var(--dashboard-text-secondary);">This will clear cache from WP Rocket, LiteSpeed, W3 Total Cache, and all other detected cache plugins.</p>
98 99 </form>
99 100
100 101 <?php
101 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
102 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
103 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
104 - $plugins = isset($_GET['plugins']) ? sanitize_text_field($_GET['plugins']) : '';
102 + if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
103 + $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
104 + $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
105 + $plugins = isset($_GET['plugins']) ? sanitize_text_field(wp_unslash($_GET['plugins'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
105 106
106 107 if ($cleared > 0) {
107 108 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
108 - echo '✅ <strong>Success!</strong> Cleared cache for ' . $cleared . ' plugin(s)';
109 + echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
109 110 if ($plugins) {
110 111 echo ': ' . esc_html($plugins);
111 112 }
112 113 echo '</p></div>';
@@ -117,15 +118,15 @@
117 118 }
118 119
119 120 if ($failed > 0) {
120 121 echo '<div class="notice notice-warning inline" style="margin-top: 15px;"><p>';
121 - echo '⚠️ Failed to clear ' . $failed . ' plugin(s).';
122 + echo '⚠️ Failed to clear ' . intval( $failed ) . ' plugin(s).';
122 123 echo '</p></div>';
123 124 }
124 125 }
125 126
126 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
127 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field($_GET['message'])) : '';
127 + if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
128 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
128 129 if (empty($message)) {
129 130 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
130 131 }
131 132 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -200,9 +201,9 @@
200 201 </button>
201 202 <span id="metasync-hc-save-msg" style="display: none; font-size: 13px;"></span>
202 203 </div>
203 204
204 - <input type="hidden" id="metasync-hc-nonce" value="<?php echo wp_create_nonce('metasync_hosting_cache_nonce'); ?>" />
205 + <input type="hidden" id="metasync-hc-nonce" value="<?php echo esc_attr(wp_create_nonce('metasync_hosting_cache_nonce')); ?>" />
205 206 </div>
206 207
207 208 <!-- Purge button -->
208 209 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
@@ -221,12 +222,12 @@
221 222 </form>
222 223
223 224 <?php
224 225 // Hosting cache result messages
225 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
226 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
227 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
228 - $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : '';
226 + if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
227 + $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
228 + $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
229 + $hc_not_detected = isset($_GET['hc_not_detected']) ? sanitize_text_field(urldecode($_GET['hc_not_detected'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
229 230
230 231 if ($hc_cleared) {
231 232 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
232 233 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -294,23 +295,23 @@
294 295 <span style="color: var(--dashboard-accent);"><?php echo esc_html($cache_count); ?> cached entries</span>
295 296 </div>
296 297
297 298 <?php
298 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
299 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
300 - $url = isset($_GET['url']) ? urldecode(sanitize_text_field($_GET['url'])) : '';
299 + if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
300 + $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
301 + $url = isset($_GET['url']) ? urldecode(sanitize_text_field(wp_unslash($_GET['url']))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
301 302
302 303 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
303 304 if (!empty($url)) {
304 - echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . $cleared_count . ' entries)';
305 + echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
305 306 } else {
306 - echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . $cleared_count . ' entries)';
307 + echo '✅ <strong>Success!</strong> Cleared entire transient cache (' . intval( $cleared_count ) . ' entries)';
307 308 }
308 309 echo '</p></div>';
309 310 }
310 311
311 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
312 - $message = isset($_GET['message']) ? urldecode(sanitize_text_field($_GET['message'])) : 'An unknown error occurred.';
312 + if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
313 + $message = isset($_GET['message']) ? urldecode(sanitize_text_field(wp_unslash($_GET['message']))) : 'An unknown error occurred.'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only result flag for an escaped notice
313 314 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
314 315 echo '❌ <strong>Error:</strong> ' . esc_html($message);
315 316 echo '</p></div>';
316 317 }
@@ -349,9 +350,9 @@
349 350 <td>
350 351 <input type="url"
351 352 id="otto_cache_url"
352 353 name="otto_cache_url"
353 - value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field($_GET['url']))) : ''; ?>"
354 + value="<?php echo isset($_GET['url']) ? esc_attr(urldecode(sanitize_text_field(wp_unslash($_GET['url'])))) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only URL prefill, the form submit carries a nonce ?>"
354 355 class="regular-text"
355 356 placeholder="https://example.com/page/"
356 357 required />
357 358 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -397,10 +398,10 @@
397 398 if (!empty($results['cleared'])) {
398 399 $redirect_url .= '&plugins=' . urlencode(implode(',', $results['cleared']));
399 400 }
400 401 } catch (Exception $e) {
401 - error_log('MetaSync Cache Clear Error: ' . $e->getMessage());
402 - $redirect_url .= '&cache_error=1&message=' . urlencode($e->getMessage());
402 + error_log('MetaSync Cache Clear Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
403 + $redirect_url .= '&cache_error=1&message=' . urlencode('An error occurred while clearing the cache. Please try again.');
403 404 }
404 405 } else {
405 406 $redirect_url .= '&cache_error=1&message=' . urlencode('Cache Purge class not available');
406 407 }
@@ -426,16 +427,27 @@
426 427 if (class_exists('Metasync_Otto_Transient_Cache')) {
427 428 try {
428 429 $result = Metasync_Otto_Transient_Cache::clear_all_transients();
429 430 $redirect_url .= '&otto_cache_cleared=1&count=' . $result['cleared_count'];
431 +
432 + # Clearing the OTTO suggestions transient is not enough on its
433 + # own. The OTTO-modified HTML (e.g. an injected hidden H1 from a module
434 + # that has since been unapproved in the dashboard) is frozen in the host
435 + # page cache and any edge CDN. Until those layers are purged, PHP never
436 + # re-renders the page, so the stale injection keeps serving even though
437 + # the suggestions cache is now empty. Purge the page caches too so the
438 + # next request regenerates with the current (clean) OTTO state.
439 + self::purge_page_caches_all();
430 440 } catch (Exception $e) {
431 - error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage());
432 - $redirect_url .= '&otto_cache_error=1&message=' . urlencode($e->getMessage());
441 + error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
442 + $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache. Please try again.');
433 443 }
434 444 } else {
435 445 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
436 446 }
437 447
448 + delete_transient('metasync_otto_js_detected');
449 +
438 450 wp_safe_redirect($redirect_url);
439 451 exit;
440 452 }
441 453
@@ -469,24 +481,93 @@
469 481 $result = Metasync_Otto_Transient_Cache::clear_url_transient($url);
470 482
471 483 if ($result['success']) {
472 484 $redirect_url .= '&otto_cache_cleared=1&count=' . $result['cleared_count'] . '&url=' . urlencode($url);
485 +
486 + # Also purge the host page cache and edge CDN for this URL so
487 + # the frozen OTTO-modified HTML (e.g. a hidden injected H1 from a module
488 + # unapproved in the dashboard) is regenerated. Clearing the suggestions
489 + # transient alone leaves the stale HTML live in the page cache because
490 + # PHP never re-renders the cached page.
491 + self::purge_page_caches_for_url($url);
473 492 } else {
474 493 $redirect_url .= '&otto_cache_error=1&message=' . urlencode($result['message']);
475 494 }
476 495 } catch (Exception $e) {
477 - error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage());
478 - $redirect_url .= '&otto_cache_error=1&message=' . urlencode($e->getMessage());
496 + error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
497 + $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache for this URL. Please try again.');
479 498 }
480 499 } else {
481 500 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
482 501 }
483 502
503 + delete_transient('metasync_otto_js_detected');
504 +
484 505 wp_safe_redirect($redirect_url);
485 506 exit;
486 507 }
487 508
488 509 // ------------------------------------------------------------------
510 + // Page-cache propagation
511 + // ------------------------------------------------------------------
512 +
513 + /**
514 + * Purge the host page cache and edge CDN after clearing OTTO suggestions
515 + * for a single URL.
516 + *
517 + * Clearing the OTTO suggestions transient only invalidates the data layer.
518 + * The rendered, OTTO-modified HTML is still frozen in the host page cache
519 + * (WP Rocket, Kinsta/WP Engine FastCGI, etc.) and any edge CDN, so PHP never
520 + * re-runs and the stale injection keeps serving. Purging both page layers
521 + * forces a clean regeneration on the next request.
522 + *
523 + * Unlike the crawl-notify webhook (which defers the same purge to a
524 + * background job because it runs under a strict response-time budget), this
525 + * is a deliberate, user-initiated admin action, so the purge runs inline —
526 + * the admin expects to wait for the clear to complete. The purge is not
527 + * followed by a warm: the goal here is to drop stale OTTO HTML, not to
528 + * pre-populate fresh content, so a cold cache that repopulates on the next
529 + * request is the desired outcome.
530 + *
531 + * @param string $url Absolute URL whose page caches should be purged.
532 + * @return void
533 + */
534 + public static function purge_page_caches_for_url($url)
535 + {
536 + if (class_exists('Metasync_Cache_Purge')) {
537 + Metasync_Cache_Purge::purge_single_url($url);
538 + }
539 + if (class_exists('Metasync_Edge_Cache_Purge')) {
540 + Metasync_Edge_Cache_Purge::purge(array($url));
541 + }
542 + }
543 +
544 + /**
545 + * Purge all host page caches and trigger edge CDN flush after clearing every
546 + * OTTO suggestions transient.
547 + *
548 + * Edge coverage caveat: full-flush providers (Sucuri/Sevalla/Cloudways, etc.)
549 + * ignore the URL list and clear everything, so the home-URL sentinel below is
550 + * enough to fire them. Tag-based providers (Cloudflare/Fastly/Akamai), however,
551 + * purge per-URL/per-tag — passing only the home URL purges just the homepage on
552 + * those CDNs. The host page cache is still flushed site-wide by purge_all(), so
553 + * the stale OTTO HTML is gone at the origin; on tag-based CDNs, non-home pages
554 + * clear on their normal TTL. Clearing a specific page's CDN entry immediately is
555 + * what the per-URL "Clear Cache for URL" action is for.
556 + *
557 + * @return void
558 + */
559 + public static function purge_page_caches_all()
560 + {
561 + if (class_exists('Metasync_Cache_Purge')) {
562 + Metasync_Cache_Purge::purge_all('otto_manual_clear');
563 + }
564 + if (class_exists('Metasync_Edge_Cache_Purge')) {
565 + Metasync_Edge_Cache_Purge::purge(array(home_url('/')));
566 + }
567 + }
568 +
569 + // ------------------------------------------------------------------
489 570 // AJAX handlers – excluded URLs
490 571 // ------------------------------------------------------------------
491 572
492 573 /**
@@ -503,11 +584,11 @@
503 584 wp_send_json_error(['message' => 'Invalid security token']);
504 585 return;
505 586 }
506 587
507 - $url_pattern = isset($_POST['url_pattern']) ? sanitize_text_field($_POST['url_pattern']) : '';
508 - $pattern_type = isset($_POST['pattern_type']) ? sanitize_text_field($_POST['pattern_type']) : 'exact';
509 - $description = isset($_POST['description']) ? sanitize_textarea_field($_POST['description']) : '';
588 + $url_pattern = isset($_POST['url_pattern']) ? sanitize_text_field(wp_unslash($_POST['url_pattern'])) : '';
589 + $pattern_type = isset($_POST['pattern_type']) ? sanitize_text_field(wp_unslash($_POST['pattern_type'])) : 'exact';
590 + $description = isset($_POST['description']) ? sanitize_textarea_field(wp_unslash($_POST['description'])) : '';
510 591
511 592 if (empty($url_pattern)) {
512 593 wp_send_json_error(['message' => 'URL pattern is required']);
513 594 return;
@@ -519,8 +600,21 @@
519 600 return;
520 601 }
521 602
522 603 if ($pattern_type === 'regex') {
604 + // Limit pattern length to prevent ReDoS via catastrophic backtracking
605 + if (strlen($url_pattern) > 500) {
606 + wp_send_json_error(['message' => 'Regular expression pattern is too long (max 500 characters)']);
607 + return;
608 + }
609 +
610 + // Reject patterns with nested quantifiers that could cause ReDoS
611 + $raw = preg_replace('/^\S(.*)\S[a-zA-Z]*$/', '$1', $url_pattern);
612 + if (preg_match('/(\([^)]*[+*][^)]*\))[+*?{]|(\[[^\]]*\])[+*][+*?{]/', $raw)) {
613 + wp_send_json_error(['message' => 'Pattern contains potentially unsafe nested quantifiers']);
614 + return;
615 + }
616 +
523 617 $test_pattern = $url_pattern;
524 618 $delimiter_chars = ['/', '#', '~', '%', '@'];
525 619 $has_valid_delimiters = false;
526 620
@@ -537,9 +631,9 @@
537 631 if (!$has_valid_delimiters) {
538 632 $test_pattern = '/' . $test_pattern . '/';
539 633 }
540 634
541 - if (@preg_match($test_pattern, '') === false) {
635 + if (!Metasync_Regex_Validator::is_valid($test_pattern)) {
542 636 wp_send_json_error(['message' => 'Invalid regular expression pattern']);
543 637 return;
544 638 }
545 639 }
@@ -561,8 +655,17 @@
561 655 ]);
562 656 return;
563 657 }
564 658
659 + if ($result === 'reactivated' || $result === true) {
660 + // Consent-gated analytics: excluding URLs from OTTO is an opt-out signal.
661 + Metasync_PostHog::feature('otto_opt_out', [
662 + 'scope' => 'pattern',
663 + 'action' => 'add',
664 + 'pattern_type' => sanitize_key((string) $pattern_type),
665 + ]);
666 + }
667 +
565 668 if ($result === 'reactivated') {
566 669 $db->clear_cache();
567 670
568 671 if ($pattern_type === 'exact') {
@@ -572,9 +675,9 @@
572 675 $cache_purge->clear_url_cache($url_pattern);
573 676 }
574 677 wp_cache_flush();
575 678 } catch (Exception $e) {
576 - error_log('MetaSync: Failed to clear cache for reactivated URL: ' . $e->getMessage());
679 + error_log('MetaSync: Failed to clear cache for reactivated URL: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
577 680 }
578 681 }
579 682
580 683 wp_send_json_success([
@@ -594,9 +697,9 @@
594 697 }
595 698
596 699 wp_cache_flush();
597 700 } catch (Exception $e) {
598 - error_log('MetaSync: Failed to clear cache for excluded URL: ' . $e->getMessage());
701 + error_log('MetaSync: Failed to clear cache for excluded URL: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
599 702 }
600 703 }
601 704
602 705 wp_send_json_success([
@@ -634,8 +737,12 @@
634 737
635 738 $result = $db->delete([$id]);
636 739
637 740 if ($result) {
741 + Metasync_PostHog::feature('otto_opt_out', [
742 + 'scope' => 'pattern',
743 + 'action' => 'remove',
744 + ]);
638 745 wp_send_json_success([
639 746 'message' => 'Excluded URL deleted successfully',
640 747 ]);
641 748 } else {
@@ -664,9 +771,8 @@
664 771 return;
665 772 }
666 773
667 774 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
668 - require_once plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php';
669 775
670 776 $db = new Metasync_Otto_Excluded_URLs_Database();
671 777 $row = $db->get_record_by_id($id);
672 778
@@ -674,13 +780,86 @@
674 780 wp_send_json_error(['message' => 'Excluded URL not found']);
675 781 return;
676 782 }
677 783
784 + // Only an exact pattern resolves to a single URL that can be probed;
785 + // contain/start/end/regex rows match many URLs, so there is nothing to check.
786 + if ('exact' !== $row->pattern_type) {
787 + wp_send_json_error(['message' => 'Only exact URL exclusions can be rechecked']);
788 + }
789 +
678 790 $url = trim($row->url_pattern);
679 - $available = metasync_otto_is_url_available($url);
680 791
792 + // The probe needs a full URL. Older rows may store a site-relative
793 + // path, and a protocol-relative pattern only lacks the scheme; any
794 + // other scheme-less pattern cannot be turned into one URL reliably,
795 + // and guessing (host as path, other host as this site) would probe a
796 + // URL nobody excluded.
797 + if (null === wp_parse_url($url, PHP_URL_SCHEME)) {
798 + if (0 === strpos($url, '//')) {
799 + $url = wp_parse_url(home_url(), PHP_URL_SCHEME) . ':' . $url;
800 + } elseif (0 === strpos($url, '/')) {
801 + $url = home_url($url);
802 + } else {
803 + wp_send_json_success([
804 + 'available' => false,
805 + 'checked' => false,
806 + 'message' => 'Stored pattern is not a full URL; edit the entry to include the scheme.',
807 + 'url' => $url,
808 + ]);
809 + }
810 + }
811 +
812 + // Exclusions exist for this site's URLs, and letting the probe leave
813 + // for arbitrary hosts would turn Recheck into a server-side request
814 + // oracle, so the probe never targets another host.
815 + $url_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
816 + $home_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
817 + if ($url_host !== $home_host || '' === $url_host) {
818 + wp_send_json_success([
819 + 'available' => false,
820 + 'checked' => false,
821 + 'message' => 'Only URLs on this site can be rechecked.',
822 + 'url' => $url,
823 + ]);
824 + }
825 +
826 + // Probe the real URL rather than guessing from local data — a deleted
827 + // post is invisible to local lookups but still 404s over HTTP.
828 + $args = [
829 + 'timeout' => 10,
830 + 'redirection' => 5,
831 + // Same opt-out core uses for loopback requests to this site.
832 + 'sslverify' => apply_filters('https_local_ssl_verify', true),
833 + ];
834 +
835 + $response = wp_remote_head($url, $args);
836 +
837 + // Some servers and edge layers refuse HEAD outright while the page
838 + // still serves to GET requests, so retry once before reporting the
839 + // refusal as a verdict.
840 + if (
841 + !is_wp_error($response)
842 + && 405 === (int) wp_remote_retrieve_response_code($response)
843 + ) {
844 + $response = wp_remote_get($url, $args);
845 + }
846 +
847 + if (is_wp_error($response)) {
848 + wp_send_json_success([
849 + 'available' => false,
850 + 'checked' => false,
851 + 'message' => $response->get_error_message(),
852 + 'url' => $url,
853 + ]);
854 + }
855 +
856 + $code = (int) wp_remote_retrieve_response_code($response);
857 +
681 858 wp_send_json_success([
682 - 'available' => $available,
859 + 'available' => ($code >= 200 && $code < 400),
860 + 'checked' => true,
861 + 'status' => $code,
683 862 'url' => $url,
684 863 ]);
685 864 }
686 865