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 | includes/class-metasync.php +111 -22 2.7.0 → 2.7.2 View file →
@@ -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'] : '/';