PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 All 139 releases
← All changes | includes/class-metasync.php +727 -27 2.6.3trunk View file →
@@ -71,10 +71,19 @@
71 71
72 72
73 73
74 74 public const option_name = "metasync_options";
75 -
75 +
76 76 /**
77 + * Dedicated option key for heartbeat throttle timestamps.
78 + *
79 + * Stored separately from the main options blob so writes to last_heart_beat
80 + * and last_heartbeat_at do not race with concurrent settings writes during
81 + * the 15-second wp_remote_post window in SyncCustomerParams.
82 + */
83 + public const heartbeat_throttle_option = "metasync_heartbeat_throttle";
84 +
85 + /**
77 86 * Search Atlas Domain Constants
78 87 * Centralized constants for all Search Atlas service endpoints
79 88 */
80 89 public const HOMEPAGE_DOMAIN = "https://searchatlas.com";
@@ -84,8 +93,27 @@
84 93 public const SUPPORT_EMAIL = "support@searchatlas.com";
85 94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
86 95
87 96 /**
97 + * Storage prefix marking a Search Atlas API key value as encrypted at rest.
98 + */
99 + private const API_KEY_ENC_PREFIX = 'enc_v1:';
100 +
101 + /**
102 + * Request-scoped memo for the decrypted Search Atlas API key.
103 + *
104 + * null = not yet loaded this request
105 + * false = load attempted but decryption failed (salts changed / corrupt)
106 + * string = decrypted plaintext (may be '')
107 + *
108 + * Never written to a transient or the DB — exists only for the lifetime
109 + * of the current request.
110 + *
111 + * @var string|false|null
112 + */
113 + private static $memo_api_key = null;
114 +
115 + /**
88 116 * Define the core functionality of the plugin.
89 117 *
90 118 * Set the plugin name and the plugin version that can be used throughout the plugin.
91 119 * Load the dependencies, define the locale, and set the hooks for the admin area and
@@ -130,11 +158,40 @@
130 158 // WordPress core — cannot be autoloaded.
131 159 require_once ABSPATH . 'wp-admin/includes/taxonomy.php';
132 160
133 161 // Procedural init file — not a class, must stay explicit.
134 - require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
162 + if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
163 + require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
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');
166 + }
135 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 +
178 + // Admin navigation is referenced statically from frontend-reachable
179 + // includes (heartbeat/connect managers). Require it explicitly here so
180 + // the static call never fatals when wp_head fires before autoload.
181 + if (!class_exists('Metasync_Admin_Navigation')) {
182 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
183 + }
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 +
136 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
137 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
138 195 $this->db_redirection = new Metasync_Redirection_Database();
139 196 }
140 197
@@ -210,27 +267,28 @@
210 267
211 268 # Redirection import AJAX handler
212 269 $redirection_handler = new Metasync_Redirection($this->db_redirection);
213 270 $this->loader->add_action('wp_ajax_metasync_import_redirections', $redirection_handler, 'handle_import_ajax');
214 -
271 + $this->loader->add_action('wp_ajax_metasync_check_redirects_health', $redirection_handler, 'handle_health_check_ajax');
272 +
215 273 // HeartBeat API Receive Respond and Settings.
216 274 $this->loader->add_action('heartbeat_settings', $plugin_admin, 'metasync_heartbeat_settings');
217 275 $this->loader->add_action('heartbeat_received', $plugin_admin, 'metasync_received_data', 10, 2);
218 - $this->loader->add_action('wp_ajax_lgSendCustomerParams', $plugin_admin, 'lgSendCustomerParams');
276 + $this->loader->add_action('wp_ajax_metasync_send_customer_params', $plugin_admin, 'lgSendCustomerParams');
219 277
220 278 // Search Atlas Connect endpoints - authenticates with Search Atlas platform to retrieve SA API key and Otto UUID
221 - $this->loader->add_action('wp_ajax_generate_searchatlas_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
222 - $this->loader->add_action('wp_ajax_check_searchatlas_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
223 - $this->loader->add_action('wp_ajax_reset_searchatlas_authentication', $plugin_admin, 'reset_searchatlas_authentication');
279 + $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
280 + $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
281 + $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
224 282
225 283 // Auto-update filter
226 284 $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
227 285
228 286 // Search Atlas Connect development/testing endpoints
229 - $this->loader->add_action('wp_ajax_test_enhanced_searchatlas_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
230 - $this->loader->add_action('wp_ajax_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
231 - $this->loader->add_action('wp_ajax_test_searchatlas_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
232 - $this->loader->add_action('wp_ajax_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
287 + $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
288 + $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
289 + $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');
233 291
234 292
235 293 $post_meta_setting = new Metasync_Post_Meta_Settings();
236 294 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -235,8 +293,16 @@
235 293 $post_meta_setting = new Metasync_Post_Meta_Settings();
236 294 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
237 295 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
238 296
297 + // Unified "SEO Suite" meta box — consolidates the separate Classic-editor
298 + // meta boxes above into one tabbed box (presentation-only; save handlers
299 + // unchanged). Classic editor only; the block editor keeps its SEO sidebar.
300 + // Self-registers its hooks in the constructor. Opt out via the
301 + // `metasync_enable_seo_suite` filter.
302 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-seo-suite.php';
303 + new Metasync_Seo_Suite();
304 +
239 305 // SEO Health CSV export: must run on admin_init (before output).
240 306 // Cheap $_GET check avoids loading the class on every admin page.
241 307 if (
242 308 isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
@@ -246,8 +312,22 @@
246 312 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
247 313 }
248 314 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
249 315
316 + // SEO meta columns on the posts/pages list tables (WP-624).
317 + // Registered for both the `posts` and `pages` variants of each hook so the
318 + // columns reach every supported post type; the callbacks bail on unsupported
319 + // ones. Hidden by default — users opt in from Screen Options.
320 + $seo_columns = Metasync_SEO_Columns::get_instance();
321 + $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
322 + $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
323 + $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
324 + $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
325 + $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
326 + $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
327 + $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
328 + $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
329 +
250 330 // Initialize XML Sitemap auto-update hooks if enabled
251 331 // Note: Must not be gated by is_admin() because Gutenberg saves posts
252 332 // via the REST API where is_admin() returns false, and REST_REQUEST
253 333 // is not yet defined at plugin load time
@@ -298,18 +378,15 @@
298 378
299 379 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
300 380 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
301 381
302 -
303 -
304 - $optimal_settings = new Metasync_Optimal_Settings();
305 - $this->loader->add_filter('wp_robots', $optimal_settings, 'add_robots_meta');
306 - $this->loader->add_action('the_content', $optimal_settings, 'add_attributes_external_links');
307 -
308 382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
309 383 $rest_api = $plugin_public->get_rest_api();
310 384 $seo_output = $plugin_public->get_seo_output();
311 - $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');
312 389
313 390 // Asset enqueue hooks (Metasync_Public)
314 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
315 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -314,8 +391,12 @@
314 391 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
315 392 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
316 393 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
317 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 +
318 399 // Elementor editor CSS injection
319 400 if (class_exists('\Elementor\Plugin')) {
320 401 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
321 402 }
@@ -328,24 +409,60 @@
328 409 // Initialize centralized SEO conflict handler (singleton — suppresses
329 410 // third-party SEO plugin descriptions when MetaSync provides its own).
330 411 Metasync_SEO_Conflict_Handler::get_instance();
331 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 +
332 426 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
333 427 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
334 428 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
335 429 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
336 430
431 + // Post-level plugin sync: propagate MetaSync post meta into
432 + // Yoast/Rank Math/AIOSEO post storage on every write.
433 + $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
434 + $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
435 + // Deletes matter too: clearing the last checkbox in a meta box removes
436 + // the meta row, which fires neither hook above.
437 + $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
438 +
337 439 // SEO Output hooks (Metasync_Seo_Output)
338 440 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
441 + // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
442 + $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
339 443 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
340 444
341 - // Hreflang / language alternates output (wp_head @ priority 2).
342 - $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);
343 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);
344 453
345 454 // Edge Cache: detect Cloudways Varnish and persist for settings UI
346 455 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
347 456
457 + // BookingPress starts a PHP session on every front-end request (init
458 + // at priority 1), which makes hosts skip page caching site-wide. Its
459 + // booking flows manage their own sessions inside their AJAX handlers,
460 + // so unhook the global start. Priority 0 keeps this ahead of theirs.
461 + // Registered with add_action directly (like Metasync_Oxygen_Compat)
462 + // because the loader's $component parameter is typed to objects.
463 + add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
464 +
348 465 // Sitemap exclusions for disabled archive types
349 466 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
350 467 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
351 468 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -378,10 +495,22 @@
378 495 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
379 496
380 497 // REST API hooks (Metasync_Rest_Api)
381 498 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
499 + // Coexist with third-party JWT auth plugins: clear their prior auth error
500 + // for metasync/v1 requests when our own API key validates. The Tmeister
501 + // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
502 + // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
503 + // later priority (11) to clear it for our namespace only.
504 + $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
505 + // Coexist with site-wide "authenticated users only" REST restrictions.
506 + // Those hook rest_authentication_errors, which WP applies before dispatch,
507 + // so rest_pre_dispatch and permission_callback never run. Hook it late
508 + // (99) to clear the error for metasync/v1 requests that present a valid
509 + // plugin API key; every other route keeps the site's restriction.
510 + $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
382 511 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
383 - $this->loader->add_action('wp_ajax_lglogin', $rest_api, 'linkgraph_login');
512 + $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
384 513
385 514 // Robots meta filter (Metasync_Seo_Output)
386 515 $this->loader->add_filter('wp_robots', $seo_output, 'metasync_wp_robots_meta');
387 516
@@ -420,10 +549,31 @@
420 549 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
421 550 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
422 551 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
423 552
553 + // Serve the IndexNow key file virtually at /{key}.txt so it works
554 + // on read-only web roots and nginx hosts that 403 direct static .txt access.
555 + require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
556 + add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
557 +
558 + // Scheduled IndexNow submissions, deferred from save_post so a slow
559 + // IndexNow round-trip cannot stall the editor or REST write. Registered
560 + // statically because the class is loaded but never instantiated on
561 + // cron loads. The hook name matches the uninstall cron sweep exactly.
562 + add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
563 +
564 + // Construct the robots.txt manager on the public load path so its
565 + // `robots_txt` filter is registered before a plain GET /robots.txt is
566 + // served. The filter is attached in the singleton's constructor, and
567 + // previously only admin screens built the instance — so the virtual
568 + // robots.txt rules and sitemap lines never rendered on the frontend.
569 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
570 + Metasync_Robots_Txt::get_instance();
571 +
424 572 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
425 - $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
573 + if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
574 + $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
575 + }
426 576 }
427 577
428 578 /**
429 579 * One-time upgrade routine: regenerate the XML sitemap so that Beaver Builder
@@ -446,11 +596,12 @@
446 596 require_once plugin_dir_path( dirname( __FILE__ ) ) . 'sitemap/class-metasync-sitemap-generator.php';
447 597 }
448 598 $sitemap = new Metasync_Sitemap_Generator();
449 599 $sitemap->generate_sitemap();
600 + update_option( $done_key, true );
450 601 }
451 -
452 - update_option( $done_key, true );
602 + // If sitemap is not in use, don't set the flag — retry on next load
603 + // so that enabling sitemaps later will still clean up BB templates.
453 604 }
454 605
455 606 /**
456 607 * Initialize endpoint URL filtering for staging mode
@@ -564,8 +715,99 @@
564 715 );
565 716 }
566 717
567 718 /**
719 + * Post meta update hook: mirror MetaSync post meta (`_metasync_*`)
720 + * into the active third-party SEO plugins' post storage.
721 + *
722 + * Registered on both `updated_post_meta` and `added_post_meta` so new
723 + * fields are synced the first time they are written as well as on
724 + * subsequent updates.
725 + *
726 + * @param int $meta_id Meta row ID (unused).
727 + * @param int $post_id Post ID.
728 + * @param string $meta_key Meta key being written.
729 + * @param mixed $meta_value Meta value being written.
730 + */
731 + public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
732 + if (!self::is_metasync_meta_key($meta_key)) {
733 + return;
734 + }
735 +
736 + if (!self::sync_layer_handles('on_meta_updated')) {
737 + return;
738 + }
739 +
740 + Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
741 + }
742 +
743 + /**
744 + * Bridge deleted_post_meta to the sync layer.
745 + *
746 + * Clearing the last checkbox in a meta box deletes its meta row rather than
747 + * updating it, so the update hooks above never fire and mirrored values can
748 + * go stale. Only the legacy robots keys are acted on; see
749 + * Metasync_Plugin_Sync::on_meta_deleted().
750 + *
751 + * @param array $meta_ids Meta row IDs (unused).
752 + * @param int $post_id Post ID.
753 + * @param string $meta_key Meta key being deleted.
754 + */
755 + public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
756 + if (!self::is_metasync_meta_key($meta_key)) {
757 + return;
758 + }
759 +
760 + if (!self::sync_layer_handles('on_meta_deleted')) {
761 + return;
762 + }
763 +
764 + Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
765 + }
766 +
767 + /**
768 + * Is this meta key one the post sync layer could possibly care about?
769 + *
770 + * Cheap string gate, no autoload, no singleton. Every watched post key is
771 + * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
772 + * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
773 + * writes out of the sync layer entirely — `deleted_post_meta` and
774 + * `updated_post_meta` fire for every key on the site, including on front-end
775 + * requests, and crossing into the sync layer for keys it will only discard
776 + * costs an autoload plus a singleton on the hot path.
777 + *
778 + * Deliberately broader than the sync layer's own key maps: those stay the
779 + * single source of exact truth, so a new MetaSync key needs no change here.
780 + *
781 + * @param string $meta_key Meta key being written or deleted.
782 + * @return bool
783 + */
784 + private static function is_metasync_meta_key($meta_key) {
785 + return strncmp($meta_key, '_metasync_', 10) === 0
786 + || strncmp($meta_key, 'metasync_', 9) === 0;
787 + }
788 +
789 + /**
790 + * Can the post sync layer actually handle this hook right now?
791 + *
792 + * A partially updated install can leave a newer class-metasync.php beside an
793 + * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
794 + * is enough. Calling a method the loaded class does not define is a fatal,
795 + * and because these bridges run on `wp_head` via third-party meta writes it
796 + * takes the front end down rather than degrading. Check before dispatching.
797 + *
798 + * Checked on the class, not an instance, so a mismatch skips the singleton.
799 + *
800 + * @param string $method Sync-layer method about to be called.
801 + * @return bool
802 + */
803 + private static function sync_layer_handles($method) {
804 + return class_exists('Metasync_Plugin_Sync')
805 + && method_exists('Metasync_Plugin_Sync', 'get_instance')
806 + && method_exists('Metasync_Plugin_Sync', $method);
807 + }
808 +
809 + /**
568 810 * Save current theme information to MetaSync options
569 811 * This runs in WordPress admin context, not during REST API requests
570 812 * Safe to use wp_get_theme() here
571 813 */
@@ -632,13 +874,396 @@
632 874 ]
633 875 );
634 876 }
635 877 }
636 -
878 +
637 879 return $result;
638 880 }
639 881
640 882 /**
883 + * Derive the 32-byte AES key from existing WordPress salts.
884 + *
885 + * No new secret is stored anywhere — the key material is the concatenation
886 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
887 + * (e.g. wp-config regenerated) the derived key changes and previously
888 + * encrypted values can no longer be decrypted, which is handled gracefully
889 + * by the callers (re-authenticate state) rather than fataling.
890 + *
891 + * @return string 32 raw bytes.
892 + */
893 + private static function api_key_crypto_key()
894 + {
895 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
896 + return hash('sha256', $material, true);
897 + }
898 +
899 + /**
900 + * Determine whether a stored value is in the encrypted-at-rest format.
901 + *
902 + * @param mixed $value
903 + * @return bool
904 + */
905 + public static function is_encrypted_api_key($value)
906 + {
907 + return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
908 + }
909 +
910 + /**
911 + * Encrypt a plaintext Search Atlas API key for storage at rest.
912 + *
913 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
914 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
915 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
916 + *
917 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
918 + * unchanged (availability over hard-fail) and the degradation is logged so
919 + * it cannot go unnoticed.
920 + *
921 + * @param string $plaintext
922 + * @return string Encrypted blob, or '' when $plaintext is empty.
923 + */
924 + public static function encrypt_api_key($plaintext)
925 + {
926 + $plaintext = (string) $plaintext;
927 + if ($plaintext === '') {
928 + return '';
929 + }
930 +
931 + // Already encrypted — do not double-encrypt.
932 + if (self::is_encrypted_api_key($plaintext)) {
933 + return $plaintext;
934 + }
935 +
936 + if (!function_exists('openssl_encrypt')) {
937 + // 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.');
939 + return $plaintext;
940 + }
941 +
942 + $key = self::api_key_crypto_key();
943 + $iv = random_bytes(12);
944 + $tag = '';
945 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
946 +
947 + if ($ciphertext === false) {
948 + // 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.');
950 + return $plaintext;
951 + }
952 +
953 + return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
954 + }
955 +
956 + /**
957 + * Decrypt a stored Search Atlas API key value.
958 + *
959 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
960 + * (returned unchanged, supporting installs that pre-date encryption). On any
961 + * decryption failure (salt change / corruption) returns false so callers can
962 + * surface a re-authenticate state instead of using a bad key.
963 + *
964 + * @param mixed $value
965 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
966 + */
967 + public static function decrypt_api_key($value)
968 + {
969 + if (!is_string($value) || $value === '') {
970 + return '';
971 + }
972 +
973 + if (!self::is_encrypted_api_key($value)) {
974 + // Legacy plaintext key.
975 + return $value;
976 + }
977 +
978 + if (!function_exists('openssl_decrypt')) {
979 + return false;
980 + }
981 +
982 + $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
983 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
984 + return false;
985 + }
986 +
987 + $iv = substr($raw, 0, 12);
988 + $tag = substr($raw, 12, 16);
989 + $ciphertext = substr($raw, 28);
990 +
991 + $key = self::api_key_crypto_key();
992 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
993 +
994 + if ($plaintext === false) {
995 + return false;
996 + }
997 +
998 + return $plaintext;
999 + }
1000 +
1001 + /**
1002 + * Get the decrypted Search Atlas API key, memoized for the request.
1003 + *
1004 + * Decrypts at most once per request and never persists the decrypted value
1005 + * anywhere. When a legacy plaintext key is found it is migrated to the
1006 + * encrypted format in place (one-time migration on load). Returns:
1007 + * - the plaintext key (string, possibly '')
1008 + * - false when an encrypted value exists but cannot be decrypted
1009 + * (salts changed / corrupt) — callers should treat this as a
1010 + * "please re-authenticate" state.
1011 + *
1012 + * @return string|false
1013 + */
1014 + public static function get_searchatlas_api_key()
1015 + {
1016 + if (self::$memo_api_key !== null) {
1017 + return self::$memo_api_key;
1018 + }
1019 +
1020 + $general = self::get_option('general');
1021 + $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
1022 +
1023 + // One-time migration: a non-empty legacy plaintext value is encrypted
1024 + // in place the first time it is read after this feature ships.
1025 + if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
1026 + $encrypted = self::encrypt_api_key($stored);
1027 + if (self::is_encrypted_api_key($encrypted)) {
1028 + $options = self::get_option();
1029 + if (!is_array($options)) {
1030 + $options = [];
1031 + }
1032 + $options['general']['searchatlas_api_key'] = $encrypted;
1033 + self::set_option($options);
1034 + }
1035 + }
1036 +
1037 + self::$memo_api_key = self::decrypt_api_key($stored);
1038 + return self::$memo_api_key;
1039 + }
1040 +
1041 + /**
1042 + * Clear the request-scoped decrypted-key memo.
1043 + *
1044 + * Call after any write that changes the stored searchatlas_api_key so a
1045 + * subsequent read in the same request reflects the new value.
1046 + */
1047 + public static function invalidate_api_key_cache()
1048 + {
1049 + self::$memo_api_key = null;
1050 + }
1051 +
1052 + /**
1053 + * Read the heartbeat throttle state from its dedicated option.
1054 + *
1055 + * Backfills from the legacy location (`metasync_options['general']`) the
1056 + * first time the dedicated option is empty, so existing installs keep
1057 + * their throttle history across the migration.
1058 + */
1059 + public static function get_heartbeat_throttle(): array
1060 + {
1061 + $value = get_option(self::heartbeat_throttle_option, []);
1062 + if (is_array($value) && !empty($value)) {
1063 + return $value;
1064 + }
1065 +
1066 + $general = self::get_option('general');
1067 + if (is_array($general) && (array_key_exists('last_heart_beat', $general) || array_key_exists('last_heartbeat_at', $general))) {
1068 + $throttle = [
1069 + 'last_heart_beat' => $general['last_heart_beat'] ?? 0,
1070 + 'last_heartbeat_at' => $general['last_heartbeat_at'] ?? null,
1071 + ];
1072 + update_option(self::heartbeat_throttle_option, $throttle);
1073 + return $throttle;
1074 + }
1075 +
1076 + return [];
1077 + }
1078 +
1079 + /**
1080 + * Merge fields into the dedicated heartbeat throttle option.
1081 + *
1082 + * Writes via update_option directly so the main metasync_options blob is
1083 + * never read or rewritten — avoiding the read-modify-write race with
1084 + * concurrent settings saves.
1085 + */
1086 + public static function set_heartbeat_throttle(array $fields): void
1087 + {
1088 + $existing = get_option(self::heartbeat_throttle_option, []);
1089 + if (!is_array($existing)) {
1090 + $existing = [];
1091 + }
1092 + $merged = array_merge($existing, $fields);
1093 + update_option(self::heartbeat_throttle_option, $merged);
1094 + }
1095 +
1096 + /**
1097 + * Storage prefix marking a secret value as encrypted at rest.
1098 + */
1099 + private const SECRET_ENC_PREFIX = 'enc_v1:';
1100 +
1101 + /**
1102 + * Derive the 32-byte AES key from existing WordPress salts.
1103 + *
1104 + * No new secret is stored anywhere — the key material is the concatenation
1105 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
1106 + * (e.g. wp-config regenerated) the derived key changes and previously
1107 + * encrypted values can no longer be decrypted, which callers handle
1108 + * gracefully rather than fataling.
1109 + *
1110 + * @return string 32 raw bytes.
1111 + */
1112 + private static function secret_crypto_key()
1113 + {
1114 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
1115 + return hash('sha256', $material, true);
1116 + }
1117 +
1118 + /**
1119 + * Determine whether a stored value is in the encrypted-at-rest format.
1120 + *
1121 + * @param mixed $value
1122 + * @return bool
1123 + */
1124 + public static function is_encrypted_secret($value)
1125 + {
1126 + return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
1127 + }
1128 +
1129 + /**
1130 + * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
1131 + * storage at rest.
1132 + *
1133 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1134 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1135 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
1136 + *
1137 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
1138 + * unchanged (availability over hard-fail) and the degradation is logged so
1139 + * it cannot go unnoticed.
1140 + *
1141 + * @param string $plaintext
1142 + * @return string Encrypted blob, or '' when $plaintext is empty.
1143 + */
1144 + public static function encrypt_secret($plaintext)
1145 + {
1146 + $plaintext = (string) $plaintext;
1147 + if ($plaintext === '') {
1148 + return '';
1149 + }
1150 +
1151 + // Already encrypted — do not double-encrypt.
1152 + if (self::is_encrypted_secret($plaintext)) {
1153 + return $plaintext;
1154 + }
1155 +
1156 + if (!function_exists('openssl_encrypt')) {
1157 + // OpenSSL unavailable — store plaintext rather than lose the secret.
1158 + error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1159 + return $plaintext;
1160 + }
1161 +
1162 + $key = self::secret_crypto_key();
1163 + $iv = random_bytes(12);
1164 + $tag = '';
1165 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1166 +
1167 + if ($ciphertext === false) {
1168 + // 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.');
1170 + return $plaintext;
1171 + }
1172 +
1173 + return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1174 + }
1175 +
1176 + /**
1177 + * Decrypt a stored secret value.
1178 + *
1179 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1180 + * (returned unchanged, supporting installs that pre-date encryption). On any
1181 + * decryption failure (salt change / corruption) returns false so callers can
1182 + * degrade gracefully instead of using a bad value.
1183 + *
1184 + * @param mixed $value
1185 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1186 + */
1187 + public static function decrypt_secret($value)
1188 + {
1189 + if (!is_string($value) || $value === '') {
1190 + return '';
1191 + }
1192 +
1193 + if (!self::is_encrypted_secret($value)) {
1194 + // Legacy plaintext value.
1195 + return $value;
1196 + }
1197 +
1198 + if (!function_exists('openssl_decrypt')) {
1199 + return false;
1200 + }
1201 +
1202 + $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
1203 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1204 + return false;
1205 + }
1206 +
1207 + $iv = substr($raw, 0, 12);
1208 + $tag = substr($raw, 12, 16);
1209 + $ciphertext = substr($raw, 28);
1210 +
1211 + $key = self::secret_crypto_key();
1212 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1213 +
1214 + if ($plaintext === false) {
1215 + return false;
1216 + }
1217 +
1218 + return $plaintext;
1219 + }
1220 +
1221 + /**
1222 + * Get the decrypted whitelabel settings password.
1223 + *
1224 + * Reads the stored (encrypted) value and returns the plaintext for
1225 + * verification or authorized display. A legacy plaintext value found in
1226 + * storage is migrated to the encrypted format in place (one-time migration
1227 + * on read). Returns '' when no password is set or when an encrypted value
1228 + * can no longer be decrypted (salts changed / corrupt) — in that case the
1229 + * stored value still counts as "password set" for protection checks, but
1230 + * the user password cannot authenticate until it is reset.
1231 + *
1232 + * @return string
1233 + */
1234 + public static function get_whitelabel_password()
1235 + {
1236 + $whitelabel = self::get_whitelabel_settings();
1237 + $stored = $whitelabel['settings_password'] ?? '';
1238 +
1239 + if (!is_string($stored) || $stored === '') {
1240 + return '';
1241 + }
1242 +
1243 + // One-time migration: encrypt a legacy plaintext value in place.
1244 + // This is a whole-blob read-modify-write of metasync_options during a
1245 + // read request; a concurrent settings save could theoretically clobber
1246 + // it, but it fires at most once per legacy install so the window is
1247 + // accepted rather than adding a dedicated option.
1248 + if (!self::is_encrypted_secret($stored)) {
1249 + $encrypted = self::encrypt_secret($stored);
1250 + if (self::is_encrypted_secret($encrypted)) {
1251 + $options = self::get_option();
1252 + if (!is_array($options)) {
1253 + $options = [];
1254 + }
1255 + $options['whitelabel']['settings_password'] = $encrypted;
1256 + self::set_option($options);
1257 + }
1258 + return $stored;
1259 + }
1260 +
1261 + $plaintext = self::decrypt_secret($stored);
1262 + return $plaintext === false ? '' : $plaintext;
1263 + }
1264 +
1265 + /**
641 1266 * Get whitelabel settings
642 1267 * Helper method to retrieve whitelabel configuration
643 1268 */
644 1269 public static function get_whitelabel_settings()
@@ -857,10 +1482,69 @@
857 1482
858 1483 // Priority 3: Return base_name as fallback
859 1484 return $base_name;
860 1485 }
861 -
1486 +
862 1487 /**
1488 + * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1489 + * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1490 + * Use this on any admin page whose fields are NOT rendered through that
1491 + * accordion field-loop (custom render_callback pages, standalone view files) —
1492 + * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1493 + * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1494 + * as this markup is present on a page where metasync-admin.js is enqueued
1495 + * (i.e. any admin page under this plugin's menu).
1496 + *
1497 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1498 + * @param string $text Plain-English help text (escaped internally).
1499 + */
1500 + public static function render_tooltip_icon($tooltip_id, $text)
1501 + {
1502 + echo self::get_tooltip_icon_html($tooltip_id, $text);
1503 + }
1504 +
1505 + /**
1506 + * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1507 + * instead of echoing it. Use this when the tooltip needs to be concatenated
1508 + * into another string — e.g. appended to the $title argument of
1509 + * add_settings_field(), which WordPress core echoes raw next to the label.
1510 + *
1511 + * The whole trigger+popup pair is wrapped in its own small
1512 + * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1513 + * `position: absolute; left: 100%` and positions itself relative to the
1514 + * nearest positioned ancestor — on the main settings accordion that's the
1515 + * `.metasync-field-label-wrapper` div, but standalone pages (custom
1516 + * render_callback templates, add_settings_field titles on plain
1517 + * do_settings_sections() pages, etc.) usually have no such ancestor, so
1518 + * the popup would escape to whatever distant positioned element exists
1519 + * on the page (rendering in the wrong corner of the screen). Wrapping
1520 + * here makes every tooltip self-contained regardless of where it's placed.
1521 + *
1522 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1523 + * @param string $text Plain-English help text (escaped internally).
1524 + * @return string HTML markup for the info-icon trigger + tooltip content.
1525 + */
1526 + public static function get_tooltip_icon_html($tooltip_id, $text)
1527 + {
1528 + $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1529 + $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1530 + $html .= '<svg class="metasync-info-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
1531 + $html .= '<circle cx="12" cy="12" r="10"></circle>';
1532 + $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1533 + $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1534 + $html .= '</svg>';
1535 + $html .= '</button>';
1536 +
1537 + $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1538 + $html .= '<div class="metasync-tooltip-arrow"></div>';
1539 + $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1540 + $html .= '</div>';
1541 + $html .= '</span>';
1542 +
1543 + return $html;
1544 + }
1545 +
1546 + /**
863 1547 * Centralized API Key Event Logging
864 1548 * Provides structured logging for all API key related events with consistent formatting
865 1549 *
866 1550 * @since 1.0.0
@@ -927,10 +1611,25 @@
927 1611 if (!is_404()) {
928 1612 return;
929 1613 }
930 1614
1615 + // PROTECTION 0: Skip WordPress system paths — these are not "broken links"
1616 + $request_uri = $_SERVER['REQUEST_URI'] ?? '';
1617 + $skip_prefixes = [
1618 + '/wp-json/',
1619 + '/wp-admin/',
1620 + '/feed/',
1621 + '/xmlrpc.php',
1622 + '/wp-login.php',
1623 + '/wp-cron.php',
1624 + ];
1625 + foreach ($skip_prefixes as $prefix) {
1626 + if (stripos($request_uri, $prefix) === 0) {
1627 + return;
1628 + }
1629 + }
1630 +
931 1631 // PROTECTION 1: Exclude static assets to reduce noise
932 - $request_uri = $_SERVER['REQUEST_URI'] ?? '';
933 1632 $static_extensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.map','.webp'];
934 1633 foreach ($static_extensions as $ext) {
935 1634 if (stripos($request_uri, $ext) !== false) {
936 1635 return; // Skip logging static asset 404s
@@ -982,8 +1681,9 @@
982 1681 return; // Skip potentially malicious or malformed URLs
983 1682 }
984 1683
985 1684 // Initialize 404 monitor database
1685 + require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
986 1686 $db_404 = new Metasync_Error_Monitor_Database();
987 1687
988 1688 // Get user agent (sanitized)
989 1689 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '';