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 +228 -137 2.6.25 → 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;
@@ -1507,11 +1580,13 @@
1507 1580 # Disable Rank Math
1508 1581 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
1509 1582 is_plugin_active('seo-by-rankmath/rank-math.php')) {
1510 1583
1511 - if ($block_title) {
1512 - add_filter('rank_math/frontend/title', '__return_empty_string', 999);
1513 - }
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.
1514 1589
1515 1590 if ($block_description) {
1516 1591 add_filter('rank_math/frontend/description', '__return_false', 999);
1517 1592 add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
@@ -1645,8 +1720,17 @@
1645 1720
1646 1721 # Handle AJAX Clear Cache request
1647 1722 # NOTE: Cache system removed - this is now a no-op
1648 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 +
1649 1733 if (!empty($_GET['clear_otto_cache'])) {
1650 1734 delete_transient('metasync_otto_js_detected');
1651 1735 # Re-run the JS detection in the background so the notice refreshes.
1652 1736 metasync_schedule_otto_js_check();
@@ -1783,9 +1867,8 @@
1783 1867 }
1784 1868
1785 1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1786 1870 if (!metasync_otto_is_url_available($route)) {
1787 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1788 1871 metasync_otto_auto_exclude_404_url($route);
1789 1872 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1790 1873 }
1791 1874
@@ -1822,24 +1905,8 @@
1822 1905 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1823 1906 }
1824 1907 }
1825 1908
1826 - # Mark this URL as crawled by OTTO for SSR
1827 - # Extract domain and path from route
1828 - $parsed_url = parse_url($route);
1829 - $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1830 - $url_path = ($parsed_url['path'] ?? '/');
1831 -
1832 - # Create crawl data structure
1833 - $crawl_data = array(
1834 - 'domain' => $domain_with_scheme,
1835 - 'urls' => array($url_path)
1836 - );
1837 -
1838 - # Load Otto pixel class and save crawl data
1839 - $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1840 - $otto_pixel->save_crawl_data($crawl_data);
1841 -
1842 1909 # Get WordPress post ID from URL
1843 1910 $post_id = url_to_postid($route);
1844 1911
1845 1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
@@ -1859,9 +1926,9 @@
1859 1926
1860 1927 # Try to find WooCommerce product by URL if url_to_postid failed
1861 1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1862 1929 # Extract product slug from URL
1863 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1930 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1864 1931
1865 1932 # Try to get product by slug
1866 1933 $products = wc_get_products(array(
1867 1934 'name' => $product_slug,
@@ -1892,15 +1959,14 @@
1892 1959 if (!$post_id || $post_id <= 0) {
1893 1960 # Check if this is a category page
1894 1961 if (strpos($route, '/category/') !== false) {
1895 1962 # Extract category slug from URL
1896 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1963 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1897 1964 $category = get_category_by_slug($category_slug);
1898 1965
1899 1966 if ($category) {
1900 1967 # Check if category would return 404 before applying OTTO changes
1901 1968 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 1969 metasync_otto_auto_exclude_404_url($route);
1904 1970 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1905 1971 }
1906 1972
@@ -1965,8 +2031,12 @@
1965 2031 $title = 'Category Structured Data Update';
1966 2032 break;
1967 2033 }
1968 2034
2035 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2036 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2037 + }
2038 +
1969 2039 if (!empty($title)) {
1970 2040 metasync_log_sync_history([
1971 2041 'title' => $title,
1972 2042 'source' => 'OTTO SEO',
@@ -1992,15 +2062,14 @@
1992 2062
1993 2063 # Check if this is a WooCommerce product category
1994 2064 if (strpos($route, '/product-category/') !== false) {
1995 2065 # Extract product category slug from URL
1996 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2066 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1997 2067 $term = get_term_by('slug', $category_slug, 'product_cat');
1998 2068
1999 2069 if ($term && !is_wp_error($term)) {
2000 2070 # Check if product category would return 404 before applying OTTO changes
2001 2071 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 2072 metasync_otto_auto_exclude_404_url($route);
2004 2073 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2005 2074 }
2006 2075
@@ -2065,8 +2134,12 @@
2065 2134 $title = 'Product Category Structured Data Update';
2066 2135 break;
2067 2136 }
2068 2137
2138 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2139 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2140 + }
2141 +
2069 2142 if (!empty($title)) {
2070 2143 metasync_log_sync_history([
2071 2144 'title' => $title,
2072 2145 'source' => 'OTTO SEO',
@@ -2109,9 +2182,8 @@
2109 2182
2110 2183 if ($home_page) {
2111 2184 # Check if home page would return 404 before applying OTTO changes
2112 2185 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 2186 metasync_otto_auto_exclude_404_url($route);
2115 2187 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2116 2188 }
2117 2189
@@ -2179,8 +2251,12 @@
2179 2251 $title = 'Home Page Structured Data Update';
2180 2252 break;
2181 2253 }
2182 2254
2255 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2256 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2257 + }
2258 +
2183 2259 if (!empty($title)) {
2184 2260 metasync_log_sync_history([
2185 2261 'title' => $title,
2186 2262 'source' => 'OTTO SEO',
@@ -2211,9 +2287,8 @@
2211 2287
2212 2288 if ($posts_page && $route_clean === $posts_page_url) {
2213 2289 # Check if blog page would return 404
2214 2290 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 2291 metasync_otto_auto_exclude_404_url($route);
2217 2292 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2218 2293 }
2219 2294
@@ -2281,8 +2356,12 @@
2281 2356 $title = 'Blog Page Structured Data Update';
2282 2357 break;
2283 2358 }
2284 2359
2360 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2361 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2362 + }
2363 +
2285 2364 if (!empty($title)) {
2286 2365 metasync_log_sync_history([
2287 2366 'title' => $title,
2288 2367 'source' => 'OTTO SEO',
@@ -2307,9 +2386,8 @@
2307 2386
2308 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2309 2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2310 2389 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 2390 metasync_otto_auto_exclude_404_url($route);
2313 2391 }
2314 2392 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2315 2393 }
@@ -2326,9 +2404,8 @@
2326 2404 }
2327 2405
2328 2406 # Check if page would return 404 before applying OTTO changes
2329 2407 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 2408 metasync_otto_auto_exclude_404_url($route);
2332 2409 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2333 2410 }
2334 2411
@@ -2396,8 +2473,12 @@
2396 2473 $title = 'Structured Data Update';
2397 2474 break;
2398 2475 }
2399 2476
2477 + if (is_scalar($field_value) && trim((string) $field_value) === '') {
2478 + $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
2479 + }
2480 +
2400 2481 if (!empty($title)) {
2401 2482 metasync_log_sync_history([
2402 2483 'title' => $title,
2403 2484 'source' => 'OTTO SEO',
@@ -2442,11 +2523,10 @@
2442 2523
2443 2524 // Minimal duplicate prevention within short time window
2444 2525 if (!empty($data['title']) && !empty($data['source'])) {
2445 2526 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",
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",
2449 2529 $data['title'],
2450 2530 $data['source'],
2451 2531 gmdate('Y-m-d H:i:s', time() - 60)
2452 2532 ));
@@ -2457,9 +2537,9 @@
2457 2537
2458 2538 $sync_db->add($data);
2459 2539
2460 2540 } catch (Exception $e) {
2461 - 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
2462 2542 }
2463 2543 }
2464 2544
2465 2545 /**
@@ -2535,9 +2615,9 @@
2535 2615 $db = new Metasync_Redirection_Database();
2536 2616 $redirect = new Metasync_Redirection($db);
2537 2617 return $redirect->resolve_url_to_final_destination($url, 10);
2538 2618 } catch (Exception $e) {
2539 - 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
2540 2620 return $url;
2541 2621 }
2542 2622 }
2543 2623
@@ -2568,9 +2648,9 @@
2568 2648 'status' => 'active',
2569 2649 'auto_excluded' => 1,
2570 2650 ]);
2571 2651 } catch (Exception $e) {
2572 - 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
2573 2653 return false;
2574 2654 }
2575 2655 }
2576 2656
@@ -2590,14 +2670,13 @@
2590 2670 try {
2591 2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2592 2672 $db = new Metasync_Otto_Excluded_URLs_Database();
2593 2673 global $wpdb;
2594 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2595 2674 # Normalize the same way is_url_excluded() does
2596 2675 $url_normalized = rtrim(trim($url), '/');
2597 - $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
2598 2677 $wpdb->prepare(
2599 - "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'",
2600 2679 $url_normalized
2601 2680 )
2602 2681 );
2603 2682 if (!empty($records)) {
@@ -2627,24 +2706,26 @@
2627 2706 }
2628 2707 try {
2629 2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2630 2709 global $wpdb;
2631 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2632 2710 $url_normalized = rtrim(trim($url), '/');
2633 2711
2634 2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2635 2713
2636 2714 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"
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"
2640 2717 );
2641 2718
2642 2719 // 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.
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).
2644 2724 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");
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 + }
2647 2728 return false;
2648 2729 }
2649 2730
2650 2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2720,9 +2801,9 @@
2720 2801 }
2721 2802
2722 2803 # WooCommerce product by slug
2723 2804 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));
2805 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2725 2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2726 2807 if (!empty($products)) {
2727 2808 $post_id = $products[0]->get_id();
2728 2809 } else {
@@ -2749,9 +2830,9 @@
2749 2830 }
2750 2831
2751 2832 # Category
2752 2833 if (strpos($route, '/category/') !== false) {
2753 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2834 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2754 2835 $category = get_category_by_slug($category_slug);
2755 2836 if ($category) {
2756 2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2757 2838 }
@@ -2758,9 +2839,9 @@
2758 2839 }
2759 2840
2760 2841 # WooCommerce product category
2761 2842 if (strpos($route, '/product-category/') !== false) {
2762 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2843 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2763 2844 $term = get_term_by('slug', $category_slug, 'product_cat');
2764 2845 if ($term && !is_wp_error($term)) {
2765 2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2766 2847 }
@@ -2802,9 +2883,9 @@
2802 2883
2803 2884 $removed = 0;
2804 2885 $marked_permanent = 0;
2805 2886 $thirty_days_ago = strtotime('-30 days');
2806 - $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);
2807 2888
2808 2889 foreach ($records as $record) {
2809 2890 $url = trim($record->url_pattern);
2810 2891 if (empty($url)) {
@@ -2825,16 +2906,10 @@
2825 2906 }
2826 2907 }
2827 2908 }
2828 2909
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 2910 } catch (Exception $e) {
2836 - 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
2837 2912 }
2838 2913 }
2839 2914
2840 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2839,8 +2914,24 @@
2839 2914
2840 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2841 2916
2842 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 +/**
2843 2934 * Check if a post/page would return 404 without making HTTP request
2844 2935 * Uses WordPress database checks for fast validation
2845 2936 *
2846 2937 * @param int $post_id WordPress post ID
@@ -3016,9 +3107,9 @@
3016 3107 }
3017 3108 }
3018 3109
3019 3110 // WP Cloud's warmer also tags its requests with a query marker.
3020 - 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
3021 3112 $is_infra = true;
3022 3113 }
3023 3114
3024 3115 /**