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 +212 -37 2.6.6 → 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,11 +427,20 @@
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 }
@@ -471,14 +481,21 @@
471 481 $result = Metasync_Otto_Transient_Cache::clear_url_transient($url);
472 482
473 483 if ($result['success']) {
474 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);
475 492 } else {
476 493 $redirect_url .= '&otto_cache_error=1&message=' . urlencode($result['message']);
477 494 }
478 495 } catch (Exception $e) {
479 - error_log('MetaSync OTTO Cache Clear Error: ' . $e->getMessage());
480 - $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.');
481 498 }
482 499 } else {
483 500 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
484 501 }
@@ -489,8 +506,68 @@
489 506 exit;
490 507 }
491 508
492 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 + // ------------------------------------------------------------------
493 570 // AJAX handlers – excluded URLs
494 571 // ------------------------------------------------------------------
495 572
496 573 /**
@@ -507,11 +584,11 @@
507 584 wp_send_json_error(['message' => 'Invalid security token']);
508 585 return;
509 586 }
510 587
511 - $url_pattern = isset($_POST['url_pattern']) ? sanitize_text_field($_POST['url_pattern']) : '';
512 - $pattern_type = isset($_POST['pattern_type']) ? sanitize_text_field($_POST['pattern_type']) : 'exact';
513 - $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'])) : '';
514 591
515 592 if (empty($url_pattern)) {
516 593 wp_send_json_error(['message' => 'URL pattern is required']);
517 594 return;
@@ -523,8 +600,21 @@
523 600 return;
524 601 }
525 602
526 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 +
527 617 $test_pattern = $url_pattern;
528 618 $delimiter_chars = ['/', '#', '~', '%', '@'];
529 619 $has_valid_delimiters = false;
530 620
@@ -541,9 +631,9 @@
541 631 if (!$has_valid_delimiters) {
542 632 $test_pattern = '/' . $test_pattern . '/';
543 633 }
544 634
545 - if (@preg_match($test_pattern, '') === false) {
635 + if (!Metasync_Regex_Validator::is_valid($test_pattern)) {
546 636 wp_send_json_error(['message' => 'Invalid regular expression pattern']);
547 637 return;
548 638 }
549 639 }
@@ -565,8 +655,17 @@
565 655 ]);
566 656 return;
567 657 }
568 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 +
569 668 if ($result === 'reactivated') {
570 669 $db->clear_cache();
571 670
572 671 if ($pattern_type === 'exact') {
@@ -576,9 +675,9 @@
576 675 $cache_purge->clear_url_cache($url_pattern);
577 676 }
578 677 wp_cache_flush();
579 678 } catch (Exception $e) {
580 - 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
581 680 }
582 681 }
583 682
584 683 wp_send_json_success([
@@ -598,9 +697,9 @@
598 697 }
599 698
600 699 wp_cache_flush();
601 700 } catch (Exception $e) {
602 - 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
603 702 }
604 703 }
605 704
606 705 wp_send_json_success([
@@ -638,8 +737,12 @@
638 737
639 738 $result = $db->delete([$id]);
640 739
641 740 if ($result) {
741 + Metasync_PostHog::feature('otto_opt_out', [
742 + 'scope' => 'pattern',
743 + 'action' => 'remove',
744 + ]);
642 745 wp_send_json_success([
643 746 'message' => 'Excluded URL deleted successfully',
644 747 ]);
645 748 } else {
@@ -668,9 +771,8 @@
668 771 return;
669 772 }
670 773
671 774 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
672 - require_once plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php';
673 775
674 776 $db = new Metasync_Otto_Excluded_URLs_Database();
675 777 $row = $db->get_record_by_id($id);
676 778
@@ -678,13 +780,86 @@
678 780 wp_send_json_error(['message' => 'Excluded URL not found']);
679 781 return;
680 782 }
681 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 +
682 790 $url = trim($row->url_pattern);
683 - $available = metasync_otto_is_url_available($url);
684 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 +
685 858 wp_send_json_success([
686 - 'available' => $available,
859 + 'available' => ($code >= 200 && $code < 400),
860 + 'checked' => true,
861 + 'status' => $code,
687 862 'url' => $url,
688 863 ]);
689 864 }
690 865