PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +67 -20 2.6.23 → 2.7.1 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);
@@ -292,11 +300,11 @@
292 300
293 301 // SEO Health CSV export: must run on admin_init (before output).
294 302 // Cheap $_GET check avoids loading the class on every admin page.
295 303 if (
296 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
297 - $_GET['export'] === 'csv' &&
298 - strpos($_GET['page'], '-seo-health') !== false
304 + isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
305 + $_GET['export'] === 'csv' && // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
306 + strpos($_GET['page'], '-seo-health') !== false // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing check, the handler verifies nonce and capability
299 307 ) {
300 308 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
301 309 }
302 310 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
@@ -369,9 +377,12 @@
369 377
370 378 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
371 379 $rest_api = $plugin_public->get_rest_api();
372 380 $seo_output = $plugin_public->get_seo_output();
373 - $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
381 + // Derive the basename from the actual file location: the plugin
382 + // folder is not guaranteed to be named after the plugin (renamed
383 + // installs), and a hardcoded name makes the settings links vanish.
384 + $get_plugin_basename = plugin_basename(dirname(__DIR__) . '/metasync.php');
374 385
375 386 // Asset enqueue hooks (Metasync_Public)
376 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
377 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -376,8 +387,12 @@
376 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
377 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
378 389 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
379 390
391 + // Registered here rather than from inside enqueue_scripts(), which would
392 + // re-register the same filter on every front-end request.
393 + $this->loader->add_filter('script_loader_tag', $plugin_public, 'add_defer_attribute', 10, 2);
394 +
380 395 // Elementor editor CSS injection
381 396 if (class_exists('\Elementor\Plugin')) {
382 397 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
383 398 }
@@ -390,8 +405,21 @@
390 405 // Initialize centralized SEO conflict handler (singleton — suppresses
391 406 // third-party SEO plugin descriptions when MetaSync provides its own).
392 407 Metasync_SEO_Conflict_Handler::get_instance();
393 408
409 + // Headless delivery: register the GraphQL SEO field. This only adds a
410 + // callback on `graphql_register_types`, which never fires unless WPGraphQL
411 + // is active, and the callback returns immediately unless Headless Mode is
412 + // on — so with the mode off nothing about this request changes.
413 + Metasync_Headless_Graphql::init();
414 +
415 + // Headless stale-while-revalidate OTTO refresh: background safety net for
416 + // missed/delayed OTTO deployment webhooks. Every branch is gated on
417 + // Metasync_Headless_Config::is_active(), so with headless mode off this
418 + // schedules no cron event and registers no route beyond an inert REST
419 + // callback that itself checks the flag before doing anything.
420 + Metasync_Headless_Refresh_Job::init();
421 +
394 422 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
395 423 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
396 424 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
397 425 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
@@ -409,11 +437,16 @@
409 437 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
410 438 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
411 439 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
412 440
413 - // Hreflang / language alternates output (wp_head @ priority 2).
414 - $plugin_hreflang = new Metasync_Hreflang_Output();
441 + // Hreflang / language alternates output (wp_head @ priority 2). The
442 + // emitter shares the SEO output instance for the noindex check.
443 + $plugin_hreflang = new Metasync_Hreflang_Output($seo_output);
415 444 $this->loader->add_action('wp_head', $plugin_hreflang, 'output_hreflang_tags', 2);
445 + // WPML prints its own hreflang set at wp_head priority 1, ahead of
446 + // MetaSync's output at priority 2 — the suppression filter has to be
447 + // in place before wp_head starts, hence template_redirect.
448 + $this->loader->add_action('template_redirect', $plugin_hreflang, 'register_wpml_suppression', 1);
416 449
417 450 // Edge Cache: detect Cloudways Varnish and persist for settings UI
418 451 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
419 452
@@ -517,8 +550,22 @@
517 550 // on read-only web roots and nginx hosts that 403 direct static .txt access.
518 551 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
519 552 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
520 553
554 + // Scheduled IndexNow submissions, deferred from save_post so a slow
555 + // IndexNow round-trip cannot stall the editor or REST write. Registered
556 + // statically because the class is loaded but never instantiated on
557 + // cron loads. The hook name matches the uninstall cron sweep exactly.
558 + add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
559 +
560 + // Construct the robots.txt manager on the public load path so its
561 + // `robots_txt` filter is registered before a plain GET /robots.txt is
562 + // served. The filter is attached in the singleton's constructor, and
563 + // previously only admin screens built the instance — so the virtual
564 + // robots.txt rules and sitemap lines never rendered on the frontend.
565 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
566 + Metasync_Robots_Txt::get_instance();
567 +
521 568 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
522 569 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
523 570 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
524 571 }
@@ -597,9 +644,9 @@
597 644 $original_url = $url;
598 645 foreach ($url_replacements as $production => $staging) {
599 646 if (strpos($url, $production) === 0) {
600 647 $url = str_replace($production, $staging, $url);
601 - error_log("MetaSync Endpoint Filter: Replaced {$production} with {$staging} in URL: {$original_url}");
648 + 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
602 649 break;
603 650 }
604 651 }
605 652
@@ -883,9 +930,9 @@
883 930 }
884 931
885 932 if (!function_exists('openssl_encrypt')) {
886 933 // OpenSSL unavailable — store plaintext rather than lose the key.
887 - error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.');
934 + 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
888 935 return $plaintext;
889 936 }
890 937
891 938 $key = self::api_key_crypto_key();
@@ -894,9 +941,9 @@
894 941 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
895 942
896 943 if ($ciphertext === false) {
897 944 // Encryption failed — fall back to plaintext storage, but say so.
898 - error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.');
945 + 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
899 946 return $plaintext;
900 947 }
901 948
902 949 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1103,9 +1150,9 @@
1103 1150 }
1104 1151
1105 1152 if (!function_exists('openssl_encrypt')) {
1106 1153 // OpenSSL unavailable — store plaintext rather than lose the secret.
1107 - error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1154 + 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
1108 1155 return $plaintext;
1109 1156 }
1110 1157
1111 1158 $key = self::secret_crypto_key();
@@ -1114,9 +1161,9 @@
1114 1161 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1115 1162
1116 1163 if ($ciphertext === false) {
1117 1164 // Encryption failed — fall back to plaintext storage, but say so.
1118 - error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.');
1165 + 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
1119 1166 return $plaintext;
1120 1167 }
1121 1168
1122 1169 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1541,9 +1588,9 @@
1541 1588 // This could be extended to store in a dedicated log table
1542 1589
1543 1590 } catch (Exception $e) {
1544 1591 // Fallback logging if structured logging fails
1545 - error_log('MetaSync API Key Event Logging Error: ' . $e->getMessage());
1592 + 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
1546 1593 }
1547 1594 }
1548 1595
1549 1596 /**
@@ -1607,9 +1654,9 @@
1607 1654 $global_hits = get_transient($global_rate_key);
1608 1655 if ($global_hits !== false && $global_hits >= 50) {
1609 1656 // More than 50 404s per minute - stop logging to protect database
1610 1657 if ($global_hits === 50) {
1611 - error_log('MetaSync 404 Monitor: Rate limit exceeded - 50+ 404s per minute. Pausing logging.');
1658 + 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
1612 1659 }
1613 1660 set_transient($global_rate_key, $global_hits + 1, 60);
1614 1661 return;
1615 1662 }
@@ -1634,9 +1681,9 @@
1634 1681 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1635 1682 $db_404 = new Metasync_Error_Monitor_Database();
1636 1683
1637 1684 // Get user agent (sanitized)
1638 - $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';
1685 + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
1639 1686
1640 1687 // Log the 404 error
1641 1688 $result = $db_404->update([
1642 1689 'uri' => $current_url,
@@ -1662,9 +1709,9 @@
1662 1709 $host = $_SERVER['SERVER_NAME'];
1663 1710 }
1664 1711 if (empty($host)) {
1665 1712 // Fallback to WordPress site URL if available
1666 - $host = parse_url(home_url(), PHP_URL_HOST);
1713 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1667 1714 }
1668 1715
1669 1716 // Safely get REQUEST_URI with fallback
1670 1717 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';