PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/otto_pixel.php +276 -149 2.6.24 → 2.7.2 View file →
@@ -16,14 +16,37 @@
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';
25 47 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-job-status.php';
48 +require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-webhook-audit.php';
26 49 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
27 50 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
28 51 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
29 52 require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';
@@ -77,9 +100,9 @@
77 100 # Use endpoint manager to get the correct domain
78 101 $otto_domain = 'sa.searchatlas.com'; # default
79 102 if (class_exists('Metasync_Endpoint_Manager')) {
80 103 $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
81 - $parsed = parse_url($otto_api_domain);
104 + $parsed = wp_parse_url($otto_api_domain);
82 105 if (!empty($parsed['host'])) {
83 106 $otto_domain = $parsed['host'];
84 107 }
85 108 }
@@ -91,8 +114,9 @@
91 114 # OPTIMIZED: use cached uuid
92 115 $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';
93 116
94 117 # out the otto tag
118 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- meta tag assembled on the line above with esc_attr() on every interpolated value
95 119 echo $otto_tag;
96 120 }, 1); # Priority 1 to output early in head
97 121
98 122 /**
@@ -143,8 +167,13 @@
143 167
144 168 # function to register the route
145 169 function metasync_otto_crawl_notify($request){
146 170
171 + # receipt time is captured before validation and scheduling, so the audit
172 + # record describes when WordPress received the delivery, not when its work
173 + # happened to finish.
174 + $received_at = time();
175 +
147 176 # get request data params
148 177 $data = $request->get_json_params();
149 178
150 179 # fields
@@ -164,14 +193,29 @@
164 193 }
165 194
166 195 }
167 196
168 - # load otto pixel
169 - $otto_pixel = new Metasync_otto_pixel(false);
197 + # The audit trail is deliberately after payload validation: invalid requests
198 + # receive the existing 400 response and are not recorded as deliveries.
199 + # It is also before any scheduling so a valid delivery is captured even if
200 + # later work is deferred or rejected. The failure guard is intentionally
201 + # broader than Exception: a broken option adapter or stale plugin copy must
202 + # never turn an otherwise acknowledged webhook into a 500 response.
203 + try {
204 + if (metasync_otto_class_provides('Metasync_Otto_Webhook_Audit', 'record')) {
205 + Metasync_Otto_Webhook_Audit::record(
206 + $received_at,
207 + $data['domain'],
208 + $data['urls']
209 + );
210 + }
211 + } catch (Throwable $metasync_otto_audit_failure) {
212 + error_log(
213 + 'MetaSync OTTO: failed to record crawl-notify webhook audit entry: '
214 + . $metasync_otto_audit_failure->getMessage()
215 + ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- audit failure must not break webhook acknowledgement
216 + }
170 217
171 - # save the otto data first (cheap local DB write — kept synchronous)
172 - $otto_pixel->save_crawl_data($data);
173 -
174 218 # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
175 219 # per-URL cache purge) to background jobs. The webhook caller enforces a
176 220 # strict response-time budget, so the response acknowledges SCHEDULING,
177 221 # never execution. The per-URL status store records what actually
@@ -301,9 +345,9 @@
301 345 // event may therefore invoke this callback without its required route.
302 346 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
303 347 // @phpstan-ignore-next-line function.alreadyNarrowedType
304 348 if (!is_string($route) || $route === '') {
305 - error_log('MetaSync OTTO: skipping crawl-url job without a valid route.');
349 + 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 350 return;
307 351 }
308 352
309 353 $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;
@@ -319,13 +363,13 @@
319 363 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
320 364 } else {
321 365 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
322 366 }
323 - error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's');
367 + 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 368 } else {
325 369 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
326 370 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);
371 + 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 372 }
329 373 };
330 374
331 375 try {
@@ -350,9 +394,9 @@
350 394 # attempt budget on warm calls that will keep being rejected.
351 395 if ($transient_cache->last_failure_is_permanent()) {
352 396 metasync_record_failed_action('metasync_process_otto_crawl_url_job');
353 397 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)');
398 + 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 399 return;
356 400 }
357 401 # Transient API failure (timeout / 5xx / breaker open). No meta
358 402 # write, no purge — straight to the retry path.
@@ -402,10 +446,32 @@
402 446 }
403 447
404 448 Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);
405 449
406 - } catch (Exception $e) {
407 - $schedule_retry('exception:' . $e->getMessage());
450 + } catch (\Throwable $e) {
451 + # Cron must survive both Exception and PHP 7+ Error failures. Always use
452 + # the same bounded retry path, and emit structured context without the URL
453 + # payload or credentials. The retry is booked BEFORE the log attempt so a
454 + # failing logger can never swallow the retry; logging is best-effort.
455 + $schedule_retry('throwable:' . get_class($e));
456 + if (class_exists('Metasync_Error_Logger')) {
457 + try {
458 + Metasync_Error_Logger::log(
459 + Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
460 + Metasync_Error_Logger::SEVERITY_ERROR,
461 + 'OTTO crawl-url job threw; retry policy applied',
462 + [
463 + 'operation' => 'crawl_url_job',
464 + 'outcome' => 'throwable',
465 + 'retry_count' => (int) $retry_count,
466 + 'exception' => get_class($e),
467 + ]
468 + );
469 + } catch (\Throwable $logger_error) {
470 + # Logging must never kill the handler after the retry is booked.
471 + error_log('MetaSync OTTO: crawl-url job error log failed: ' . $logger_error->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
472 + }
473 + }
408 474 }
409 475 }
410 476
411 477 /**
@@ -429,9 +495,9 @@
429 495 // event may therefore invoke this callback without its required route list.
430 496 // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
431 497 // @phpstan-ignore-next-line function.alreadyNarrowedType
432 498 if (!is_array($routes) || empty($routes)) {
433 - error_log('MetaSync OTTO: skipping batch cache job without valid routes.');
499 + 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 500 return;
435 501 }
436 502
437 503 $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
@@ -473,9 +539,9 @@
473 539 # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
474 540 Metasync_Edge_Cache_Purge::purge($ready);
475 541 } catch (Exception $e) {
476 542 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());
543 + 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 544 }
479 545 }
480 546
481 547 /**
@@ -522,42 +588,8 @@
522 588 metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
523 589 }
524 590 }
525 591
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 592 // metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
561 593 // (loaded unconditionally before this file) so all SEO surfaces share one rule.
562 594
563 595 /**
@@ -580,15 +612,15 @@
580 612 */
581 613 function metasync_is_elementor_editor_request() {
582 614 # The preview iframe loads the front end with ?elementor-preview=POST_ID.
583 615 # This is the request OTTO would otherwise buffer and corrupt.
584 - if (isset($_GET['elementor-preview'])) {
616 + if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
585 617 return true;
586 618 }
587 619
588 620 # 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)
621 + if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
622 + && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
591 623 ) {
592 624 return true;
593 625 }
594 626
@@ -639,10 +671,10 @@
639 671 # and OTTO's route identity ignores the query string, so a processed preview
640 672 # is answered with the route's public page (the homepage for /?p=…) instead
641 673 # of the draft the editor asked for. Skip early and let WP render it.
642 674 if (
643 - (isset($_GET['preview']) && $_GET['preview'] === 'true') ||
644 - !empty($_GET['preview_id'])
675 + (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
676 + !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
645 677 ) {
646 678 if (!defined('DONOTCACHEPAGE')) {
647 679 define('DONOTCACHEPAGE', true);
648 680 }
@@ -682,9 +714,9 @@
682 714 # then lower-case and strip surrounding slashes.
683 715 $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
684 716 $otto_req_path = strtolower(trim($otto_req_path, '/'));
685 717 # 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), '/');
718 + $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
687 719 if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
688 720 $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
689 721 }
690 722 $otto_first_segment = explode('/', $otto_req_path, 2)[0];
@@ -702,11 +734,11 @@
702 734 # ──────────────────────────────────────────────────────────────────────
703 735
704 736 if (
705 737 # disable ajax calls
706 - isset($_GET['ucfrontajaxaction']) ||
738 + isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
707 739 # OTTO Preview mode - skip OTTO when previewing original content
708 - (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') ||
740 + (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
709 741 # WooCommerce shop archive page only (products and categories now use SSR)
710 742 //(function_exists('is_shop') && is_shop()) ||
711 743 # Cart page
712 744 (function_exists('is_cart') && is_cart()) ||
@@ -718,32 +750,32 @@
718 750 (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
719 751 # check by constant
720 752 (defined('DOING_AJAX') && DOING_AJAX) ||
721 753 # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
722 - (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) ||
754 + (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
723 755 # AJAX requests via X-Requested-With header
724 756 (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
725 757 # 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']))
758 + (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
759 + is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
760 + (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 761 )) ||
730 762 # Gravity Forms AJAX submission
731 - (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && (
732 - is_array($_POST['gform_submit']) || !empty($_POST['gform_submit'])
763 + (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
764 + is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
733 765 )) ||
734 766 # Gravity Forms file upload
735 - (isset($_POST['gform_uploaded_files'])) ||
767 + (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
736 768 # Any Gravity Forms POST parameter
737 - (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) ||
769 + (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 770 # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
739 - (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') ||
771 + (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
740 772 # Formidable Forms POST parameters
741 - (isset($_POST['form_id']) && !empty($_POST['form_id'])) ||
773 + (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
742 774 # Formidable Forms action parameter
743 - (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) ||
775 + (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
744 776 # Formidable Forms item_key (used in form submissions)
745 - (isset($_POST['item_key']) && !empty($_POST['item_key']))
777 + (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
746 778 ) {
747 779 return;
748 780 }
749 781
@@ -812,8 +844,27 @@
812 844 if ( $detection['is_bot'] ) {
813 845 $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
814 846 }
815 847
848 + # A headless frontend renders the public HTML, so stop after the cheap cache
849 + # protections and crawl telemetry, before any OTTO rendering work begins.
850 + #
851 + # Routed through metasync_headless_is_active() rather than calling the class
852 + # directly: Metasync_Headless_Config resolves only through the committed
853 + # Composer classmap, so a partially updated install can leave this newer
854 + # caller beside a missing or older copy with no fallback lookup path. This
855 + # function is hooked to `wp`, so an unguarded static call is a white screen
856 + # on every front-end page view. The helper treats an unresolvable class as
857 + # "not headless", which keeps OTTO behaving as it did before headless mode.
858 + # @phpstan-ignore-next-line function.alreadyNarrowedType
859 + $metasync_is_headless = function_exists('metasync_headless_is_active')
860 + ? metasync_headless_is_active()
861 + : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());
862 +
863 + if ($metasync_is_headless) {
864 + return;
865 + }
866 +
816 867 # Throttle OTTO rendering for bots that are not exempt: at most one render
817 868 # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
818 869 # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
819 870 # synthetic performance auditors are never throttled — their hits always get
@@ -882,9 +933,9 @@
882 933 return;
883 934 }
884 935
885 936 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
886 - if (class_exists('WP_Rocket')) {
937 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
887 938 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
888 939
889 940 if ($wp_rocket_compat_mode === 'disable_otto') {
890 941 return; # Exit early, Otto is disabled when WP Rocket is active
@@ -890,14 +941,25 @@
890 941 return; # Exit early, Otto is disabled when WP Rocket is active
891 942 }
892 943 }
893 944
894 - # Skip OTTO for Divi AJAX pagination and paginated archive requests.
895 - # ?et_blog = Divi AJAX pagination callback
945 + # Skip OTTO for Divi AJAX pagination, and for paginated archive requests
946 + # on Divi-built sites.
947 + # ?et_blog = Divi AJAX pagination callback — always skipped, on every
948 + # theme, because Divi fires it regardless of the site layout.
896 949 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
897 950 # module numbering mismatch between page 1 (with TB template) and page N
898 951 # (without TB template), breaking Divi's JS pagination selector matching.
899 - if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) {
952 + # That mismatch is a Divi rendering bug, so the /page/N/ skip is gated on
953 + # Divi detection: non-Divi sites (Elementor + the7, ...) keep OTTO on real
954 + # paginated archives. Detection covers the Divi theme and its child themes
955 + # (get_template() returns the PARENT template dir, "Divi" either way), the
956 + # Extra theme and the Divi Builder plugin (both define ET_BUILDER_VERSION
957 + # / et_setup_theme — same TB machinery, same bug).
958 + $is_divi = defined('ET_BUILDER_VERSION')
959 + || function_exists('et_setup_theme')
960 + || wp_get_theme()->get_template() === 'Divi';
961 + if (isset($_GET['et_blog']) || ($is_divi && is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
900 962 return;
901 963 }
902 964
903 965 # Skip OTTO on search-results pages. A search request carries its
@@ -912,9 +974,9 @@
912 974 # correct pre-rendered search page to everyone else. Search-results pages
913 975 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
914 976 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
915 977 # the main query is altered before this point.
916 - if (is_search() || !empty($_GET['s'])) {
978 + if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
917 979 return;
918 980 }
919 981
920 982 # Handle cache plugin compatibility early - before any caching happens
@@ -966,9 +1028,9 @@
966 1028 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
967 1029
968 1030 $otto_is_internal_fetch = $otto_has_fetch_detector
969 1031 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
970 - : !empty($_GET['is_otto_page_fetch']);
1032 + : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
971 1033
972 1034 if($otto_is_internal_fetch){
973 1035
974 1036 # Block SEO plugins NOW for this internal fetch request
@@ -973,10 +1035,10 @@
973 1035
974 1036 # Block SEO plugins NOW for this internal fetch request
975 1037 # metasync_otto_block_seo_plugins();
976 1038 # $_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';
1039 + $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
1040 + $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 1041
980 1042 # Block SEO plugins conditionally based on what Otto has
981 1043 if ($block_title || $block_description) {
982 1044 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -1012,8 +1074,19 @@
1012 1074
1013 1075 # get the otto uuid
1014 1076 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1015 1077
1078 + # Upgrade-window guard: the bundled DOM parser resolves only through the
1079 + # otto/vendor autoloader required at the top of this file. While an update
1080 + # is mid-copy (or a stale opcache classmap is still resident), that
1081 + # dependency can briefly be undefined here, and constructing the render
1082 + # stack below would throw a class-not-found Error and take the whole page
1083 + # down with it. Degrade to an OTTO-less page for this request instead —
1084 + # the next request, on the settled tree, renders normally.
1085 + if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
1086 + return;
1087 + }
1088 +
1016 1089 # start the class
1017 1090 $otto = new Metasync_otto_pixel($otto_uuid);
1018 1091
1019 1092 # call render
@@ -1029,9 +1102,9 @@
1029 1102 # Detect active plugins. SG Optimizer detection now lives in
1030 1103 # metasync_otto_disable_sg_page_cache(), which is invoked later
1031 1104 # once OTTO confirms suggestions for the URL.
1032 1105 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
1033 - $wp_rocket_active = class_exists('WP_Rocket');
1106 + $wp_rocket_active = defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket');
1034 1107
1035 1108 # OPTIMIZED: Get configuration option using cached config
1036 1109 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
1037 1110
@@ -1045,9 +1118,9 @@
1045 1118 if ($cached !== false) {
1046 1119 $has_brizy_posts = ($cached === 'yes');
1047 1120 } else {
1048 1121 # Query database only if cache missed
1049 - $has_brizy_posts = $wpdb->get_var(
1122 + $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 1123 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1051 1124 WHERE meta_key = 'brizy_post_uid'
1052 1125 AND meta_value != ''
1053 1126 LIMIT 1"
@@ -1104,9 +1177,9 @@
1104 1177 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1105 1178 */
1106 1179 function metasync_otto_disable_sg_page_cache() {
1107 1180 # Only relevant when SG Optimizer is active and WP Rocket is not present.
1108 - if (class_exists('WP_Rocket')) {
1181 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
1109 1182 return;
1110 1183 }
1111 1184 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1112 1185 return;
@@ -1415,8 +1488,29 @@
1415 1488 }
1416 1489 }
1417 1490
1418 1491 /**
1492 + * Whether MetaSync is still emitting Open Graph / Twitter tags.
1493 + *
1494 + * The Social Media & Open Graph switch stops MetaSync writing those tags on
1495 + * every render path, so the suppression below has to stand down with it —
1496 + * removing another plugin's og:/twitter: tag while contributing none of our own
1497 + * would leave the page with no social tags at all.
1498 + *
1499 + * The class_exists() guard keeps this usable if the flags file has not loaded
1500 + * yet; treating that window as "enabled" preserves the previous behaviour.
1501 + *
1502 + * @return bool
1503 + */
1504 +function metasync_otto_social_output_enabled() {
1505 + if (!class_exists('Metasync_Feature_Flags')) {
1506 + return true;
1507 + }
1508 +
1509 + return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
1510 +}
1511 +
1512 +/**
1419 1513 * Block SEO plugins conditionally based on what Otto is providing
1420 1514 * Only blocks title if Otto has title, only blocks description if Otto has description
1421 1515 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
1422 1516 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
@@ -1456,11 +1550,19 @@
1456 1550
1457 1551
1458 1552 # Remove description presenters only when OTTO has a description
1459 1553 if ($block_description) {
1554 + # The plain meta description belongs to the SEO title/description
1555 + # feature, which has its own setting.
1460 1556 $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';
1461 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1462 - $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1557 +
1558 + # The OG/Twitter descriptions are social tags. With that feature
1559 + # switched off MetaSync writes none of its own, so Yoast's must be
1560 + # left alone rather than removed with nothing to replace them.
1561 + if (metasync_otto_social_output_enabled()) {
1562 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
1563 + $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
1564 + }
1463 1565 }
1464 1566
1465 1567 foreach ($presenters as $key => $presenter) {
1466 1568 // Safely get class name, suppressing autoload errors
@@ -1478,11 +1580,13 @@
1478 1580 # Disable Rank Math
1479 1581 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1480 1582 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1481 1583
1482 - if ($block_title) {
1483 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1484 - }
1584 + # TITLE: Never blank Rank Math's title output during SSR fetch.
1585 + # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
1586 + # output, so forcing it empty blanks those social tags along with the
1587 + # document title. Let Rank Math render its own title/og/twitter tags;
1588 + # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.
1485 1589
1486 1590 if ($block_description) {
1487 1591 add_filter('rank_math/frontend/description', '__return_false', 999);
1488 1592 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -1493,17 +1597,22 @@
1493 1597 if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
1494 1598 is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {
1495 1599
1496 1600 if ($block_title) {
1601 + # The document title belongs to the SEO title feature, which has its
1602 + # own setting; only the og:/twitter: unsets below are social.
1497 1603 add_filter('aioseo_title', '__return_empty_string', 999);
1498 - add_filter('aioseo_facebook_tags', function($meta) {
1499 - if (is_array($meta)) { unset($meta['og:title']); }
1500 - return $meta;
1501 - }, 999);
1502 - add_filter('aioseo_twitter_tags', function($meta) {
1503 - if (is_array($meta)) { unset($meta['twitter:title']); }
1504 - return $meta;
1505 - }, 999);
1604 +
1605 + if (metasync_otto_social_output_enabled()) {
1606 + add_filter('aioseo_facebook_tags', function($meta) {
1607 + if (is_array($meta)) { unset($meta['og:title']); }
1608 + return $meta;
1609 + }, 999);
1610 + add_filter('aioseo_twitter_tags', function($meta) {
1611 + if (is_array($meta)) { unset($meta['twitter:title']); }
1612 + return $meta;
1613 + }, 999);
1614 + }
1506 1615 }
1507 1616
1508 1617 if ($block_description) {
1509 1618 # Use granular tag list when available to only block what OTTO provides
@@ -1508,10 +1617,12 @@
1508 1617 if ($block_description) {
1509 1618 # Use granular tag list when available to only block what OTTO provides
1510 1619 $tags = !empty($description_tags) ? $description_tags : [];
1511 1620 $block_standard = empty($tags) || in_array('meta[name=description]', $tags);
1512 - $block_og_desc = empty($tags) || in_array('meta[property=og:description]', $tags);
1513 - $block_tw_desc = empty($tags) || in_array('meta[name=twitter:description]', $tags);
1621 + # Social tags only get blocked while MetaSync is still emitting them.
1622 + $social_enabled = metasync_otto_social_output_enabled();
1623 + $block_og_desc = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
1624 + $block_tw_desc = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));
1514 1625
1515 1626 if ($block_standard) {
1516 1627 add_filter('aioseo_description', '__return_empty_string', 999);
1517 1628 }
@@ -1609,8 +1720,17 @@
1609 1720
1610 1721 # Handle AJAX Clear Cache request
1611 1722 # NOTE: Cache system removed - this is now a no-op
1612 1723 function metasync_clear_otto_cache_handler() {
1724 + # State-changing admin action: verify the nonce sent by admin/js
1725 + # (clear_otto_caches) and confirm plugin access before clearing the
1726 + # detection transient or queueing the loopback re-check.
1727 + check_ajax_referer('metasync_clear_otto_cache', 'nonce');
1728 +
1729 + if (!Metasync::current_user_has_plugin_access()) {
1730 + wp_send_json_error(['message' => 'Not authorized']);
1731 + }
1732 +
1613 1733 if (!empty($_GET['clear_otto_cache'])) {
1614 1734 delete_transient('metasync_otto_js_detected');
1615 1735 # Re-run the JS detection in the background so the notice refreshes.
1616 1736 metasync_schedule_otto_js_check();
@@ -1747,9 +1867,8 @@
1747 1867 }
1748 1868
1749 1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1750 1870 if (!metasync_otto_is_url_available($route)) {
1751 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1752 1871 metasync_otto_auto_exclude_404_url($route);
1753 1872 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1754 1873 }
1755 1874
@@ -1786,24 +1905,8 @@
1786 1905 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1787 1906 }
1788 1907 }
1789 1908
1790 - # Mark this URL as crawled by OTTO for SSR
1791 - # Extract domain and path from route
1792 - $parsed_url = parse_url($route);
1793 - $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1794 - $url_path = ($parsed_url['path'] ?? '/');
1795 -
1796 - # Create crawl data structure
1797 - $crawl_data = array(
1798 - 'domain' => $domain_with_scheme,
1799 - 'urls' => array($url_path)
1800 - );
1801 -
1802 - # Load Otto pixel class and save crawl data
1803 - $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1804 - $otto_pixel->save_crawl_data($crawl_data);
1805 -
1806 1909 # Get WordPress post ID from URL
1807 1910 $post_id = url_to_postid($route);
1808 1911
1809 1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
@@ -1823,9 +1926,9 @@
1823 1926
1824 1927 # Try to find WooCommerce product by URL if url_to_postid failed
1825 1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1826 1929 # Extract product slug from URL
1827 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1930 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1828 1931
1829 1932 # Try to get product by slug
1830 1933 $products = wc_get_products(array(
1831 1934 'name' => $product_slug,
@@ -1856,15 +1959,14 @@
1856 1959 if (!$post_id || $post_id <= 0) {
1857 1960 # Check if this is a category page
1858 1961 if (strpos($route, '/category/') !== false) {
1859 1962 # Extract category slug from URL
1860 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1963 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1861 1964 $category = get_category_by_slug($category_slug);
1862 1965
1863 1966 if ($category) {
1864 1967 # Check if category would return 404 before applying OTTO changes
1865 1968 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1866 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1867 1969 metasync_otto_auto_exclude_404_url($route);
1868 1970 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1869 1971 }
1870 1972
@@ -1929,8 +2031,12 @@
1929 2031 $title = 'Category Structured Data Update';
1930 2032 break;
1931 2033 }
1932 2034
2035 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2036 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2037 + }
2038 +
1933 2039 if (!empty($title)) {
1934 2040 metasync_log_sync_history([
1935 2041 'title' => $title,
1936 2042 'source' => 'OTTO SEO',
@@ -1956,15 +2062,14 @@
1956 2062
1957 2063 # Check if this is a WooCommerce product category
1958 2064 if (strpos($route, '/product-category/') !== false) {
1959 2065 # Extract product category slug from URL
1960 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2066 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1961 2067 $term = get_term_by('slug', $category_slug, 'product_cat');
1962 2068
1963 2069 if ($term && !is_wp_error($term)) {
1964 2070 # Check if product category would return 404 before applying OTTO changes
1965 2071 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
1966 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
1967 2072 metasync_otto_auto_exclude_404_url($route);
1968 2073 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1969 2074 }
1970 2075
@@ -2029,8 +2134,12 @@
2029 2134 $title = 'Product Category Structured Data Update';
2030 2135 break;
2031 2136 }
2032 2137
2138 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2139 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2140 + }
2141 +
2033 2142 if (!empty($title)) {
2034 2143 metasync_log_sync_history([
2035 2144 'title' => $title,
2036 2145 'source' => 'OTTO SEO',
@@ -2073,9 +2182,8 @@
2073 2182
2074 2183 if ($home_page) {
2075 2184 # Check if home page would return 404 before applying OTTO changes
2076 2185 if (metasync_would_page_return_404($home_page->ID, $route)) {
2077 - 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})");
2078 2186 metasync_otto_auto_exclude_404_url($route);
2079 2187 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2080 2188 }
2081 2189
@@ -2143,8 +2251,12 @@
2143 2251 $title = 'Home Page Structured Data Update';
2144 2252 break;
2145 2253 }
2146 2254
2255 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2256 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2257 + }
2258 +
2147 2259 if (!empty($title)) {
2148 2260 metasync_log_sync_history([
2149 2261 'title' => $title,
2150 2262 'source' => 'OTTO SEO',
@@ -2175,9 +2287,8 @@
2175 2287
2176 2288 if ($posts_page && $route_clean === $posts_page_url) {
2177 2289 # Check if blog page would return 404
2178 2290 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2179 - 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})");
2180 2291 metasync_otto_auto_exclude_404_url($route);
2181 2292 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2182 2293 }
2183 2294
@@ -2245,8 +2356,12 @@
2245 2356 $title = 'Blog Page Structured Data Update';
2246 2357 break;
2247 2358 }
2248 2359
2360 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2361 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2362 + }
2363 +
2249 2364 if (!empty($title)) {
2250 2365 metasync_log_sync_history([
2251 2366 'title' => $title,
2252 2367 'source' => 'OTTO SEO',
@@ -2271,9 +2386,8 @@
2271 2386
2272 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2273 2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2274 2389 if (!metasync_otto_is_url_available($route)) {
2275 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
2276 2390 metasync_otto_auto_exclude_404_url($route);
2277 2391 }
2278 2392 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2279 2393 }
@@ -2290,9 +2404,8 @@
2290 2404 }
2291 2405
2292 2406 # Check if page would return 404 before applying OTTO changes
2293 2407 if (metasync_would_page_return_404($post_id, $route)) {
2294 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
2295 2408 metasync_otto_auto_exclude_404_url($route);
2296 2409 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2297 2410 }
2298 2411
@@ -2360,8 +2473,12 @@
2360 2473 $title = 'Structured Data Update';
2361 2474 break;
2362 2475 }
2363 2476
2477 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2478 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2479 + }
2480 +
2364 2481 if (!empty($title)) {
2365 2482 metasync_log_sync_history([
2366 2483 'title' => $title,
2367 2484 'source' => 'OTTO SEO',
@@ -2406,11 +2523,10 @@
2406 2523
2407 2524 // Minimal duplicate prevention within short time window
2408 2525 if (!empty($data['title']) && !empty($data['source'])) {
2409 2526 global $wpdb;
2410 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2411 - $recent = $wpdb->get_var($wpdb->prepare(
2412 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
2527 + $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
2528 + "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
2413 2529 $data['title'],
2414 2530 $data['source'],
2415 2531 gmdate('Y-m-d H:i:s', time() - 60)
2416 2532 ));
@@ -2421,9 +2537,9 @@
2421 2537
2422 2538 $sync_db->add($data);
2423 2539
2424 2540 } catch (Exception $e) {
2425 - error_log("MetaSync: Failed to log sync history: " . $e->getMessage());
2541 + 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
2426 2542 }
2427 2543 }
2428 2544
2429 2545 /**
@@ -2499,9 +2615,9 @@
2499 2615 $db = new Metasync_Redirection_Database();
2500 2616 $redirect = new Metasync_Redirection($db);
2501 2617 return $redirect->resolve_url_to_final_destination($url, 10);
2502 2618 } catch (Exception $e) {
2503 - error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage());
2619 + 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
2504 2620 return $url;
2505 2621 }
2506 2622 }
2507 2623
@@ -2532,9 +2648,9 @@
2532 2648 'status' => 'active',
2533 2649 'auto_excluded' => 1,
2534 2650 ]);
2535 2651 } catch (Exception $e) {
2536 - error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage());
2652 + 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
2537 2653 return false;
2538 2654 }
2539 2655 }
2540 2656
@@ -2554,14 +2670,13 @@
2554 2670 try {
2555 2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2556 2672 $db = new Metasync_Otto_Excluded_URLs_Database();
2557 2673 global $wpdb;
2558 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2559 2674 # Normalize the same way is_url_excluded() does
2560 2675 $url_normalized = rtrim(trim($url), '/');
2561 - $records = $wpdb->get_results(
2676 + $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
2562 2677 $wpdb->prepare(
2563 - "SELECT id FROM `{$table}` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2678 + "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
2564 2679 $url_normalized
2565 2680 )
2566 2681 );
2567 2682 if (!empty($records)) {
@@ -2591,24 +2706,26 @@
2591 2706 }
2592 2707 try {
2593 2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2594 2709 global $wpdb;
2595 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2596 2710 $url_normalized = rtrim(trim($url), '/');
2597 2711
2598 2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2599 2713
2600 2714 if ($records === false) {
2601 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2602 - $records = $wpdb->get_results(
2603 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
2715 + $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
2716 + "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"
2604 2717 );
2605 2718
2606 2719 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2607 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
2720 + // Schema changes belong to the upgrade routines, never the render path:
2721 + // degrade to "not excluded" so OTTO keeps rendering, and schedule a
2722 + // one-shot background repair that runs the same column migration off
2723 + // the visitor request (handled by metasync_repair_otto_excluded_schema).
2608 2724 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2609 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2610 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
2725 + if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
2726 + wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
2727 + }
2611 2728 return false;
2612 2729 }
2613 2730
2614 2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2684,9 +2801,9 @@
2684 2801 }
2685 2802
2686 2803 # WooCommerce product by slug
2687 2804 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2688 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2805 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2689 2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2690 2807 if (!empty($products)) {
2691 2808 $post_id = $products[0]->get_id();
2692 2809 } else {
@@ -2713,9 +2830,9 @@
2713 2830 }
2714 2831
2715 2832 # Category
2716 2833 if (strpos($route, '/category/') !== false) {
2717 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2834 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2718 2835 $category = get_category_by_slug($category_slug);
2719 2836 if ($category) {
2720 2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2721 2838 }
@@ -2722,9 +2839,9 @@
2722 2839 }
2723 2840
2724 2841 # WooCommerce product category
2725 2842 if (strpos($route, '/product-category/') !== false) {
2726 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2843 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2727 2844 $term = get_term_by('slug', $category_slug, 'product_cat');
2728 2845 if ($term && !is_wp_error($term)) {
2729 2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2730 2847 }
@@ -2766,9 +2883,9 @@
2766 2883
2767 2884 $removed = 0;
2768 2885 $marked_permanent = 0;
2769 2886 $thirty_days_ago = strtotime('-30 days');
2770 - $next_recheck = date('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2887 + $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);
2771 2888
2772 2889 foreach ($records as $record) {
2773 2890 $url = trim($record->url_pattern);
2774 2891 if (empty($url)) {
@@ -2789,16 +2906,10 @@
2789 2906 }
2790 2907 }
2791 2908 }
2792 2909
2793 - if ($removed > 0) {
2794 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2795 - }
2796 - if ($marked_permanent > 0) {
2797 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2798 - }
2799 2910 } catch (Exception $e) {
2800 - error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage());
2911 + 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
2801 2912 }
2802 2913 }
2803 2914
2804 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2803,8 +2914,24 @@
2803 2914
2804 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2805 2916
2806 2917 /**
2918 + * Background repair of the metasync_otto_excluded_urls schema (cron).
2919 + *
2920 + * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
2921 + * read fails because the auto_excluded column is missing (pre-2.7.4 install
2922 + * whose version migration did not run). Runs the DB layer's self-healing
2923 + * column migration off the visitor request instead of ALTERing inline.
2924 + */
2925 +function metasync_repair_otto_excluded_schema()
2926 +{
2927 + require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2928 + Metasync_Otto_Excluded_URLs_Database::ensure_schema();
2929 +}
2930 +
2931 +add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');
2932 +
2933 +/**
2807 2934 * Check if a post/page would return 404 without making HTTP request
2808 2935 * Uses WordPress database checks for fast validation
2809 2936 *
2810 2937 * @param int $post_id WordPress post ID
@@ -2980,9 +3107,9 @@
2980 3107 }
2981 3108 }
2982 3109
2983 3110 // WP Cloud's warmer also tags its requests with a query marker.
2984 - if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) {
3111 + if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
2985 3112 $is_infra = true;
2986 3113 }
2987 3114
2988 3115 /**