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 +53 -20 2.6.24 → 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
@@ -611,9 +644,9 @@
611 644 $original_url = $url;
612 645 foreach ($url_replacements as $production => $staging) {
613 646 if (strpos($url, $production) === 0) {
614 647 $url = str_replace($production, $staging, $url);
615 - 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
616 649 break;
617 650 }
618 651 }
619 652
@@ -897,9 +930,9 @@
897 930 }
898 931
899 932 if (!function_exists('openssl_encrypt')) {
900 933 // 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.');
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
902 935 return $plaintext;
903 936 }
904 937
905 938 $key = self::api_key_crypto_key();
@@ -908,9 +941,9 @@
908 941 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
909 942
910 943 if ($ciphertext === false) {
911 944 // 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.');
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
913 946 return $plaintext;
914 947 }
915 948
916 949 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1117,9 +1150,9 @@
1117 1150 }
1118 1151
1119 1152 if (!function_exists('openssl_encrypt')) {
1120 1153 // OpenSSL unavailable — store plaintext rather than lose the secret.
1121 - 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
1122 1155 return $plaintext;
1123 1156 }
1124 1157
1125 1158 $key = self::secret_crypto_key();
@@ -1128,9 +1161,9 @@
1128 1161 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1129 1162
1130 1163 if ($ciphertext === false) {
1131 1164 // 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.');
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
1133 1166 return $plaintext;
1134 1167 }
1135 1168
1136 1169 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1555,9 +1588,9 @@
1555 1588 // This could be extended to store in a dedicated log table
1556 1589
1557 1590 } catch (Exception $e) {
1558 1591 // Fallback logging if structured logging fails
1559 - 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
1560 1593 }
1561 1594 }
1562 1595
1563 1596 /**
@@ -1621,9 +1654,9 @@
1621 1654 $global_hits = get_transient($global_rate_key);
1622 1655 if ($global_hits !== false && $global_hits >= 50) {
1623 1656 // More than 50 404s per minute - stop logging to protect database
1624 1657 if ($global_hits === 50) {
1625 - 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
1626 1659 }
1627 1660 set_transient($global_rate_key, $global_hits + 1, 60);
1628 1661 return;
1629 1662 }
@@ -1648,9 +1681,9 @@
1648 1681 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1649 1682 $db_404 = new Metasync_Error_Monitor_Database();
1650 1683
1651 1684 // Get user agent (sanitized)
1652 - $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'])) : '';
1653 1686
1654 1687 // Log the 404 error
1655 1688 $result = $db_404->update([
1656 1689 'uri' => $current_url,
@@ -1676,9 +1709,9 @@
1676 1709 $host = $_SERVER['SERVER_NAME'];
1677 1710 }
1678 1711 if (empty($host)) {
1679 1712 // Fallback to WordPress site URL if available
1680 - $host = parse_url(home_url(), PHP_URL_HOST);
1713 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1681 1714 }
1682 1715
1683 1716 // Safely get REQUEST_URI with fallback
1684 1717 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';