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 | otto/otto_pixel.php +132 -108 2.7.0 → 2.7.1 View file →
@@ -16,9 +16,31 @@
16 16 # uses the simple html dom library
17 17 use simplehtmldom\HtmlDocument;
18 18
19 19 # include the otto class file
20 -require_once plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
20 +#
21 +# The vendor tree is generated content, unlike the plugin files beside it: an
22 +# update that copies the plugin directory piece by piece can briefly leave this
23 +# file beside an otto/vendor that is missing, half-written or holding a stale
24 +# classmap. A bare require on a missing autoloader is a fatal during plugin
25 +# bootstrap — the whole site, not just OTTO. Load it only when it is actually
26 +# readable, and catch Throwable while doing so: the autoloader is a trampoline
27 +# of bare requires into otto/vendor/composer/, so a tree that is present but
28 +# still mid-copy (or a truncated autoload.php, which parses only to a
29 +# ParseError) would otherwise fatal here too. Either way the parser class
30 +# stays unloaded, and the DOM-parser guard in metasync_start_otto() degrades
31 +# the request to OTTO-less rendering instead of fataling.
32 +$metasync_otto_vendor_autoload = plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
33 +if ( is_readable( $metasync_otto_vendor_autoload ) ) {
34 + try {
35 + require_once $metasync_otto_vendor_autoload;
36 + } catch ( \Throwable $e ) {
37 + # Deliberately swallowed: any exception raised while loading generated
38 + # autoloader code means this request runs without the DOM parser. The
39 + # guard below turns that into one OTTO-less request rather than a
40 + # bootstrap fatal on the whole site.
41 + }
42 +}
21 43 require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
22 44 require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
23 45 require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
24 46 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
@@ -77,9 +99,9 @@
77 99 # Use endpoint manager to get the correct domain
78 100 $otto_domain = 'sa.searchatlas.com'; # default
79 101 if (class_exists('Metasync_Endpoint_Manager')) {
80 102 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
81 - $parsed = parse_url($otto_api_domain);
103 + $parsed = wp_parse_url($otto_api_domain);
82 104 if (!empty($parsed['host'])) {
83 105 $otto_domain = $parsed['host'];
84 106 }
85 107 }
@@ -301,9 +323,9 @@
301 323 // event may therefore invoke this callback without its required route.
302 324 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
303 325 // @phpstan-ignore-next-line function.alreadyNarrowedType
304 326 if (!is_string($route) || $route === '') {
305 - error_log('MetaSync OTTO: skipping crawl-url job without a valid route.');
327 + error_log('MetaSync OTTO: skipping crawl-url job without a valid route.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
306 328 return;
307 329 }
308 330
309 331 $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
@@ -319,13 +341,13 @@
319 341 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
320 342 } else {
321 343 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
322 344 }
323 - error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's');
345 + error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
324 346 } else {
325 347 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
326 348 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
327 - error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason);
349 + error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
328 350 }
329 351 };
330 352
331 353 try {
@@ -350,9 +372,9 @@
350 372 # attempt budget on warm calls that will keep being rejected.
351 373 if ($transient_cache->last_failure_is_permanent()) {
352 374 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
353 375 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
354 - error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)');
376 + error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
355 377 return;
356 378 }
357 379 # Transient API failure (timeout / 5xx / breaker open). No meta
358 380 # write, no purge — straight to the retry path.
@@ -429,9 +451,9 @@
429 451 // event may therefore invoke this callback without its required route list.
430 452 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
431 453 // @phpstan-ignore-next-line function.alreadyNarrowedType
432 454 if (!is_array($routes) || empty($routes)) {
433 - error_log('MetaSync OTTO: skipping batch cache job without valid routes.');
455 + error_log('MetaSync OTTO: skipping batch cache job without valid routes.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
434 456 return;
435 457 }
436 458
437 459 $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
@@ -473,9 +495,9 @@
473 495 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
474 496 Metasync_Edge_Cache_Purge::purge($ready);
475 497 } catch (Exception $e) {
476 498 metasync_record_failed_action('metasync_process_otto_batch_cache_job');
477 - error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
499 + error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
478 500 }
479 501 }
480 502
481 503 /**
@@ -522,42 +544,8 @@
522 544 metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
523 545 }
524 546 }
525 547
526 -# NOTE: Cache system removed - these functions are no longer needed
527 -# Kept for backward compatibility in case old cache directories need cleanup
528 -function metasync_deleteDir($dir) {
529 - if (!is_dir($dir)) {
530 - return false;
531 - }
532 - $files = array_diff(scandir($dir), array('.', '..'));
533 - foreach ($files as $file) {
534 - $filePath = $dir . DIRECTORY_SEPARATOR . $file;
535 - if (is_dir($filePath)) {
536 - metasync_deleteDir($filePath);
537 - } else {
538 - unlink($filePath);
539 - }
540 - }
541 - return rmdir($dir);
542 -}
543 -
544 -# Cleanup function for removing old cache directories (if they exist)
545 -function metasync_invalidate_all_caches($folder = ''){
546 - # Cache system removed - this function only exists to clean up old cache directories
547 - if(!defined('WP_CONTENT_DIR')){
548 - return false;
549 - }
550 - $wp_content_dir = WP_CONTENT_DIR;
551 - $cache_dir = $wp_content_dir . '/metasync_caches';
552 - if(in_array($folder, ['posts', 'pages'])){
553 - $cache_dir = $cache_dir . '/' . $folder;
554 - }
555 - if(is_dir($cache_dir)){
556 - metasync_deleteDir($cache_dir);
557 - }
558 -}
559 -
560 548 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
561 549 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
562 550
563 551 /**
@@ -580,15 +568,15 @@
580 568 */
581 569 function metasync_is_elementor_editor_request() {
582 570 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
583 571 # This is the request OTTO would otherwise buffer and corrupt.
584 - if (isset($_GET['elementor-preview'])) {
572 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
585 573 return true;
586 574 }
587 575
588 576 # Elementor editor / app entry points carried as an action on the front end.
589 - if (isset($_REQUEST['action'])
590 - && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true)
577 + if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
578 + && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
591 579 ) {
592 580 return true;
593 581 }
594 582
@@ -639,10 +627,10 @@
639 627 # and OTTO's route identity ignores the query string, so a processed preview
640 628 # is answered with the route's public page (the homepage for /?p=…) instead
641 629 # of the draft the editor asked for. Skip early and let WP render it.
642 630 if (
643 - (isset($_GET['preview']) && $_GET['preview'] === 'true') ||
644 - !empty($_GET['preview_id'])
631 + (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
632 + !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
645 633 ) {
646 634 if (!defined('DONOTCACHEPAGE')) {
647 635 define('DONOTCACHEPAGE', true);
648 636 }
@@ -682,9 +670,9 @@
682 670 # then lower-case and strip surrounding slashes.
683 671 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
684 672 $otto_req_path = strtolower(trim($otto_req_path, '/'));
685 673 # Strip the site's base path so first-segment matching works on subdir installs.
686 - $otto_home_path = trim((string) parse_url(home_url(), PHP_URL_PATH), '/');
674 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
687 675 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
688 676 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
689 677 }
690 678 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
@@ -702,11 +690,11 @@
702 690 # ──────────────────────────────────────────────────────────────────────
703 691
704 692 if (
705 693 # disable ajax calls
706 - isset($_GET['ucfrontajaxaction']) ||
694 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
707 695 # OTTO Preview mode - skip OTTO when previewing original content
708 - (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
696 + (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
709 697 # WooCommerce shop archive page only (products and categories now use SSR)
710 698 //(function_exists('is_shop') && is_shop()) ||
711 699 # Cart page
712 700 (function_exists('is_cart') && is_cart()) ||
@@ -718,32 +706,32 @@
718 706 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
719 707 # check by constant
720 708 (defined('DOING_AJAX') && DOING_AJAX) ||
721 709 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
722 - (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
710 + (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
723 711 # AJAX requests via X-Requested-With header
724 712 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
725 713 # Gravity Forms submission detection - skip OTTO to allow form processing
726 - (isset($_POST['gform_submit']) && (
727 - is_array($_POST['gform_submit']) ||
728 - (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit']))
714 + (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
715 + is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
716 + (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
729 717 )) ||
730 718 # Gravity Forms AJAX submission
731 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
732 - is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
719 + (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
720 + is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
733 721 )) ||
734 722 # Gravity Forms file upload
735 - (isset($_POST['gform_uploaded_files'])) ||
723 + (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
736 724 # Any Gravity Forms POST parameter
737 - (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
725 + (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
738 726 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
739 - (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
727 + (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
740 728 # Formidable Forms POST parameters
741 - (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
729 + (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
742 730 # Formidable Forms action parameter
743 - (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
731 + (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
744 732 # Formidable Forms item_key (used in form submissions)
745 - (isset($_POST['item_key']) && !empty($_POST['item_key']))
733 + (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
746 734 ) {
747 735 return;
748 736 }
749 737
@@ -814,9 +802,22 @@
814 802 }
815 803
816 804 # A headless frontend renders the public HTML, so stop after the cheap cache
817 805 # protections and crawl telemetry, before any OTTO rendering work begins.
818 - if (Metasync_Headless_Config::is_active()) {
806 + #
807 + # Routed through metasync_headless_is_active() rather than calling the class
808 + # directly: Metasync_Headless_Config resolves only through the committed
809 + # Composer classmap, so a partially updated install can leave this newer
810 + # caller beside a missing or older copy with no fallback lookup path. This
811 + # function is hooked to `wp`, so an unguarded static call is a white screen
812 + # on every front-end page view. The helper treats an unresolvable class as
813 + # "not headless", which keeps OTTO behaving as it did before headless mode.
814 + # @phpstan-ignore-next-line function.alreadyNarrowedType
815 + $metasync_is_headless = function_exists('metasync_headless_is_active')
816 + ? metasync_headless_is_active()
817 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
818 +
819 + if ($metasync_is_headless) {
819 820 return;
820 821 }
821 822
822 823 # Throttle OTTO rendering for bots that are not exempt: at most one render
@@ -901,9 +902,9 @@
901 902 # ?et_blog = Divi AJAX pagination callback
902 903 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
903 904 # module numbering mismatch between page 1 (with TB template) and page N
904 905 # (without TB template), breaking Divi's JS pagination selector matching.
905 - if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
906 + if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
906 907 return;
907 908 }
908 909
909 910 # Skip OTTO on search-results pages. A search request carries its
@@ -918,9 +919,9 @@
918 919 # correct pre-rendered search page to everyone else. Search-results pages
919 920 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
920 921 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
921 922 # the main query is altered before this point.
922 - if (is_search() || !empty($_GET['s'])) {
923 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
923 924 return;
924 925 }
925 926
926 927 # Handle cache plugin compatibility early - before any caching happens
@@ -972,9 +973,9 @@
972 973 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
973 974
974 975 $otto_is_internal_fetch = $otto_has_fetch_detector
975 976 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
976 - : !empty($_GET['is_otto_page_fetch']);
977 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
977 978
978 979 if($otto_is_internal_fetch){
979 980
980 981 # Block SEO plugins NOW for this internal fetch request
@@ -979,10 +980,10 @@
979 980
980 981 # Block SEO plugins NOW for this internal fetch request
981 982 # metasync_otto_block_seo_plugins();
982 983 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
983 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
984 - $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
984 + $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
985 + $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
985 986
986 987 # Block SEO plugins conditionally based on what Otto has
987 988 if ($block_title || $block_description) {
988 989 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -1018,8 +1019,19 @@
1018 1019
1019 1020 # get the otto uuid
1020 1021 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1021 1022
1023 + # Upgrade-window guard: the bundled DOM parser resolves only through the
1024 + # otto/vendor autoloader required at the top of this file. While an update
1025 + # is mid-copy (or a stale opcache classmap is still resident), that
1026 + # dependency can briefly be undefined here, and constructing the render
1027 + # stack below would throw a class-not-found Error and take the whole page
1028 + # down with it. Degrade to an OTTO-less page for this request instead —
1029 + # the next request, on the settled tree, renders normally.
1030 + if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1031 + return;
1032 + }
1033 +
1022 1034 # start the class
1023 1035 $otto = new Metasync_otto_pixel($otto_uuid);
1024 1036
1025 1037 # call render
@@ -1051,9 +1063,9 @@
1051 1063 if ($cached !== false) {
1052 1064 $has_brizy_posts = ($cached === 'yes');
1053 1065 } else {
1054 1066 # Query database only if cache missed
1055 - $has_brizy_posts = $wpdb->get_var(
1067 + $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
1056 1068 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1057 1069 WHERE meta_key = 'brizy_post_uid'
1058 1070 AND meta_value != ''
1059 1071 LIMIT 1"
@@ -1653,8 +1665,17 @@
1653 1665
1654 1666 # Handle AJAX Clear Cache request
1655 1667 # NOTE: Cache system removed - this is now a no-op
1656 1668 function metasync_clear_otto_cache_handler() {
1669 + # State-changing admin action: verify the nonce sent by admin/js
1670 + # (clear_otto_caches) and confirm plugin access before clearing the
1671 + # detection transient or queueing the loopback re-check.
1672 + check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1673 +
1674 + if (!Metasync::current_user_has_plugin_access()) {
1675 + wp_send_json_error(['message' => 'Not authorized']);
1676 + }
1677 +
1657 1678 if (!empty($_GET['clear_otto_cache'])) {
1658 1679 delete_transient('metasync_otto_js_detected');
1659 1680 # Re-run the JS detection in the background so the notice refreshes.
1660 1681 metasync_schedule_otto_js_check();
@@ -1791,9 +1812,8 @@
1791 1812 }
1792 1813
1793 1814 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1794 1815 if (!metasync_otto_is_url_available($route)) {
1795 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1796 1816 metasync_otto_auto_exclude_404_url($route);
1797 1817 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1798 1818 }
1799 1819
@@ -1832,9 +1852,9 @@
1832 1852 }
1833 1853
1834 1854 # Mark this URL as crawled by OTTO for SSR
1835 1855 # Extract domain and path from route
1836 - $parsed_url = parse_url($route);
1856 + $parsed_url = wp_parse_url($route);
1837 1857 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1838 1858 $url_path = ($parsed_url['path'] ?? '/');
1839 1859
1840 1860 # Create crawl data structure
@@ -1867,9 +1887,9 @@
1867 1887
1868 1888 # Try to find WooCommerce product by URL if url_to_postid failed
1869 1889 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1870 1890 # Extract product slug from URL
1871 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1891 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1872 1892
1873 1893 # Try to get product by slug
1874 1894 $products = wc_get_products(array(
1875 1895 'name' => $product_slug,
@@ -1900,15 +1920,14 @@
1900 1920 if (!$post_id || $post_id <= 0) {
1901 1921 # Check if this is a category page
1902 1922 if (strpos($route, '/category/') !== false) {
1903 1923 # Extract category slug from URL
1904 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1924 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1905 1925 $category = get_category_by_slug($category_slug);
1906 1926
1907 1927 if ($category) {
1908 1928 # Check if category would return 404 before applying OTTO changes
1909 1929 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1910 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1911 1930 metasync_otto_auto_exclude_404_url($route);
1912 1931 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1913 1932 }
1914 1933
@@ -2004,15 +2023,14 @@
2004 2023
2005 2024 # Check if this is a WooCommerce product category
2006 2025 if (strpos($route, '/product-category/') !== false) {
2007 2026 # Extract product category slug from URL
2008 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2027 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2009 2028 $term = get_term_by('slug', $category_slug, 'product_cat');
2010 2029
2011 2030 if ($term && !is_wp_error($term)) {
2012 2031 # Check if product category would return 404 before applying OTTO changes
2013 2032 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2014 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
2015 2033 metasync_otto_auto_exclude_404_url($route);
2016 2034 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2017 2035 }
2018 2036
@@ -2125,9 +2143,8 @@
2125 2143
2126 2144 if ($home_page) {
2127 2145 # Check if home page would return 404 before applying OTTO changes
2128 2146 if (metasync_would_page_return_404($home_page->ID, $route)) {
2129 - error_log("MetaSync OTTO: Skipping SEO processing for home page that would return 404: {$route} (Post ID: {$home_page->ID}, Status: {$home_page->post_status})");
2130 2147 metasync_otto_auto_exclude_404_url($route);
2131 2148 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2132 2149 }
2133 2150
@@ -2231,9 +2248,8 @@
2231 2248
2232 2249 if ($posts_page && $route_clean === $posts_page_url) {
2233 2250 # Check if blog page would return 404
2234 2251 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2235 - error_log("MetaSync OTTO: Skipping SEO processing for blog page that would return 404: {$route} (Post ID: {$posts_page->ID}, Status: {$posts_page->post_status})");
2236 2252 metasync_otto_auto_exclude_404_url($route);
2237 2253 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2238 2254 }
2239 2255
@@ -2331,9 +2347,8 @@
2331 2347
2332 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2333 2349 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2334 2350 if (!metasync_otto_is_url_available($route)) {
2335 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
2336 2351 metasync_otto_auto_exclude_404_url($route);
2337 2352 }
2338 2353 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2339 2354 }
@@ -2350,9 +2365,8 @@
2350 2365 }
2351 2366
2352 2367 # Check if page would return 404 before applying OTTO changes
2353 2368 if (metasync_would_page_return_404($post_id, $route)) {
2354 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
2355 2369 metasync_otto_auto_exclude_404_url($route);
2356 2370 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2357 2371 }
2358 2372
@@ -2470,11 +2484,10 @@
2470 2484
2471 2485 // Minimal duplicate prevention within short time window
2472 2486 if (!empty($data['title']) && !empty($data['source'])) {
2473 2487 global $wpdb;
2474 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2475 - $recent = $wpdb->get_var($wpdb->prepare(
2476 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2488 + $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2489 + "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
2477 2490 $data['title'],
2478 2491 $data['source'],
2479 2492 gmdate('Y-m-d H:i:s', time() - 60)
2480 2493 ));
@@ -2485,9 +2498,9 @@
2485 2498
2486 2499 $sync_db->add($data);
2487 2500
2488 2501 } catch (Exception $e) {
2489 - error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2502 + error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2490 2503 }
2491 2504 }
2492 2505
2493 2506 /**
@@ -2563,9 +2576,9 @@
2563 2576 $db = new Metasync_Redirection_Database();
2564 2577 $redirect = new Metasync_Redirection($db);
2565 2578 return $redirect->resolve_url_to_final_destination($url, 10);
2566 2579 } catch (Exception $e) {
2567 - error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2580 + error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2568 2581 return $url;
2569 2582 }
2570 2583 }
2571 2584
@@ -2596,9 +2609,9 @@
2596 2609 'status' => 'active',
2597 2610 'auto_excluded' => 1,
2598 2611 ]);
2599 2612 } catch (Exception $e) {
2600 - error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2613 + error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2601 2614 return false;
2602 2615 }
2603 2616 }
2604 2617
@@ -2618,14 +2631,13 @@
2618 2631 try {
2619 2632 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2620 2633 $db = new Metasync_Otto_Excluded_URLs_Database();
2621 2634 global $wpdb;
2622 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2623 2635 # Normalize the same way is_url_excluded() does
2624 2636 $url_normalized = rtrim(trim($url), '/');
2625 - $records = $wpdb->get_results(
2637 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
2626 2638 $wpdb->prepare(
2627 - "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2639 + "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2628 2640 $url_normalized
2629 2641 )
2630 2642 );
2631 2643 if (!empty($records)) {
@@ -2655,24 +2667,26 @@
2655 2667 }
2656 2668 try {
2657 2669 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2658 2670 global $wpdb;
2659 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2660 2671 $url_normalized = rtrim(trim($url), '/');
2661 2672
2662 2673 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2663 2674
2664 2675 if ($records === false) {
2665 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2666 - $records = $wpdb->get_results(
2667 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2676 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- render-path read IS cached via get/set_transient with invalidation on every write — wp_cache_* is not used because the payload must survive object-cache-only setups
2677 + "SELECT url_pattern, pattern_type FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2668 2678 );
2669 2679
2670 2680 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2671 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2681 + // Schema changes belong to the upgrade routines, never the render path:
2682 + // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2683 + // one-shot background repair that runs the same column migration off
2684 + // the visitor request (handled by metasync_repair_otto_excluded_schema).
2672 2685 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2673 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2674 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2686 + if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2687 + wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2688 + }
2675 2689 return false;
2676 2690 }
2677 2691
2678 2692 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2748,9 +2762,9 @@
2748 2762 }
2749 2763
2750 2764 # WooCommerce product by slug
2751 2765 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2752 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2766 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2753 2767 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2754 2768 if (!empty($products)) {
2755 2769 $post_id = $products[0]->get_id();
2756 2770 } else {
@@ -2777,9 +2791,9 @@
2777 2791 }
2778 2792
2779 2793 # Category
2780 2794 if (strpos($route, '/category/') !== false) {
2781 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2795 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2782 2796 $category = get_category_by_slug($category_slug);
2783 2797 if ($category) {
2784 2798 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2785 2799 }
@@ -2786,9 +2800,9 @@
2786 2800 }
2787 2801
2788 2802 # WooCommerce product category
2789 2803 if (strpos($route, '/product-category/') !== false) {
2790 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2804 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2791 2805 $term = get_term_by('slug', $category_slug, 'product_cat');
2792 2806 if ($term && !is_wp_error($term)) {
2793 2807 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2794 2808 }
@@ -2830,9 +2844,9 @@
2830 2844
2831 2845 $removed = 0;
2832 2846 $marked_permanent = 0;
2833 2847 $thirty_days_ago = strtotime('-30 days');
2834 - $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2848 + $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2835 2849
2836 2850 foreach ($records as $record) {
2837 2851 $url = trim($record->url_pattern);
2838 2852 if (empty($url)) {
@@ -2853,16 +2867,10 @@
2853 2867 }
2854 2868 }
2855 2869 }
2856 2870
2857 - if ($removed > 0) {
2858 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2859 - }
2860 - if ($marked_permanent > 0) {
2861 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2862 - }
2863 2871 } catch (Exception $e) {
2864 - error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2872 + error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2865 2873 }
2866 2874 }
2867 2875
2868 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2867,8 +2875,24 @@
2867 2875
2868 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2869 2877
2870 2878 /**
2879 + * Background repair of the metasync_otto_excluded_urls schema (cron).
2880 + *
2881 + * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2882 + * read fails because the auto_excluded column is missing (pre-2.7.4 install
2883 + * whose version migration did not run). Runs the DB layer's self-healing
2884 + * column migration off the visitor request instead of ALTERing inline.
2885 + */
2886 +function metasync_repair_otto_excluded_schema()
2887 +{
2888 + require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2889 + Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2890 +}
2891 +
2892 +add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2893 +
2894 +/**
2871 2895 * Check if a post/page would return 404 without making HTTP request
2872 2896 * Uses WordPress database checks for fast validation
2873 2897 *
2874 2898 * @param int $post_id WordPress post ID
@@ -3044,9 +3068,9 @@
3044 3068 }
3045 3069 }
3046 3070
3047 3071 // WP Cloud's warmer also tags its requests with a query marker.
3048 - if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
3072 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
3049 3073 $is_infra = true;
3050 3074 }
3051 3075
3052 3076 /**