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 +141 -22 2.6.21 → 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')) {
@@ -171,9 +181,17 @@
171 181 if (!class_exists('Metasync_Admin_Navigation')) {
172 182 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
173 183 }
174 184
185 + // Hooked on init at priority 0, before any theme or plugin callback
186 + // runs, so it cannot wait for a lazy autoload. See define_public_hooks.
187 + if (!class_exists('Metasync_BookingPress_Compat')) {
188 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-bookingpress-compat.php';
189 + }
190 +
175 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
176 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
177 195 $this->db_redirection = new Metasync_Redirection_Database();
178 196 }
179 197
@@ -261,16 +279,12 @@
261 279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
262 280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
263 281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
264 282
265 - // Auto-update filter
266 - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
267 -
268 283 // Search Atlas Connect development/testing endpoints
269 284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
270 285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
271 286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
272 - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
273 287
274 288
275 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
276 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -275,14 +289,22 @@
275 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
276 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
277 291 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
278 292
293 + // Unified "SEO Suite" meta box — consolidates the separate Classic-editor
294 + // meta boxes above into one tabbed box (presentation-only; save handlers
295 + // unchanged). Classic editor only; the block editor keeps its SEO sidebar.
296 + // Self-registers its hooks in the constructor. Opt out via the
297 + // `metasync_enable_seo_suite` filter.
298 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
299 + new Metasync_Seo_Suite();
300 +
279 301 // SEO Health CSV export: must run on admin_init (before output).
280 302 // Cheap $_GET check avoids loading the class on every admin page.
281 303 if (
282 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
283 - $_GET['export'] === 'csv' &&
284 - 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
285 307 ) {
286 308 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
287 309 }
288 310 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
@@ -355,9 +377,12 @@
355 377
356 378 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
357 379 $rest_api = $plugin_public->get_rest_api();
358 380 $seo_output = $plugin_public->get_seo_output();
359 - $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');
360 385
361 386 // Asset enqueue hooks (Metasync_Public)
362 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
363 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -362,8 +387,12 @@
362 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
363 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
364 389 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
365 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 +
366 395 // Elementor editor CSS injection
367 396 if (class_exists('\Elementor\Plugin')) {
368 397 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
369 398 }
@@ -376,8 +405,21 @@
376 405 // Initialize centralized SEO conflict handler (singleton — suppresses
377 406 // third-party SEO plugin descriptions when MetaSync provides its own).
378 407 Metasync_SEO_Conflict_Handler::get_instance();
379 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 +
380 422 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
381 423 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
382 424 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
383 425 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
@@ -395,15 +437,28 @@
395 437 // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
396 438 $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
397 439 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
398 440
399 - // Hreflang / language alternates output (wp_head @ priority 2).
400 - $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);
401 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);
402 449
403 450 // Edge Cache: detect Cloudways Varnish and persist for settings UI
404 451 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
405 452
453 + // BookingPress starts a PHP session on every front-end request (init
454 + // at priority 1), which makes hosts skip page caching site-wide. Its
455 + // booking flows manage their own sessions inside their AJAX handlers,
456 + // so unhook the global start. Priority 0 keeps this ahead of theirs.
457 + // Registered with add_action directly (like Metasync_Oxygen_Compat)
458 + // because the loader's $component parameter is typed to objects.
459 + add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
460 +
406 461 // Sitemap exclusions for disabled archive types
407 462 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
408 463 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
409 464 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -495,8 +550,22 @@
495 550 // on read-only web roots and nginx hosts that 403 direct static .txt access.
496 551 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
497 552 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
498 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 +
499 568 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
500 569 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
501 570 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
502 571 }
@@ -575,9 +644,9 @@
575 644 $original_url = $url;
576 645 foreach ($url_replacements as $production => $staging) {
577 646 if (strpos($url, $production) === 0) {
578 647 $url = str_replace($production, $staging, $url);
579 - 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
580 649 break;
581 650 }
582 651 }
583 652
@@ -655,12 +724,16 @@
655 724 * @param string $meta_key Meta key being written.
656 725 * @param mixed $meta_value Meta value being written.
657 726 */
658 727 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
659 - if (!class_exists('Metasync_Plugin_Sync')) {
728 + if (!self::is_metasync_meta_key($meta_key)) {
660 729 return;
661 730 }
662 731
732 + if (!self::sync_layer_handles('on_meta_updated')) {
733 + return;
734 + }
735 +
663 736 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
664 737 }
665 738
666 739 /**
@@ -675,16 +748,62 @@
675 748 * @param int $post_id Post ID.
676 749 * @param string $meta_key Meta key being deleted.
677 750 */
678 751 public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
679 - if (!class_exists('Metasync_Plugin_Sync')) {
752 + if (!self::is_metasync_meta_key($meta_key)) {
680 753 return;
681 754 }
682 755
756 + if (!self::sync_layer_handles('on_meta_deleted')) {
757 + return;
758 + }
759 +
683 760 Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
684 761 }
685 762
686 763 /**
764 + * Is this meta key one the post sync layer could possibly care about?
765 + *
766 + * Cheap string gate, no autoload, no singleton. Every watched post key is
767 + * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
768 + * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
769 + * writes out of the sync layer entirely — `deleted_post_meta` and
770 + * `updated_post_meta` fire for every key on the site, including on front-end
771 + * requests, and crossing into the sync layer for keys it will only discard
772 + * costs an autoload plus a singleton on the hot path.
773 + *
774 + * Deliberately broader than the sync layer's own key maps: those stay the
775 + * single source of exact truth, so a new MetaSync key needs no change here.
776 + *
777 + * @param string $meta_key Meta key being written or deleted.
778 + * @return bool
779 + */
780 + private static function is_metasync_meta_key($meta_key) {
781 + return strncmp($meta_key, '_metasync_', 10) === 0
782 + || strncmp($meta_key, 'metasync_', 9) === 0;
783 + }
784 +
785 + /**
786 + * Can the post sync layer actually handle this hook right now?
787 + *
788 + * A partially updated install can leave a newer class-metasync.php beside an
789 + * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
790 + * is enough. Calling a method the loaded class does not define is a fatal,
791 + * and because these bridges run on `wp_head` via third-party meta writes it
792 + * takes the front end down rather than degrading. Check before dispatching.
793 + *
794 + * Checked on the class, not an instance, so a mismatch skips the singleton.
795 + *
796 + * @param string $method Sync-layer method about to be called.
797 + * @return bool
798 + */
799 + private static function sync_layer_handles($method) {
800 + return class_exists('Metasync_Plugin_Sync')
801 + && method_exists('Metasync_Plugin_Sync', 'get_instance')
802 + && method_exists('Metasync_Plugin_Sync', $method);
803 + }
804 +
805 + /**
687 806 * Save current theme information to MetaSync options
688 807 * This runs in WordPress admin context, not during REST API requests
689 808 * Safe to use wp_get_theme() here
690 809 */
@@ -811,9 +930,9 @@
811 930 }
812 931
813 932 if (!function_exists('openssl_encrypt')) {
814 933 // OpenSSL unavailable — store plaintext rather than lose the key.
815 - 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
816 935 return $plaintext;
817 936 }
818 937
819 938 $key = self::api_key_crypto_key();
@@ -822,9 +941,9 @@
822 941 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
823 942
824 943 if ($ciphertext === false) {
825 944 // Encryption failed — fall back to plaintext storage, but say so.
826 - 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
827 946 return $plaintext;
828 947 }
829 948
830 949 return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1031,9 +1150,9 @@
1031 1150 }
1032 1151
1033 1152 if (!function_exists('openssl_encrypt')) {
1034 1153 // OpenSSL unavailable — store plaintext rather than lose the secret.
1035 - 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
1036 1155 return $plaintext;
1037 1156 }
1038 1157
1039 1158 $key = self::secret_crypto_key();
@@ -1042,9 +1161,9 @@
1042 1161 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1043 1162
1044 1163 if ($ciphertext === false) {
1045 1164 // Encryption failed — fall back to plaintext storage, but say so.
1046 - 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
1047 1166 return $plaintext;
1048 1167 }
1049 1168
1050 1169 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1469,9 +1588,9 @@
1469 1588 // This could be extended to store in a dedicated log table
1470 1589
1471 1590 } catch (Exception $e) {
1472 1591 // Fallback logging if structured logging fails
1473 - 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
1474 1593 }
1475 1594 }
1476 1595
1477 1596 /**
@@ -1535,9 +1654,9 @@
1535 1654 $global_hits = get_transient($global_rate_key);
1536 1655 if ($global_hits !== false && $global_hits >= 50) {
1537 1656 // More than 50 404s per minute - stop logging to protect database
1538 1657 if ($global_hits === 50) {
1539 - 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
1540 1659 }
1541 1660 set_transient($global_rate_key, $global_hits + 1, 60);
1542 1661 return;
1543 1662 }
@@ -1562,9 +1681,9 @@
1562 1681 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1563 1682 $db_404 = new Metasync_Error_Monitor_Database();
1564 1683
1565 1684 // Get user agent (sanitized)
1566 - $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'])) : '';
1567 1686
1568 1687 // Log the 404 error
1569 1688 $result = $db_404->update([
1570 1689 'uri' => $current_url,
@@ -1590,9 +1709,9 @@
1590 1709 $host = $_SERVER['SERVER_NAME'];
1591 1710 }
1592 1711 if (empty($host)) {
1593 1712 // Fallback to WordPress site URL if available
1594 - $host = parse_url(home_url(), PHP_URL_HOST);
1713 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1595 1714 }
1596 1715
1597 1716 // Safely get REQUEST_URI with fallback
1598 1717 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';