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 +162 -110 2.6.26 → 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
@@ -812,8 +800,27 @@
812 800 if ( $detection['is_bot'] ) {
813 801 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
814 802 }
815 803
804 + # A headless frontend renders the public HTML, so stop after the cheap cache
805 + # protections and crawl telemetry, before any OTTO rendering work begins.
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) {
820 + return;
821 + }
822 +
816 823 # Throttle OTTO rendering for bots that are not exempt: at most one render
817 824 # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
818 825 # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
819 826 # synthetic performance auditors are never throttled — their hits always get
@@ -895,9 +902,9 @@
895 902 # ?et_blog = Divi AJAX pagination callback
896 903 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
897 904 # module numbering mismatch between page 1 (with TB template) and page N
898 905 # (without TB template), breaking Divi's JS pagination selector matching.
899 - 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
900 907 return;
901 908 }
902 909
903 910 # Skip OTTO on search-results pages. A search request carries its
@@ -912,9 +919,9 @@
912 919 # correct pre-rendered search page to everyone else. Search-results pages
913 920 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
914 921 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
915 922 # the main query is altered before this point.
916 - 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
917 924 return;
918 925 }
919 926
920 927 # Handle cache plugin compatibility early - before any caching happens
@@ -966,9 +973,9 @@
966 973 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
967 974
968 975 $otto_is_internal_fetch = $otto_has_fetch_detector
969 976 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
970 - : !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
971 978
972 979 if($otto_is_internal_fetch){
973 980
974 981 # Block SEO plugins NOW for this internal fetch request
@@ -973,10 +980,10 @@
973 980
974 981 # Block SEO plugins NOW for this internal fetch request
975 982 # metasync_otto_block_seo_plugins();
976 983 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
977 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
978 - $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
979 986
980 987 # Block SEO plugins conditionally based on what Otto has
981 988 if ($block_title || $block_description) {
982 989 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -1012,8 +1019,19 @@
1012 1019
1013 1020 # get the otto uuid
1014 1021 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1015 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 +
1016 1034 # start the class
1017 1035 $otto = new Metasync_otto_pixel($otto_uuid);
1018 1036
1019 1037 # call render
@@ -1045,9 +1063,9 @@
1045 1063 if ($cached !== false) {
1046 1064 $has_brizy_posts = ($cached === 'yes');
1047 1065 } else {
1048 1066 # Query database only if cache missed
1049 - $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
1050 1068 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1051 1069 WHERE meta_key = 'brizy_post_uid'
1052 1070 AND meta_value != ''
1053 1071 LIMIT 1"
@@ -1507,11 +1525,13 @@
1507 1525 # Disable Rank Math
1508 1526 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1509 1527 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1510 1528
1511 - if ($block_title) {
1512 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1513 - }
1529 + # TITLE: Never blank Rank Math's title output during SSR fetch.
1530 + # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1531 + # output, so forcing it empty blanks those social tags along with the
1532 + # document title. Let Rank Math render its own title/og/twitter tags;
1533 + # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
1514 1534
1515 1535 if ($block_description) {
1516 1536 add_filter('rank_math/frontend/description', '__return_false', 999);
1517 1537 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -1645,8 +1665,17 @@
1645 1665
1646 1666 # Handle AJAX Clear Cache request
1647 1667 # NOTE: Cache system removed - this is now a no-op
1648 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 +
1649 1678 if (!empty($_GET['clear_otto_cache'])) {
1650 1679 delete_transient('metasync_otto_js_detected');
1651 1680 # Re-run the JS detection in the background so the notice refreshes.
1652 1681 metasync_schedule_otto_js_check();
@@ -1783,9 +1812,8 @@
1783 1812 }
1784 1813
1785 1814 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1786 1815 if (!metasync_otto_is_url_available($route)) {
1787 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1788 1816 metasync_otto_auto_exclude_404_url($route);
1789 1817 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1790 1818 }
1791 1819
@@ -1824,9 +1852,9 @@
1824 1852 }
1825 1853
1826 1854 # Mark this URL as crawled by OTTO for SSR
1827 1855 # Extract domain and path from route
1828 - $parsed_url = parse_url($route);
1856 + $parsed_url = wp_parse_url($route);
1829 1857 $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1830 1858 $url_path = ($parsed_url['path'] ?? '/');
1831 1859
1832 1860 # Create crawl data structure
@@ -1859,9 +1887,9 @@
1859 1887
1860 1888 # Try to find WooCommerce product by URL if url_to_postid failed
1861 1889 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1862 1890 # Extract product slug from URL
1863 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1891 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1864 1892
1865 1893 # Try to get product by slug
1866 1894 $products = wc_get_products(array(
1867 1895 'name' => $product_slug,
@@ -1892,15 +1920,14 @@
1892 1920 if (!$post_id || $post_id <= 0) {
1893 1921 # Check if this is a category page
1894 1922 if (strpos($route, '/category/') !== false) {
1895 1923 # Extract category slug from URL
1896 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1924 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1897 1925 $category = get_category_by_slug($category_slug);
1898 1926
1899 1927 if ($category) {
1900 1928 # Check if category would return 404 before applying OTTO changes
1901 1929 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1902 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1903 1930 metasync_otto_auto_exclude_404_url($route);
1904 1931 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1905 1932 }
1906 1933
@@ -1965,8 +1992,12 @@
1965 1992 $title = 'Category Structured Data Update';
1966 1993 break;
1967 1994 }
1968 1995
1996 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
1997 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
1998 + }
1999 +
1969 2000 if (!empty($title)) {
1970 2001 metasync_log_sync_history([
1971 2002 'title' => $title,
1972 2003 'source' => 'OTTO SEO',
@@ -1992,15 +2023,14 @@
1992 2023
1993 2024 # Check if this is a WooCommerce product category
1994 2025 if (strpos($route, '/product-category/') !== false) {
1995 2026 # Extract product category slug from URL
1996 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2027 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1997 2028 $term = get_term_by('slug', $category_slug, 'product_cat');
1998 2029
1999 2030 if ($term && !is_wp_error($term)) {
2000 2031 # Check if product category would return 404 before applying OTTO changes
2001 2032 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2002 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
2003 2033 metasync_otto_auto_exclude_404_url($route);
2004 2034 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2005 2035 }
2006 2036
@@ -2065,8 +2095,12 @@
2065 2095 $title = 'Product Category Structured Data Update';
2066 2096 break;
2067 2097 }
2068 2098
2099 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2100 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2101 + }
2102 +
2069 2103 if (!empty($title)) {
2070 2104 metasync_log_sync_history([
2071 2105 'title' => $title,
2072 2106 'source' => 'OTTO SEO',
@@ -2109,9 +2143,8 @@
2109 2143
2110 2144 if ($home_page) {
2111 2145 # Check if home page would return 404 before applying OTTO changes
2112 2146 if (metasync_would_page_return_404($home_page->ID, $route)) {
2113 - 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})");
2114 2147 metasync_otto_auto_exclude_404_url($route);
2115 2148 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2116 2149 }
2117 2150
@@ -2179,8 +2212,12 @@
2179 2212 $title = 'Home Page Structured Data Update';
2180 2213 break;
2181 2214 }
2182 2215
2216 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2217 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2218 + }
2219 +
2183 2220 if (!empty($title)) {
2184 2221 metasync_log_sync_history([
2185 2222 'title' => $title,
2186 2223 'source' => 'OTTO SEO',
@@ -2211,9 +2248,8 @@
2211 2248
2212 2249 if ($posts_page && $route_clean === $posts_page_url) {
2213 2250 # Check if blog page would return 404
2214 2251 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2215 - 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})");
2216 2252 metasync_otto_auto_exclude_404_url($route);
2217 2253 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2218 2254 }
2219 2255
@@ -2281,8 +2317,12 @@
2281 2317 $title = 'Blog Page Structured Data Update';
2282 2318 break;
2283 2319 }
2284 2320
2321 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2322 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2323 + }
2324 +
2285 2325 if (!empty($title)) {
2286 2326 metasync_log_sync_history([
2287 2327 'title' => $title,
2288 2328 'source' => 'OTTO SEO',
@@ -2307,9 +2347,8 @@
2307 2347
2308 2348 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2309 2349 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2310 2350 if (!metasync_otto_is_url_available($route)) {
2311 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
2312 2351 metasync_otto_auto_exclude_404_url($route);
2313 2352 }
2314 2353 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2315 2354 }
@@ -2326,9 +2365,8 @@
2326 2365 }
2327 2366
2328 2367 # Check if page would return 404 before applying OTTO changes
2329 2368 if (metasync_would_page_return_404($post_id, $route)) {
2330 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
2331 2369 metasync_otto_auto_exclude_404_url($route);
2332 2370 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2333 2371 }
2334 2372
@@ -2396,8 +2434,12 @@
2396 2434 $title = 'Structured Data Update';
2397 2435 break;
2398 2436 }
2399 2437
2438 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2439 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2440 + }
2441 +
2400 2442 if (!empty($title)) {
2401 2443 metasync_log_sync_history([
2402 2444 'title' => $title,
2403 2445 'source' => 'OTTO SEO',
@@ -2442,11 +2484,10 @@
2442 2484
2443 2485 // Minimal duplicate prevention within short time window
2444 2486 if (!empty($data['title']) && !empty($data['source'])) {
2445 2487 global $wpdb;
2446 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2447 - $recent = $wpdb->get_var($wpdb->prepare(
2448 - "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",
2449 2490 $data['title'],
2450 2491 $data['source'],
2451 2492 gmdate('Y-m-d H:i:s', time() - 60)
2452 2493 ));
@@ -2457,9 +2498,9 @@
2457 2498
2458 2499 $sync_db->add($data);
2459 2500
2460 2501 } catch (Exception $e) {
2461 - 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
2462 2503 }
2463 2504 }
2464 2505
2465 2506 /**
@@ -2535,9 +2576,9 @@
2535 2576 $db = new Metasync_Redirection_Database();
2536 2577 $redirect = new Metasync_Redirection($db);
2537 2578 return $redirect->resolve_url_to_final_destination($url, 10);
2538 2579 } catch (Exception $e) {
2539 - 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
2540 2581 return $url;
2541 2582 }
2542 2583 }
2543 2584
@@ -2568,9 +2609,9 @@
2568 2609 'status' => 'active',
2569 2610 'auto_excluded' => 1,
2570 2611 ]);
2571 2612 } catch (Exception $e) {
2572 - 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
2573 2614 return false;
2574 2615 }
2575 2616 }
2576 2617
@@ -2590,14 +2631,13 @@
2590 2631 try {
2591 2632 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2592 2633 $db = new Metasync_Otto_Excluded_URLs_Database();
2593 2634 global $wpdb;
2594 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2595 2635 # Normalize the same way is_url_excluded() does
2596 2636 $url_normalized = rtrim(trim($url), '/');
2597 - $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
2598 2638 $wpdb->prepare(
2599 - "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'",
2600 2640 $url_normalized
2601 2641 )
2602 2642 );
2603 2643 if (!empty($records)) {
@@ -2627,24 +2667,26 @@
2627 2667 }
2628 2668 try {
2629 2669 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2630 2670 global $wpdb;
2631 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2632 2671 $url_normalized = rtrim(trim($url), '/');
2633 2672
2634 2673 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2635 2674
2636 2675 if ($records === false) {
2637 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2638 - $records = $wpdb->get_results(
2639 - "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"
2640 2678 );
2641 2679
2642 2680 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2643 - // 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).
2644 2685 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2645 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2646 - $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 + }
2647 2689 return false;
2648 2690 }
2649 2691
2650 2692 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2720,9 +2762,9 @@
2720 2762 }
2721 2763
2722 2764 # WooCommerce product by slug
2723 2765 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2724 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2766 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2725 2767 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2726 2768 if (!empty($products)) {
2727 2769 $post_id = $products[0]->get_id();
2728 2770 } else {
@@ -2749,9 +2791,9 @@
2749 2791 }
2750 2792
2751 2793 # Category
2752 2794 if (strpos($route, '/category/') !== false) {
2753 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2795 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2754 2796 $category = get_category_by_slug($category_slug);
2755 2797 if ($category) {
2756 2798 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2757 2799 }
@@ -2758,9 +2800,9 @@
2758 2800 }
2759 2801
2760 2802 # WooCommerce product category
2761 2803 if (strpos($route, '/product-category/') !== false) {
2762 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2804 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2763 2805 $term = get_term_by('slug', $category_slug, 'product_cat');
2764 2806 if ($term && !is_wp_error($term)) {
2765 2807 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2766 2808 }
@@ -2802,9 +2844,9 @@
2802 2844
2803 2845 $removed = 0;
2804 2846 $marked_permanent = 0;
2805 2847 $thirty_days_ago = strtotime('-30 days');
2806 - $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);
2807 2849
2808 2850 foreach ($records as $record) {
2809 2851 $url = trim($record->url_pattern);
2810 2852 if (empty($url)) {
@@ -2825,16 +2867,10 @@
2825 2867 }
2826 2868 }
2827 2869 }
2828 2870
2829 - if ($removed > 0) {
2830 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2831 - }
2832 - if ($marked_permanent > 0) {
2833 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2834 - }
2835 2871 } catch (Exception $e) {
2836 - 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
2837 2873 }
2838 2874 }
2839 2875
2840 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2839,8 +2875,24 @@
2839 2875
2840 2876 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2841 2877
2842 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 +/**
2843 2895 * Check if a post/page would return 404 without making HTTP request
2844 2896 * Uses WordPress database checks for fast validation
2845 2897 *
2846 2898 * @param int $post_id WordPress post ID
@@ -3016,9 +3068,9 @@
3016 3068 }
3017 3069 }
3018 3070
3019 3071 // WP Cloud's warmer also tags its requests with a query marker.
3020 - 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
3021 3073 $is_infra = true;
3022 3074 }
3023 3075
3024 3076 /**