| @@ -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 | /** |