| @@ -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 | |