| @@ -161,9 +161,9 @@ | ||
| 161 | 161 | // Procedural init file — not a class, must stay explicit. |
| 162 | 162 | if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) { |
| 163 | 163 | require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'; |
| 164 | 164 | } else { |
| 165 | - error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'); | |
| 165 | + error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | 168 | // The SEO precedence resolver is referenced statically from the render |
| 169 | 169 | // filters, the sync layer and four admin screens. Require it explicitly |
| @@ -279,16 +279,12 @@ | ||
| 279 | 279 | $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url'); |
| 280 | 280 | $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status'); |
| 281 | 281 | $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication'); |
| 282 | 282 | |
| 283 | - // Auto-update filter | |
| 284 | - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2); | |
| 285 | - | |
| 286 | 283 | // Search Atlas Connect development/testing endpoints |
| 287 | 284 | $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens'); |
| 288 | 285 | $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain'); |
| 289 | 286 | $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint'); |
| 290 | - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test'); | |
| 291 | 287 | |
| 292 | 288 | |
| 293 | 289 | $post_meta_setting = new Metasync_Post_Meta_Settings(); |
| 294 | 290 | $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2); |
| @@ -301,14 +297,23 @@ | ||
| 301 | 297 | // `metasync_enable_seo_suite` filter. |
| 302 | 298 | require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php'; |
| 303 | 299 | new Metasync_Seo_Suite(); |
| 304 | 300 | |
| 301 | + // Classic-editor save path for the three fields that exist only as | |
| 302 | + // Gutenberg sidebar (REST) meta otherwise: breadcrumb title, language | |
| 303 | + // alternates, primary category. The Suite renders the first two and | |
| 304 | + // emits the nonce; this handler persists them from the classic form. | |
| 305 | + // It guards itself against block-editor and managed-page context, so | |
| 306 | + // it is safe to register unconditionally. | |
| 307 | + require_once plugin_dir_path(dirname(__FILE__)) . 'admin/class-metasync-classic-seo-fields.php'; | |
| 308 | + new Metasync_Classic_Seo_Fields(); | |
| 309 | + | |
| 305 | 310 | // SEO Health CSV export: must run on admin_init (before output). |
| 306 | 311 | // Cheap $_GET check avoids loading the class on every admin page. |
| 307 | 312 | if ( |
| 308 | - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && | |
| 309 | - $_GET['export'] === 'csv' && | |
| 310 | - strpos($_GET['page'], '-seo-health') !== false | |
| 313 | + isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability | |
| 314 | + $_GET['export'] === 'csv' && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability | |
| 315 | + strpos($_GET['page'], '-seo-health') !== false // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability | |
| 311 | 316 | ) { |
| 312 | 317 | $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1); |
| 313 | 318 | } |
| 314 | 319 | $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9); |
| @@ -347,13 +352,8 @@ | ||
| 347 | 352 | if (class_exists('Metasync_Breadcrumbs_Schema')) { |
| 348 | 353 | new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version()); |
| 349 | 354 | } |
| 350 | 355 | |
| 351 | - // Initialize Developer Panel (for endpoint switching) | |
| 352 | - if (class_exists('Metasync_Dev_Panel')) { | |
| 353 | - $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version()); | |
| 354 | - } | |
| 355 | - | |
| 356 | 356 | // Initialize Site Health integration |
| 357 | 357 | if (class_exists('Metasync_Site_Health')) { |
| 358 | 358 | $site_health = new Metasync_Site_Health(); |
| 359 | 359 | $site_health->register_tests(); |
| @@ -572,8 +572,20 @@ | ||
| 572 | 572 | // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries |
| 573 | 573 | if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) { |
| 574 | 574 | $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade'); |
| 575 | 575 | } |
| 576 | + | |
| 577 | + // One-time upgrade: delete the legacy Otto crawl-data option. Nothing | |
| 578 | + // reads or writes it anymore (the webhook and SEO sync job stopped | |
| 579 | + // persisting crawl data), and on sites where OTTO crawled many URLs | |
| 580 | + // the row could grow to megabytes while being autoloaded on every | |
| 581 | + // request before WordPress 6.6. The guard below runs on every request, | |
| 582 | + // so the flag must be autoloaded (like the sitemap flag above): a | |
| 583 | + // boolean true costs ~2 bytes inside the alloptions query WordPress | |
| 584 | + // already always runs, keeping the steady-state cost a cache hit. | |
| 585 | + if ( ! get_option( 'metasync_otto_crawldata_removed' ) ) { | |
| 586 | + $this->loader->add_action('init', $this, 'maybe_delete_legacy_otto_crawl_data'); | |
| 587 | + } | |
| 576 | 588 | } |
| 577 | 589 | |
| 578 | 590 | /** |
| 579 | 591 | * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder |
| @@ -603,8 +615,40 @@ | ||
| 603 | 615 | // so that enabling sitemaps later will still clean up BB templates. |
| 604 | 616 | } |
| 605 | 617 | |
| 606 | 618 | /** |
| 619 | + * One-time upgrade routine: delete the legacy Otto crawl-data option. | |
| 620 | + * | |
| 621 | + * The option stored every URL Otto ever sent and grew without bound; | |
| 622 | + * nothing writes it anymore and nothing in production reads it (per-URL | |
| 623 | + * state lives in Metasync_Otto_Job_Status, delivery receipts in | |
| 624 | + * Metasync_Otto_Webhook_Audit, both bounded). This drops the leftover | |
| 625 | + * row — which on long-running sites can be megabytes of autoloaded data. | |
| 626 | + * | |
| 627 | + * Runs once on 'init' and sets a flag so it never runs again. The flag | |
| 628 | + * is autoloaded deliberately: the registration guard reads it on every | |
| 629 | + * request, and an autoloaded boolean is a free cache hit inside the | |
| 630 | + * alloptions query WordPress always performs — a non-autoloaded flag | |
| 631 | + * would cost a dedicated SELECT per request forever, the exact waste | |
| 632 | + * this routine exists to remove. delete_option() is called directly | |
| 633 | + * without reading the option first: loading a multi-megabyte value into | |
| 634 | + * memory just to delete it would be the one way this cleanup could hurt | |
| 635 | + * a site, and the SQL DELETE never needs the value. | |
| 636 | + */ | |
| 637 | + public function maybe_delete_legacy_otto_crawl_data() { | |
| 638 | + $done_key = 'metasync_otto_crawldata_removed'; | |
| 639 | + if ( get_option( $done_key ) ) { | |
| 640 | + return; | |
| 641 | + } | |
| 642 | + | |
| 643 | + // No "feature in use" conditional here, unlike the sitemap routine: | |
| 644 | + // the row is dead weight whether or not Otto is configured, and | |
| 645 | + // delete_option() on an absent row is a cheap no-op. | |
| 646 | + delete_option( 'metasync_otto_crawldata' ); | |
| 647 | + update_option( $done_key, true ); | |
| 648 | + } | |
| 649 | + | |
| 650 | + /** | |
| 607 | 651 | * Initialize endpoint URL filtering for staging mode |
| 608 | 652 | * Intercepts HTTP requests and replaces production URLs with staging URLs |
| 609 | 653 | */ |
| 610 | 654 | private function init_endpoint_filtering() { |
| @@ -648,9 +692,9 @@ | ||
| 648 | 692 | $original_url = $url; |
| 649 | 693 | foreach ($url_replacements as $production => $staging) { |
| 650 | 694 | if (strpos($url, $production) === 0) { |
| 651 | 695 | $url = str_replace($production, $staging, $url); |
| 652 | - error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}"); | |
| 696 | + error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}"); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets | |
| 653 | 697 | break; |
| 654 | 698 | } |
| 655 | 699 | } |
| 656 | 700 | |
| @@ -907,8 +951,52 @@ | ||
| 907 | 951 | return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0; |
| 908 | 952 | } |
| 909 | 953 | |
| 910 | 954 | /** |
| 955 | + * Whether a value is a strictly valid UUID (versions 1-8). | |
| 956 | + * | |
| 957 | + * The single shared validator for every write path that persists | |
| 958 | + * `general.otto_pixel_uuid` (SSO push callback, pull-based connect, | |
| 959 | + * heartbeat writes, settings sanitizer). The OTTO Pixel UUID must never | |
| 960 | + * hold arbitrary text — usernames such as `raza.imran` landing in it | |
| 961 | + * break OTTO SSR authentication — so every write path funnels through | |
| 962 | + * this check instead of a scattered one-off regex. | |
| 963 | + * | |
| 964 | + * Deliberately stricter than wp_is_uuid(), which only checks the | |
| 965 | + * 8-4-4-4-12 hex shape and would accept placeholder values such as | |
| 966 | + * `11111111-2222-3333-4444-555555555555`. The version nibble (first hex | |
| 967 | + * of the third group) must be 1-8 — RFC 4122 defined 1-5 and RFC 9562 | |
| 968 | + * added 6/7/8, which modern generators increasingly emit — and the | |
| 969 | + * variant nibble (first hex of the fourth group) must be 8/9/a/b. That | |
| 970 | + * keeps behaviour identical with and without the WP helper loaded (unit | |
| 971 | + * tests run without it). | |
| 972 | + * | |
| 973 | + * An empty string is NOT valid: clearing the field is a separate concern | |
| 974 | + * owned by the disconnect flow (unset + write_options_without_stored_merge), | |
| 975 | + * so '' must not masquerade as a usable UUID here. | |
| 976 | + * | |
| 977 | + * @param mixed $value | |
| 978 | + * @return bool True only for a canonical 8-4-4-4-12 hex UUID of version 1-8 | |
| 979 | + * with an RFC-4122 variant (8/9/a/b) in the fourth group. | |
| 980 | + */ | |
| 981 | + public static function is_valid_uuid($value) | |
| 982 | + { | |
| 983 | + if (!is_string($value)) { | |
| 984 | + return false; | |
| 985 | + } | |
| 986 | + | |
| 987 | + $value = trim($value); | |
| 988 | + if ($value === '') { | |
| 989 | + return false; | |
| 990 | + } | |
| 991 | + | |
| 992 | + return (bool) preg_match( | |
| 993 | + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', | |
| 994 | + $value | |
| 995 | + ); | |
| 996 | + } | |
| 997 | + | |
| 998 | + /** | |
| 911 | 999 | * Encrypt a plaintext Search Atlas API key for storage at rest. |
| 912 | 1000 | * |
| 913 | 1001 | * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the |
| 914 | 1002 | * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded |
| @@ -934,9 +1022,9 @@ | ||
| 934 | 1022 | } |
| 935 | 1023 | |
| 936 | 1024 | if (!function_exists('openssl_encrypt')) { |
| 937 | 1025 | // OpenSSL unavailable — store plaintext rather than lose the key. |
| 938 | - error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.'); | |
| 1026 | + error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 939 | 1027 | return $plaintext; |
| 940 | 1028 | } |
| 941 | 1029 | |
| 942 | 1030 | $key = self::api_key_crypto_key(); |
| @@ -945,9 +1033,9 @@ | ||
| 945 | 1033 | $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); |
| 946 | 1034 | |
| 947 | 1035 | if ($ciphertext === false) { |
| 948 | 1036 | // Encryption failed — fall back to plaintext storage, but say so. |
| 949 | - error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.'); | |
| 1037 | + error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 950 | 1038 | return $plaintext; |
| 951 | 1039 | } |
| 952 | 1040 | |
| 953 | 1041 | return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext); |
| @@ -1154,9 +1242,9 @@ | ||
| 1154 | 1242 | } |
| 1155 | 1243 | |
| 1156 | 1244 | if (!function_exists('openssl_encrypt')) { |
| 1157 | 1245 | // OpenSSL unavailable — store plaintext rather than lose the secret. |
| 1158 | - error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.'); | |
| 1246 | + error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 1159 | 1247 | return $plaintext; |
| 1160 | 1248 | } |
| 1161 | 1249 | |
| 1162 | 1250 | $key = self::secret_crypto_key(); |
| @@ -1165,9 +1253,9 @@ | ||
| 1165 | 1253 | $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); |
| 1166 | 1254 | |
| 1167 | 1255 | if ($ciphertext === false) { |
| 1168 | 1256 | // Encryption failed — fall back to plaintext storage, but say so. |
| 1169 | - error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.'); | |
| 1257 | + error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 1170 | 1258 | return $plaintext; |
| 1171 | 1259 | } |
| 1172 | 1260 | |
| 1173 | 1261 | return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext); |
| @@ -1498,8 +1586,9 @@ | ||
| 1498 | 1586 | * @param string $text Plain-English help text (escaped internally). |
| 1499 | 1587 | */ |
| 1500 | 1588 | public static function render_tooltip_icon($tooltip_id, $text) |
| 1501 | 1589 | { |
| 1590 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_tooltip_icon_html() escapes every interpolated value internally (esc_attr / esc_html) | |
| 1502 | 1591 | echo self::get_tooltip_icon_html($tooltip_id, $text); |
| 1503 | 1592 | } |
| 1504 | 1593 | |
| 1505 | 1594 | /** |
| @@ -1592,9 +1681,9 @@ | ||
| 1592 | 1681 | // This could be extended to store in a dedicated log table |
| 1593 | 1682 | |
| 1594 | 1683 | } catch (Exception $e) { |
| 1595 | 1684 | // Fallback logging if structured logging fails |
| 1596 | - error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage()); | |
| 1685 | + error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 1597 | 1686 | } |
| 1598 | 1687 | } |
| 1599 | 1688 | |
| 1600 | 1689 | /** |
| @@ -1658,9 +1747,9 @@ | ||
| 1658 | 1747 | $global_hits = get_transient($global_rate_key); |
| 1659 | 1748 | if ($global_hits !== false && $global_hits >= 50) { |
| 1660 | 1749 | // More than 50 404s per minute - stop logging to protect database |
| 1661 | 1750 | if ($global_hits === 50) { |
| 1662 | - error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.'); | |
| 1751 | + error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets | |
| 1663 | 1752 | } |
| 1664 | 1753 | set_transient($global_rate_key, $global_hits + 1, 60); |
| 1665 | 1754 | return; |
| 1666 | 1755 | } |
| @@ -1685,9 +1774,9 @@ | ||
| 1685 | 1774 | require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php'; |
| 1686 | 1775 | $db_404 = new Metasync_Error_Monitor_Database(); |
| 1687 | 1776 | |
| 1688 | 1777 | // Get user agent (sanitized) |
| 1689 | - $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : ''; | |
| 1778 | + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; | |
| 1690 | 1779 | |
| 1691 | 1780 | // Log the 404 error |
| 1692 | 1781 | $result = $db_404->update([ |
| 1693 | 1782 | 'uri' => $current_url, |
| @@ -1713,9 +1802,9 @@ | ||
| 1713 | 1802 | $host = $_SERVER['SERVER_NAME']; |
| 1714 | 1803 | } |
| 1715 | 1804 | if (empty($host)) { |
| 1716 | 1805 | // Fallback to WordPress site URL if available |
| 1717 | - $host = parse_url(home_url(), PHP_URL_HOST); | |
| 1806 | + $host = wp_parse_url(home_url(), PHP_URL_HOST); | |
| 1718 | 1807 | } |
| 1719 | 1808 | |
| 1720 | 1809 | // Safely get REQUEST_URI with fallback |
| 1721 | 1810 | $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/'; |