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 +198 -135 2.7.0 → 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
@@ -814,9 +846,22 @@
814 846 }
815 847
816 848 # A headless frontend renders the public HTML, so stop after the cheap cache
817 849 # protections and crawl telemetry, before any OTTO rendering work begins.
818 - if (Metasync_Headless_Config::is_active()) {
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) {
819 864 return;
820 865 }
821 866
822 867 # Throttle OTTO rendering for bots that are not exempt: at most one render
@@ -888,9 +933,9 @@
888 933 return;
889 934 }
890 935
891 936 # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
892 - if (class_exists('WP_Rocket')) {
937 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
893 938 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
894 939
895 940 if ($wp_rocket_compat_mode === 'disable_otto') {
896 941 return; # Exit early, Otto is disabled when WP Rocket is active
@@ -896,14 +941,25 @@
896 941 return; # Exit early, Otto is disabled when WP Rocket is active
897 942 }
898 943 }
899 944
900 - # Skip OTTO for Divi AJAX pagination and paginated archive requests.
901 - # ?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.
902 949 # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
903 950 # module numbering mismatch between page 1 (with TB template) and page N
904 951 # (without TB template), breaking Divi's JS pagination selector matching.
905 - 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
906 962 return;
907 963 }
908 964
909 965 # Skip OTTO on search-results pages. A search request carries its
@@ -918,9 +974,9 @@
918 974 # correct pre-rendered search page to everyone else. Search-results pages
919 975 # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
920 976 # run here. The raw $_GET['s'] check is a defensive fallback for setups where
921 977 # the main query is altered before this point.
922 - 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
923 979 return;
924 980 }
925 981
926 982 # Handle cache plugin compatibility early - before any caching happens
@@ -972,9 +1028,9 @@
972 1028 $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');
973 1029
974 1030 $otto_is_internal_fetch = $otto_has_fetch_detector
975 1031 ? Metasync_Otto_Render_Strategy::is_internal_fetch()
976 - : !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
977 1033
978 1034 if($otto_is_internal_fetch){
979 1035
980 1036 # Block SEO plugins NOW for this internal fetch request
@@ -979,10 +1035,10 @@
979 1035
980 1036 # Block SEO plugins NOW for this internal fetch request
981 1037 # metasync_otto_block_seo_plugins();
982 1038 # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
983 - $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1';
984 - $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1';
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
985 1041
986 1042 # Block SEO plugins conditionally based on what Otto has
987 1043 if ($block_title || $block_description) {
988 1044 metasync_otto_block_seo_plugins($block_title, $block_description);
@@ -1018,8 +1074,19 @@
1018 1074
1019 1075 # get the otto uuid
1020 1076 $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
1021 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 +
1022 1089 # start the class
1023 1090 $otto = new Metasync_otto_pixel($otto_uuid);
1024 1091
1025 1092 # call render
@@ -1035,9 +1102,9 @@
1035 1102 # Detect active plugins. SG Optimizer detection now lives in
1036 1103 # metasync_otto_disable_sg_page_cache(), which is invoked later
1037 1104 # once OTTO confirms suggestions for the URL.
1038 1105 $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
1039 - $wp_rocket_active = class_exists('WP_Rocket');
1106 + $wp_rocket_active = defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket');
1040 1107
1041 1108 # OPTIMIZED: Get configuration option using cached config
1042 1109 $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();
1043 1110
@@ -1051,9 +1118,9 @@
1051 1118 if ($cached !== false) {
1052 1119 $has_brizy_posts = ($cached === 'yes');
1053 1120 } else {
1054 1121 # Query database only if cache missed
1055 - $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
1056 1123 "SELECT COUNT(*) FROM {$wpdb->postmeta}
1057 1124 WHERE meta_key = 'brizy_post_uid'
1058 1125 AND meta_value != ''
1059 1126 LIMIT 1"
@@ -1110,9 +1177,9 @@
1110 1177 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
1111 1178 */
1112 1179 function metasync_otto_disable_sg_page_cache() {
1113 1180 # Only relevant when SG Optimizer is active and WP Rocket is not present.
1114 - if (class_exists('WP_Rocket')) {
1181 + if (defined('WP_ROCKET_VERSION') || class_exists('WP_Rocket')) {
1115 1182 return;
1116 1183 }
1117 1184 if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
1118 1185 return;
@@ -1653,8 +1720,17 @@
1653 1720
1654 1721 # Handle AJAX Clear Cache request
1655 1722 # NOTE: Cache system removed - this is now a no-op
1656 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 +
1657 1733 if (!empty($_GET['clear_otto_cache'])) {
1658 1734 delete_transient('metasync_otto_js_detected');
1659 1735 # Re-run the JS detection in the background so the notice refreshes.
1660 1736 metasync_schedule_otto_js_check();
@@ -1791,9 +1867,8 @@
1791 1867 }
1792 1868
1793 1869 # Pre-flight 404 check: exclude URLs that would return 404 before making API call
1794 1870 if (!metasync_otto_is_url_available($route)) {
1795 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route}");
1796 1871 metasync_otto_auto_exclude_404_url($route);
1797 1872 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1798 1873 }
1799 1874
@@ -1830,24 +1905,8 @@
1830 1905 return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
1831 1906 }
1832 1907 }
1833 1908
1834 - # Mark this URL as crawled by OTTO for SSR
1835 - # Extract domain and path from route
1836 - $parsed_url = parse_url($route);
1837 - $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
1838 - $url_path = ($parsed_url['path'] ?? '/');
1839 -
1840 - # Create crawl data structure
1841 - $crawl_data = array(
1842 - 'domain' => $domain_with_scheme,
1843 - 'urls' => array($url_path)
1844 - );
1845 -
1846 - # Load Otto pixel class and save crawl data
1847 - $otto_pixel = new Metasync_otto_pixel($otto_uuid);
1848 - $otto_pixel->save_crawl_data($crawl_data);
1849 -
1850 1909 # Get WordPress post ID from URL
1851 1910 $post_id = url_to_postid($route);
1852 1911
1853 1912 # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
@@ -1867,9 +1926,9 @@
1867 1926
1868 1927 # Try to find WooCommerce product by URL if url_to_postid failed
1869 1928 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
1870 1929 # Extract product slug from URL
1871 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
1930 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1872 1931
1873 1932 # Try to get product by slug
1874 1933 $products = wc_get_products(array(
1875 1934 'name' => $product_slug,
@@ -1900,15 +1959,14 @@
1900 1959 if (!$post_id || $post_id <= 0) {
1901 1960 # Check if this is a category page
1902 1961 if (strpos($route, '/category/') !== false) {
1903 1962 # Extract category slug from URL
1904 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
1963 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
1905 1964 $category = get_category_by_slug($category_slug);
1906 1965
1907 1966 if ($category) {
1908 1967 # Check if category would return 404 before applying OTTO changes
1909 1968 if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
1910 - error_log("MetaSync OTTO: Skipping SEO processing for category that would return 404: {$route} (Category ID: {$category->term_id})");
1911 1969 metasync_otto_auto_exclude_404_url($route);
1912 1970 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
1913 1971 }
1914 1972
@@ -2004,15 +2062,14 @@
2004 2062
2005 2063 # Check if this is a WooCommerce product category
2006 2064 if (strpos($route, '/product-category/') !== false) {
2007 2065 # Extract product category slug from URL
2008 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2066 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2009 2067 $term = get_term_by('slug', $category_slug, 'product_cat');
2010 2068
2011 2069 if ($term && !is_wp_error($term)) {
2012 2070 # Check if product category would return 404 before applying OTTO changes
2013 2071 if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
2014 - error_log("MetaSync OTTO: Skipping SEO processing for product category that would return 404: {$route} (Term ID: {$term->term_id})");
2015 2072 metasync_otto_auto_exclude_404_url($route);
2016 2073 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2017 2074 }
2018 2075
@@ -2125,9 +2182,8 @@
2125 2182
2126 2183 if ($home_page) {
2127 2184 # Check if home page would return 404 before applying OTTO changes
2128 2185 if (metasync_would_page_return_404($home_page->ID, $route)) {
2129 - error_log("MetaSync OTTO: Skipping SEO processing for home page that would return 404: {$route} (Post ID: {$home_page->ID}, Status: {$home_page->post_status})");
2130 2186 metasync_otto_auto_exclude_404_url($route);
2131 2187 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2132 2188 }
2133 2189
@@ -2231,9 +2287,8 @@
2231 2287
2232 2288 if ($posts_page && $route_clean === $posts_page_url) {
2233 2289 # Check if blog page would return 404
2234 2290 if (metasync_would_page_return_404($posts_page->ID, $route)) {
2235 - error_log("MetaSync OTTO: Skipping SEO processing for blog page that would return 404: {$route} (Post ID: {$posts_page->ID}, Status: {$posts_page->post_status})");
2236 2291 metasync_otto_auto_exclude_404_url($route);
2237 2292 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2238 2293 }
2239 2294
@@ -2331,9 +2386,8 @@
2331 2386
2332 2387 # URL didn't resolve to any supported entity (post, category, home page, blog page)
2333 2388 # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
2334 2389 if (!metasync_otto_is_url_available($route)) {
2335 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404 (no matching entity): {$route}");
2336 2390 metasync_otto_auto_exclude_404_url($route);
2337 2391 }
2338 2392 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2339 2393 }
@@ -2350,9 +2404,8 @@
2350 2404 }
2351 2405
2352 2406 # Check if page would return 404 before applying OTTO changes
2353 2407 if (metasync_would_page_return_404($post_id, $route)) {
2354 - error_log("MetaSync OTTO: Skipping SEO processing for URL that would return 404: {$route} (Post ID: {$post_id}, Status: {$post->post_status})");
2355 2408 metasync_otto_auto_exclude_404_url($route);
2356 2409 return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
2357 2410 }
2358 2411
@@ -2470,11 +2523,10 @@
2470 2523
2471 2524 // Minimal duplicate prevention within short time window
2472 2525 if (!empty($data['title']) && !empty($data['source'])) {
2473 2526 global $wpdb;
2474 - $table = $wpdb->prefix . Metasync_Sync_History_Database::$table_name;
2475 - $recent = $wpdb->get_var($wpdb->prepare(
2476 - "SELECT COUNT(*) FROM `$table` WHERE title = %s AND source = %s AND created_at >= %s",
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",
2477 2529 $data['title'],
2478 2530 $data['source'],
2479 2531 gmdate('Y-m-d H:i:s', time() - 60)
2480 2532 ));
@@ -2485,9 +2537,9 @@
2485 2537
2486 2538 $sync_db->add($data);
2487 2539
2488 2540 } catch (Exception $e) {
2489 - 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
2490 2542 }
2491 2543 }
2492 2544
2493 2545 /**
@@ -2563,9 +2615,9 @@
2563 2615 $db = new Metasync_Redirection_Database();
2564 2616 $redirect = new Metasync_Redirection($db);
2565 2617 return $redirect->resolve_url_to_final_destination($url, 10);
2566 2618 } catch (Exception $e) {
2567 - 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
2568 2620 return $url;
2569 2621 }
2570 2622 }
2571 2623
@@ -2596,9 +2648,9 @@
2596 2648 'status' => 'active',
2597 2649 'auto_excluded' => 1,
2598 2650 ]);
2599 2651 } catch (Exception $e) {
2600 - 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
2601 2653 return false;
2602 2654 }
2603 2655 }
2604 2656
@@ -2618,14 +2670,13 @@
2618 2670 try {
2619 2671 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2620 2672 $db = new Metasync_Otto_Excluded_URLs_Database();
2621 2673 global $wpdb;
2622 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2623 2674 # Normalize the same way is_url_excluded() does
2624 2675 $url_normalized = rtrim(trim($url), '/');
2625 - $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
2626 2677 $wpdb->prepare(
2627 - "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'",
2628 2679 $url_normalized
2629 2680 )
2630 2681 );
2631 2682 if (!empty($records)) {
@@ -2655,24 +2706,26 @@
2655 2706 }
2656 2707 try {
2657 2708 require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
2658 2709 global $wpdb;
2659 - $table = $wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name;
2660 2710 $url_normalized = rtrim(trim($url), '/');
2661 2711
2662 2712 $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);
2663 2713
2664 2714 if ($records === false) {
2665 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no user input, table name from $wpdb->prefix
2666 - $records = $wpdb->get_results(
2667 - "SELECT url_pattern, pattern_type FROM `{$table}` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
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"
2668 2717 );
2669 2718
2670 2719 // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
2671 - // Run ALTER TABLE to add it and treat URL as not excluded so OTTO continues rendering.
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).
2672 2724 if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
2673 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
2674 - $wpdb->query("ALTER TABLE `{$table}` ADD COLUMN `auto_excluded` TINYINT(1) NOT NULL DEFAULT 0");
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 + }
2675 2728 return false;
2676 2729 }
2677 2730
2678 2731 set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
@@ -2748,9 +2801,9 @@
2748 2801 }
2749 2802
2750 2803 # WooCommerce product by slug
2751 2804 if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
2752 - $product_slug = basename(parse_url($route, PHP_URL_PATH));
2805 + $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2753 2806 $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
2754 2807 if (!empty($products)) {
2755 2808 $post_id = $products[0]->get_id();
2756 2809 } else {
@@ -2777,9 +2830,9 @@
2777 2830 }
2778 2831
2779 2832 # Category
2780 2833 if (strpos($route, '/category/') !== false) {
2781 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2834 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2782 2835 $category = get_category_by_slug($category_slug);
2783 2836 if ($category) {
2784 2837 return !metasync_would_term_return_404($category->term_id, 'category', $route);
2785 2838 }
@@ -2786,9 +2839,9 @@
2786 2839 }
2787 2840
2788 2841 # WooCommerce product category
2789 2842 if (strpos($route, '/product-category/') !== false) {
2790 - $category_slug = basename(parse_url($route, PHP_URL_PATH));
2843 + $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
2791 2844 $term = get_term_by('slug', $category_slug, 'product_cat');
2792 2845 if ($term && !is_wp_error($term)) {
2793 2846 return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
2794 2847 }
@@ -2830,9 +2883,9 @@
2830 2883
2831 2884 $removed = 0;
2832 2885 $marked_permanent = 0;
2833 2886 $thirty_days_ago = strtotime('-30 days');
2834 - $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);
2835 2888
2836 2889 foreach ($records as $record) {
2837 2890 $url = trim($record->url_pattern);
2838 2891 if (empty($url)) {
@@ -2853,16 +2906,10 @@
2853 2906 }
2854 2907 }
2855 2908 }
2856 2909
2857 - if ($removed > 0) {
2858 - error_log("MetaSync OTTO: Recheck 404 exclusions - removed {$removed} URL(s) that are now available");
2859 - }
2860 - if ($marked_permanent > 0) {
2861 - error_log("MetaSync OTTO: Recheck 404 exclusions - marked {$marked_permanent} URL(s) as permanent (still 404 after 30 days)");
2862 - }
2863 2910 } catch (Exception $e) {
2864 - 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
2865 2912 }
2866 2913 }
2867 2914
2868 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
@@ -2867,8 +2914,24 @@
2867 2914
2868 2915 add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');
2869 2916
2870 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 +/**
2871 2934 * Check if a post/page would return 404 without making HTTP request
2872 2935 * Uses WordPress database checks for fast validation
2873 2936 *
2874 2937 * @param int $post_id WordPress post ID
@@ -3044,9 +3107,9 @@
3044 3107 }
3045 3108 }
3046 3109
3047 3110 // WP Cloud's warmer also tags its requests with a query marker.
3048 - 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
3049 3112 $is_infra = true;
3050 3113 }
3051 3114
3052 3115 /**