PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +176 -28 2.6.7 → 2.7.1 View file →
@@ -80,9 +80,9 @@
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 - error_log('MetaSync Cache Status Error: ' . $e->getMessage());
84 + error_log('MetaSync Cache Status Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
85 85 echo '<p style="color: var(--dashboard-error);">⚠️ An error occurred while retrieving cache plugin status.</p>';
86 86 }
87 87 } else {
88 88 echo '<p style="color: var(--dashboard-error);">⚠️ Cache Purge class not loaded.</p>';
@@ -98,12 +98,12 @@
98 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>
99 99 </form>
100 100
101 101 <?php
102 - if (isset($_GET['cache_cleared']) && $_GET['cache_cleared'] == '1') {
103 - $cleared = isset($_GET['cleared']) ? intval($_GET['cleared']) : 0;
104 - $failed = isset($_GET['failed']) ? intval($_GET['failed']) : 0;
105 - $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
106 106
107 107 if ($cleared > 0) {
108 108 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
109 109 echo '✅ <strong>Success!</strong> Cleared cache for ' . intval( $cleared ) . ' plugin(s)';
@@ -123,10 +123,10 @@
123 123 echo '</p></div>';
124 124 }
125 125 }
126 126
127 - if (isset($_GET['cache_error']) && $_GET['cache_error'] == '1') {
128 - $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
129 129 if (empty($message)) {
130 130 $message = 'An unknown error occurred while clearing cache. Please check error logs for details.';
131 131 }
132 132 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
@@ -222,12 +222,12 @@
222 222 </form>
223 223
224 224 <?php
225 225 // Hosting cache result messages
226 - if (isset($_GET['hosting_cache_cleared']) && $_GET['hosting_cache_cleared'] == '1') {
227 - $hc_cleared = isset($_GET['hc_cleared']) ? sanitize_text_field(urldecode($_GET['hc_cleared'])) : '';
228 - $hc_failed = isset($_GET['hc_failed']) ? sanitize_text_field(urldecode($_GET['hc_failed'])) : '';
229 - $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
230 230
231 231 if ($hc_cleared) {
232 232 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
233 233 echo '✅ <strong>Success!</strong> Purged hosting cache on: ' . esc_html($hc_cleared);
@@ -295,11 +295,11 @@
295 295 <span style="color: var(--dashboard-accent);"><?php echo esc_html($cache_count); ?> cached entries</span>
296 296 </div>
297 297
298 298 <?php
299 - if (isset($_GET['otto_cache_cleared']) && $_GET['otto_cache_cleared'] == '1') {
300 - $cleared_count = isset($_GET['count']) ? intval($_GET['count']) : 0;
301 - $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
302 302
303 303 echo '<div class="notice notice-success inline" style="margin-top: 15px;"><p>';
304 304 if (!empty($url)) {
305 305 echo '✅ <strong>Success!</strong> Cleared cache for URL: <code>' . esc_html($url) . '</code> (' . intval( $cleared_count ) . ' entries)';
@@ -308,10 +308,10 @@
308 308 }
309 309 echo '</p></div>';
310 310 }
311 311
312 - if (isset($_GET['otto_cache_error']) && $_GET['otto_cache_error'] == '1') {
313 - $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
314 314 echo '<div class="notice notice-error inline" style="margin-top: 15px;"><p>';
315 315 echo '❌ <strong>Error:</strong> ' . esc_html($message);
316 316 echo '</p></div>';
317 317 }
@@ -350,9 +350,9 @@
350 350 <td>
351 351 <input type="url"
352 352 id="otto_cache_url"
353 353 name="otto_cache_url"
354 - 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 ?>"
355 355 class="regular-text"
356 356 placeholder="https://example.com/page/"
357 357 required />
358 358 <p class="description" style="color: var(--dashboard-text-secondary);">Enter the full URL of the page whose cache you want to clear.</p>
@@ -398,9 +398,9 @@
398 398 if (!empty($results['cleared'])) {
399 399 $redirect_url .= '&plugins=' . urlencode(implode(',', $results['cleared']));
400 400 }
401 401 } catch (Exception $e) {
402 - error_log('MetaSync Cache Clear Error: ' . $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 403 $redirect_url .= '&cache_error=1&message=' . urlencode('An error occurred while clearing the cache. Please try again.');
404 404 }
405 405 } else {
406 406 $redirect_url .= '&cache_error=1&message=' . urlencode('Cache Purge class not available');
@@ -427,10 +427,19 @@
427 427 if (class_exists('Metasync_Otto_Transient_Cache')) {
428 428 try {
429 429 $result = Metasync_Otto_Transient_Cache::clear_all_transients();
430 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();
431 440 } catch (Exception $e) {
432 - error_log('MetaSync OTTO Cache Clear Error: ' . $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
433 442 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache. Please try again.');
434 443 }
435 444 } else {
436 445 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
@@ -472,13 +481,20 @@
472 481 $result = Metasync_Otto_Transient_Cache::clear_url_transient($url);
473 482
474 483 if ($result['success']) {
475 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);
476 492 } else {
477 493 $redirect_url .= '&otto_cache_error=1&message=' . urlencode($result['message']);
478 494 }
479 495 } catch (Exception $e) {
480 - error_log('MetaSync OTTO Cache Clear Error: ' . $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
481 497 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('An error occurred while clearing the OTTO cache for this URL. Please try again.');
482 498 }
483 499 } else {
484 500 $redirect_url .= '&otto_cache_error=1&message=' . urlencode('Transient Cache class not found');
@@ -490,8 +506,68 @@
490 506 exit;
491 507 }
492 508
493 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 + // ------------------------------------------------------------------
494 570 // AJAX handlers – excluded URLs
495 571 // ------------------------------------------------------------------
496 572
497 573 /**
@@ -508,11 +584,11 @@
508 584 wp_send_json_error(['message' => 'Invalid security token']);
509 585 return;
510 586 }
511 587
512 - $url_pattern = isset($_POST['url_pattern']) ? sanitize_text_field($_POST['url_pattern']) : '';
513 - $pattern_type = isset($_POST['pattern_type']) ? sanitize_text_field($_POST['pattern_type']) : 'exact';
514 - $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'])) : '';
515 591
516 592 if (empty($url_pattern)) {
517 593 wp_send_json_error(['message' => 'URL pattern is required']);
518 594 return;
@@ -590,9 +666,9 @@
590 666 $cache_purge->clear_url_cache($url_pattern);
591 667 }
592 668 wp_cache_flush();
593 669 } catch (Exception $e) {
594 - error_log('MetaSync: Failed to clear cache for reactivated URL: ' . $e->getMessage());
670 + 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
595 671 }
596 672 }
597 673
598 674 wp_send_json_success([
@@ -612,9 +688,9 @@
612 688 }
613 689
614 690 wp_cache_flush();
615 691 } catch (Exception $e) {
616 - error_log('MetaSync: Failed to clear cache for excluded URL: ' . $e->getMessage());
692 + 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
617 693 }
618 694 }
619 695
620 696 wp_send_json_success([
@@ -682,9 +758,8 @@
682 758 return;
683 759 }
684 760
685 761 require_once plugin_dir_path(dirname(__FILE__)) . 'otto/class-metasync-otto-excluded-urls-database.php';
686 - require_once plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php';
687 762
688 763 $db = new Metasync_Otto_Excluded_URLs_Database();
689 764 $row = $db->get_record_by_id($id);
690 765
@@ -692,13 +767,86 @@
692 767 wp_send_json_error(['message' => 'Excluded URL not found']);
693 768 return;
694 769 }
695 770
771 + // Only an exact pattern resolves to a single URL that can be probed;
772 + // contain/start/end/regex rows match many URLs, so there is nothing to check.
773 + if ('exact' !== $row->pattern_type) {
774 + wp_send_json_error(['message' => 'Only exact URL exclusions can be rechecked']);
775 + }
776 +
696 777 $url = trim($row->url_pattern);
697 - $available = metasync_otto_is_url_available($url);
698 778
779 + // The probe needs a full URL. Older rows may store a site-relative
780 + // path, and a protocol-relative pattern only lacks the scheme; any
781 + // other scheme-less pattern cannot be turned into one URL reliably,
782 + // and guessing (host as path, other host as this site) would probe a
783 + // URL nobody excluded.
784 + if (null === wp_parse_url($url, PHP_URL_SCHEME)) {
785 + if (0 === strpos($url, '//')) {
786 + $url = wp_parse_url(home_url(), PHP_URL_SCHEME) . ':' . $url;
787 + } elseif (0 === strpos($url, '/')) {
788 + $url = home_url($url);
789 + } else {
790 + wp_send_json_success([
791 + 'available' => false,
792 + 'checked' => false,
793 + 'message' => 'Stored pattern is not a full URL; edit the entry to include the scheme.',
794 + 'url' => $url,
795 + ]);
796 + }
797 + }
798 +
799 + // Exclusions exist for this site's URLs, and letting the probe leave
800 + // for arbitrary hosts would turn Recheck into a server-side request
801 + // oracle, so the probe never targets another host.
802 + $url_host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
803 + $home_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
804 + if ($url_host !== $home_host || '' === $url_host) {
805 + wp_send_json_success([
806 + 'available' => false,
807 + 'checked' => false,
808 + 'message' => 'Only URLs on this site can be rechecked.',
809 + 'url' => $url,
810 + ]);
811 + }
812 +
813 + // Probe the real URL rather than guessing from local data — a deleted
814 + // post is invisible to local lookups but still 404s over HTTP.
815 + $args = [
816 + 'timeout' => 10,
817 + 'redirection' => 5,
818 + // Same opt-out core uses for loopback requests to this site.
819 + 'sslverify' => apply_filters('https_local_ssl_verify', true),
820 + ];
821 +
822 + $response = wp_remote_head($url, $args);
823 +
824 + // Some servers and edge layers refuse HEAD outright while the page
825 + // still serves to GET requests, so retry once before reporting the
826 + // refusal as a verdict.
827 + if (
828 + !is_wp_error($response)
829 + && 405 === (int) wp_remote_retrieve_response_code($response)
830 + ) {
831 + $response = wp_remote_get($url, $args);
832 + }
833 +
834 + if (is_wp_error($response)) {
835 + wp_send_json_success([
836 + 'available' => false,
837 + 'checked' => false,
838 + 'message' => $response->get_error_message(),
839 + 'url' => $url,
840 + ]);
841 + }
842 +
843 + $code = (int) wp_remote_retrieve_response_code($response);
844 +
699 845 wp_send_json_success([
700 - 'available' => $available,
846 + 'available' => ($code >= 200 && $code < 400),
847 + 'checked' => true,
848 + 'status' => $code,
701 849 'url' => $url,
702 850 ]);
703 851 }
704 852