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 +151 -25 2.6.24 → 2.7.2 View file →
@@ -161,11 +161,21 @@
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 + // The SEO precedence resolver is referenced statically from the render
169 + // filters, the sync layer and four admin screens. Require it explicitly
170 + // for the same reason as the admin navigation below: a partial update can
171 + // leave newer PHP files beside an older committed autoload classmap, and
172 + // a missing class on wp_head or the posts list is a fatal, not a
173 + // degradation.
174 + if (!class_exists('Metasync_Seo_Precedence')) {
175 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-precedence.php';
176 + }
177 +
168 178 // Admin navigation is referenced statically from frontend-reachable
169 179 // includes (heartbeat/connect managers). Require it explicitly here so
170 180 // the static call never fatals when wp_head fires before autoload.
171 181 if (!class_exists('Metasync_Admin_Navigation')) {
@@ -178,8 +188,10 @@
178 188 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php';
179 189 }
180 190
181 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
182 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
183 195 $this->db_redirection = new Metasync_Redirection_Database();
184 196 }
185 197
@@ -267,16 +279,12 @@
267 279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
268 280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
269 281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
270 282
271 - // Auto-update filter
272 - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
273 -
274 283 // Search Atlas Connect development/testing endpoints
275 284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
276 285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
277 286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
278 - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
279 287
280 288
281 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
282 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -289,14 +297,23 @@
289 297 // `metasync_enable_seo_suite` filter.
290 298 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
291 299 new Metasync_Seo_Suite();
292 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 +
293 310 // SEO Health CSV export: must run on admin_init (before output).
294 311 // Cheap $_GET check avoids loading the class on every admin page.
295 312 if (
296 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
297 - $_GET['export'] === 'csv' &&
298 - 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
299 316 ) {
300 317 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
301 318 }
302 319 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
@@ -335,13 +352,8 @@
335 352 if (class_exists('Metasync_Breadcrumbs_Schema')) {
336 353 new Metasync_Breadcrumbs_Schema($this->get_plugin_name(), $this->get_version());
337 354 }
338 355
339 - // Initialize Developer Panel (for endpoint switching)
340 - if (class_exists('Metasync_Dev_Panel')) {
341 - $dev_panel = new Metasync_Dev_Panel($this->get_plugin_name(), $this->get_version());
342 - }
343 -
344 356 // Initialize Site Health integration
345 357 if (class_exists('Metasync_Site_Health')) {
346 358 $site_health = new Metasync_Site_Health();
347 359 $site_health->register_tests();
@@ -369,9 +381,12 @@
369 381
370 382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
371 383 $rest_api = $plugin_public->get_rest_api();
372 384 $seo_output = $plugin_public->get_seo_output();
373 - $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
385 + // Derive the basename from the actual file location: the plugin
386 + // folder is not guaranteed to be named after the plugin (renamed
387 + // installs), and a hardcoded name makes the settings links vanish.
388 + $get_plugin_basename = plugin_basename(dirname(__DIR__) . '/metasync.php');
374 389
375 390 // Asset enqueue hooks (Metasync_Public)
376 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
377 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -376,8 +391,12 @@
376 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
377 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
378 393 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
379 394
395 + // Registered here rather than from inside enqueue_scripts(), which would
396 + // re-register the same filter on every front-end request.
397 + $this->loader->add_filter('script_loader_tag', $plugin_public, 'add_defer_attribute', 10, 2);
398 +
380 399 // Elementor editor CSS injection
381 400 if (class_exists('\Elementor\Plugin')) {
382 401 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
383 402 }
@@ -390,8 +409,21 @@
390 409 // Initialize centralized SEO conflict handler (singleton — suppresses
391 410 // third-party SEO plugin descriptions when MetaSync provides its own).
392 411 Metasync_SEO_Conflict_Handler::get_instance();
393 412
413 + // Headless delivery: register the GraphQL SEO field. This only adds a
414 + // callback on `graphql_register_types`, which never fires unless WPGraphQL
415 + // is active, and the callback returns immediately unless Headless Mode is
416 + // on — so with the mode off nothing about this request changes.
417 + Metasync_Headless_Graphql::init();
418 +
419 + // Headless stale-while-revalidate OTTO refresh: background safety net for
420 + // missed/delayed OTTO deployment webhooks. Every branch is gated on
421 + // Metasync_Headless_Config::is_active(), so with headless mode off this
422 + // schedules no cron event and registers no route beyond an inert REST
423 + // callback that itself checks the flag before doing anything.
424 + Metasync_Headless_Refresh_Job::init();
425 +
394 426 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
395 427 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
396 428 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
397 429 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
@@ -409,11 +441,16 @@
409 441 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
410 442 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
411 443 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
412 444
413 - // Hreflang / language alternates output (wp_head @ priority 2).
414 - $plugin_hreflang = new Metasync_Hreflang_Output();
445 + // Hreflang / language alternates output (wp_head @ priority 2). The
446 + // emitter shares the SEO output instance for the noindex check.
447 + $plugin_hreflang = new Metasync_Hreflang_Output($seo_output);
415 448 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
449 + // WPML prints its own hreflang set at wp_head priority 1, ahead of
450 + // MetaSync's output at priority 2 — the suppression filter has to be
451 + // in place before wp_head starts, hence template_redirect.
452 + $this->loader->add_action('template_redirect', $plugin_hreflang, 'register_wpml_suppression', 1);
416 453
417 454 // Edge Cache: detect Cloudways Varnish and persist for settings UI
418 455 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
419 456
@@ -535,8 +572,20 @@
535 572 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
536 573 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
537 574 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
538 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 + }
539 588 }
540 589
541 590 /**
542 591 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
@@ -566,8 +615,40 @@
566 615 // so that enabling sitemaps later will still clean up BB templates.
567 616 }
568 617
569 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 + /**
570 651 * Initialize endpoint URL filtering for staging mode
571 652 * Intercepts HTTP requests and replaces production URLs with staging URLs
572 653 */
573 654 private function init_endpoint_filtering() {
@@ -611,9 +692,9 @@
611 692 $original_url = $url;
612 693 foreach ($url_replacements as $production => $staging) {
613 694 if (strpos($url, $production) === 0) {
614 695 $url = str_replace($production, $staging, $url);
615 - 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
616 697 break;
617 698 }
618 699 }
619 700
@@ -870,8 +951,52 @@
870 951 return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
871 952 }
872 953
873 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 + /**
874 999 * Encrypt a plaintext Search Atlas API key for storage at rest.
875 1000 *
876 1001 * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
877 1002 * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
@@ -897,9 +1022,9 @@
897 1022 }
898 1023
899 1024 if (!function_exists('openssl_encrypt')) {
900 1025 // OpenSSL unavailable — store plaintext rather than lose the key.
901 - 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
902 1027 return $plaintext;
903 1028 }
904 1029
905 1030 $key = self::api_key_crypto_key();
@@ -908,9 +1033,9 @@
908 1033 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
909 1034
910 1035 if ($ciphertext === false) {
911 1036 // Encryption failed — fall back to plaintext storage, but say so.
912 - 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
913 1038 return $plaintext;
914 1039 }
915 1040
916 1041 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1117,9 +1242,9 @@
1117 1242 }
1118 1243
1119 1244 if (!function_exists('openssl_encrypt')) {
1120 1245 // OpenSSL unavailable — store plaintext rather than lose the secret.
1121 - 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
1122 1247 return $plaintext;
1123 1248 }
1124 1249
1125 1250 $key = self::secret_crypto_key();
@@ -1128,9 +1253,9 @@
1128 1253 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1129 1254
1130 1255 if ($ciphertext === false) {
1131 1256 // Encryption failed — fall back to plaintext storage, but say so.
1132 - 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
1133 1258 return $plaintext;
1134 1259 }
1135 1260
1136 1261 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1461,8 +1586,9 @@
1461 1586 * @param string $text Plain-English help text (escaped internally).
1462 1587 */
1463 1588 public static function render_tooltip_icon($tooltip_id, $text)
1464 1589 {
1590 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_tooltip_icon_html() escapes every interpolated value internally (esc_attr / esc_html)
1465 1591 echo self::get_tooltip_icon_html($tooltip_id, $text);
1466 1592 }
1467 1593
1468 1594 /**
@@ -1555,9 +1681,9 @@
1555 1681 // This could be extended to store in a dedicated log table
1556 1682
1557 1683 } catch (Exception $e) {
1558 1684 // Fallback logging if structured logging fails
1559 - 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
1560 1686 }
1561 1687 }
1562 1688
1563 1689 /**
@@ -1621,9 +1747,9 @@
1621 1747 $global_hits = get_transient($global_rate_key);
1622 1748 if ($global_hits !== false && $global_hits >= 50) {
1623 1749 // More than 50 404s per minute - stop logging to protect database
1624 1750 if ($global_hits === 50) {
1625 - 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
1626 1752 }
1627 1753 set_transient($global_rate_key, $global_hits + 1, 60);
1628 1754 return;
1629 1755 }
@@ -1648,9 +1774,9 @@
1648 1774 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1649 1775 $db_404 = new Metasync_Error_Monitor_Database();
1650 1776
1651 1777 // Get user agent (sanitized)
1652 - $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'])) : '';
1653 1779
1654 1780 // Log the 404 error
1655 1781 $result = $db_404->update([
1656 1782 'uri' => $current_url,
@@ -1676,9 +1802,9 @@
1676 1802 $host = $_SERVER['SERVER_NAME'];
1677 1803 }
1678 1804 if (empty($host)) {
1679 1805 // Fallback to WordPress site URL if available
1680 - $host = parse_url(home_url(), PHP_URL_HOST);
1806 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1681 1807 }
1682 1808
1683 1809 // Safely get REQUEST_URI with fallback
1684 1810 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';