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 +435 -29 2.6.16 → 2.7.1 View file →
@@ -93,8 +93,27 @@
93 93 public const SUPPORT_EMAIL = "[email protected]";
94 94 public const DOCUMENTATION_DOMAIN = "https://help.searchatlas.com";
95 95
96 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 + /**
97 116 * Define the core functionality of the plugin.
98 117 *
99 118 * Set the plugin name and the plugin version that can be used throughout the plugin.
100 119 * Load the dependencies, define the locale, and set the hooks for the admin area and
@@ -142,11 +161,21 @@
142 161 // Procedural init file — not a class, must stay explicit.
143 162 if (file_exists(plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php')) {
144 163 require_once plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php';
145 164 } else {
146 - 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
147 166 }
148 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 +
149 178 // Admin navigation is referenced statically from frontend-reachable
150 179 // includes (heartbeat/connect managers). Require it explicitly here so
151 180 // the static call never fatals when wp_head fires before autoload.
152 181 if (!class_exists('Metasync_Admin_Navigation')) {
@@ -152,9 +181,17 @@
152 181 if (!class_exists('Metasync_Admin_Navigation')) {
153 182 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-admin-navigation.php';
154 183 }
155 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 +
156 191 $this->loader = new Metasync_Loader();
192 + // Install aggregate-option password protection in every runtime context.
193 + Metasync_Settings_Registration::instance();
157 194 $this->db_heartbeat_errors = new Metasync_HeartBeat_Error_Monitor_Database();
158 195 $this->db_redirection = new Metasync_Redirection_Database();
159 196 }
160 197
@@ -242,16 +279,12 @@
242 279 $this->loader->add_action('wp_ajax_metasync_generate_connect_url', $plugin_admin, 'generate_searchatlas_connect_url');
243 280 $this->loader->add_action('wp_ajax_metasync_check_connect_status', $plugin_admin, 'check_searchatlas_connect_status');
244 281 $this->loader->add_action('wp_ajax_metasync_reset_authentication', $plugin_admin, 'reset_searchatlas_authentication');
245 282
246 - // Auto-update filter
247 - $this->loader->add_filter('auto_update_plugin', $plugin_admin, 'control_plugin_auto_updates', 10, 2);
248 -
249 283 // Search Atlas Connect development/testing endpoints
250 284 $this->loader->add_action('wp_ajax_metasync_test_enhanced_tokens', $plugin_admin, 'test_enhanced_searchatlas_tokens');
251 285 $this->loader->add_action('wp_ajax_metasync_test_whitelabel_domain', $plugin_admin, 'test_whitelabel_domain');
252 286 $this->loader->add_action('wp_ajax_metasync_test_ajax_endpoint', $plugin_admin, 'test_searchatlas_ajax_endpoint');
253 - $this->loader->add_action('wp_ajax_metasync_simple_ajax_test', $plugin_admin, 'simple_ajax_test');
254 287
255 288
256 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
257 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
@@ -256,19 +289,41 @@
256 289 $post_meta_setting = new Metasync_Post_Meta_Settings();
257 290 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
258 291 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
259 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 +
260 301 // SEO Health CSV export: must run on admin_init (before output).
261 302 // Cheap $_GET check avoids loading the class on every admin page.
262 303 if (
263 - isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
264 - $_GET['export'] === 'csv' &&
265 - 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
266 307 ) {
267 308 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
268 309 }
269 310 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
270 311
312 + // SEO meta columns on the posts/pages list tables (WP-624).
313 + // Registered for both the `posts` and `pages` variants of each hook so the
314 + // columns reach every supported post type; the callbacks bail on unsupported
315 + // ones. Hidden by default — users opt in from Screen Options.
316 + $seo_columns = Metasync_SEO_Columns::get_instance();
317 + $this->loader->add_filter('manage_posts_columns', $seo_columns, 'add_columns', 10, 2);
318 + $this->loader->add_filter('manage_pages_columns', $seo_columns, 'add_columns', 10, 1);
319 + $this->loader->add_action('manage_posts_custom_column', $seo_columns, 'render_column', 10, 2);
320 + $this->loader->add_action('manage_pages_custom_column', $seo_columns, 'render_column', 10, 2);
321 + $this->loader->add_filter('default_hidden_columns', $seo_columns, 'default_hidden_columns', 10, 2);
322 + $this->loader->add_filter('hidden_columns', $seo_columns, 'hidden_columns', 10, 3);
323 + $this->loader->add_action('admin_head', $seo_columns, 'print_styles');
324 + $this->loader->add_action('admin_notices', $seo_columns, 'companion_plugin_notice');
325 +
271 326 // Initialize XML Sitemap auto-update hooks if enabled
272 327 // Note: Must not be gated by is_admin() because Gutenberg saves posts
273 328 // via the REST API where is_admin() returns false, and REST_REQUEST
274 329 // is not yet defined at plugin load time
@@ -319,18 +374,15 @@
319 374
320 375 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
321 376 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
322 377
323 -
324 -
325 - $optimal_settings = new Metasync_Optimal_Settings();
326 - $this->loader->add_filter('wp_robots', $optimal_settings, 'add_robots_meta');
327 - $this->loader->add_action('the_content', $optimal_settings, 'add_attributes_external_links');
328 -
329 378 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
330 379 $rest_api = $plugin_public->get_rest_api();
331 380 $seo_output = $plugin_public->get_seo_output();
332 - $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');
333 385
334 386 // Asset enqueue hooks (Metasync_Public)
335 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
336 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
@@ -335,8 +387,12 @@
335 387 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_styles');
336 388 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_scripts');
337 389 $this->loader->add_action('wp_enqueue_scripts', $plugin_public, 'enqueue_page_custom_css', 999);
338 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 +
339 395 // Elementor editor CSS injection
340 396 if (class_exists('\Elementor\Plugin')) {
341 397 $this->loader->add_action('elementor/preview/enqueue_styles', $plugin_public, 'enqueue_elementor_editor_css', 999);
342 398 }
@@ -349,29 +405,60 @@
349 405 // Initialize centralized SEO conflict handler (singleton — suppresses
350 406 // third-party SEO plugin descriptions when MetaSync provides its own).
351 407 Metasync_SEO_Conflict_Handler::get_instance();
352 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 +
353 422 // Term-level SEO plugin sync: propagate MetaSync term meta (category/tag
354 423 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
355 424 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
356 425 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
357 426
358 - // Post-level plugin sync (WP-196): propagate MetaSync post meta into
427 + // Post-level plugin sync: propagate MetaSync post meta into
359 428 // Yoast/Rank Math/AIOSEO post storage on every write.
360 429 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
361 430 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
431 + // Deletes matter too: clearing the last checkbox in a meta box removes
432 + // the meta row, which fires neither hook above.
433 + $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
362 434
363 435 // SEO Output hooks (Metasync_Seo_Output)
364 436 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
437 + // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
438 + $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
365 439 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
366 440
367 - // Hreflang / language alternates output (wp_head @ priority 2).
368 - $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);
369 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);
370 449
371 450 // Edge Cache: detect Cloudways Varnish and persist for settings UI
372 451 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
373 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 +
374 461 // Sitemap exclusions for disabled archive types
375 462 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
376 463 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
377 464 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -410,8 +497,14 @@
410 497 // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
411 498 // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
412 499 // later priority (11) to clear it for our namespace only.
413 500 $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
501 + // Coexist with site-wide "authenticated users only" REST restrictions.
502 + // Those hook rest_authentication_errors, which WP applies before dispatch,
503 + // so rest_pre_dispatch and permission_callback never run. Hook it late
504 + // (99) to clear the error for metasync/v1 requests that present a valid
505 + // plugin API key; every other route keeps the site's restriction.
506 + $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
414 507 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
415 508 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
416 509
417 510 // Robots meta filter (Metasync_Seo_Output)
@@ -452,13 +545,27 @@
452 545 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
453 546 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
454 547 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
455 548
456 - // Serve the IndexNow key file virtually at /{key}.txt (WP-511) so it works
549 + // Serve the IndexNow key file virtually at /{key}.txt so it works
457 550 // on read-only web roots and nginx hosts that 403 direct static .txt access.
458 551 require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
459 552 add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
460 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 +
461 568 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
462 569 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
463 570 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
464 571 }
@@ -537,9 +644,9 @@
537 644 $original_url = $url;
538 645 foreach ($url_replacements as $production => $staging) {
539 646 if (strpos($url, $production) === 0) {
540 647 $url = str_replace($production, $staging, $url);
541 - 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
542 649 break;
543 650 }
544 651 }
545 652
@@ -617,16 +724,86 @@
617 724 * @param string $meta_key Meta key being written.
618 725 * @param mixed $meta_value Meta value being written.
619 726 */
620 727 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
621 - if (!class_exists('Metasync_Plugin_Sync')) {
728 + if (!self::is_metasync_meta_key($meta_key)) {
622 729 return;
623 730 }
624 731
732 + if (!self::sync_layer_handles('on_meta_updated')) {
733 + return;
734 + }
735 +
625 736 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
626 737 }
627 738
628 739 /**
740 + * Bridge deleted_post_meta to the sync layer.
741 + *
742 + * Clearing the last checkbox in a meta box deletes its meta row rather than
743 + * updating it, so the update hooks above never fire and mirrored values can
744 + * go stale. Only the legacy robots keys are acted on; see
745 + * Metasync_Plugin_Sync::on_meta_deleted().
746 + *
747 + * @param array $meta_ids Meta row IDs (unused).
748 + * @param int $post_id Post ID.
749 + * @param string $meta_key Meta key being deleted.
750 + */
751 + public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
752 + if (!self::is_metasync_meta_key($meta_key)) {
753 + return;
754 + }
755 +
756 + if (!self::sync_layer_handles('on_meta_deleted')) {
757 + return;
758 + }
759 +
760 + Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
761 + }
762 +
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 + /**
629 806 * Save current theme information to MetaSync options
630 807 * This runs in WordPress admin context, not during REST API requests
631 808 * Safe to use wp_get_theme() here
632 809 */
@@ -693,13 +870,183 @@
693 870 ]
694 871 );
695 872 }
696 873 }
697 -
874 +
698 875 return $result;
699 876 }
700 877
701 878 /**
879 + * Derive the 32-byte AES key from existing WordPress salts.
880 + *
881 + * No new secret is stored anywhere — the key material is the concatenation
882 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
883 + * (e.g. wp-config regenerated) the derived key changes and previously
884 + * encrypted values can no longer be decrypted, which is handled gracefully
885 + * by the callers (re-authenticate state) rather than fataling.
886 + *
887 + * @return string 32 raw bytes.
888 + */
889 + private static function api_key_crypto_key()
890 + {
891 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
892 + return hash('sha256', $material, true);
893 + }
894 +
895 + /**
896 + * Determine whether a stored value is in the encrypted-at-rest format.
897 + *
898 + * @param mixed $value
899 + * @return bool
900 + */
901 + public static function is_encrypted_api_key($value)
902 + {
903 + return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
904 + }
905 +
906 + /**
907 + * Encrypt a plaintext Search Atlas API key for storage at rest.
908 + *
909 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
910 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
911 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
912 + *
913 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
914 + * unchanged (availability over hard-fail) and the degradation is logged so
915 + * it cannot go unnoticed.
916 + *
917 + * @param string $plaintext
918 + * @return string Encrypted blob, or '' when $plaintext is empty.
919 + */
920 + public static function encrypt_api_key($plaintext)
921 + {
922 + $plaintext = (string) $plaintext;
923 + if ($plaintext === '') {
924 + return '';
925 + }
926 +
927 + // Already encrypted — do not double-encrypt.
928 + if (self::is_encrypted_api_key($plaintext)) {
929 + return $plaintext;
930 + }
931 +
932 + if (!function_exists('openssl_encrypt')) {
933 + // OpenSSL unavailable — store plaintext rather than lose the key.
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
935 + return $plaintext;
936 + }
937 +
938 + $key = self::api_key_crypto_key();
939 + $iv = random_bytes(12);
940 + $tag = '';
941 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
942 +
943 + if ($ciphertext === false) {
944 + // Encryption failed — fall back to plaintext storage, but say so.
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
946 + return $plaintext;
947 + }
948 +
949 + return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
950 + }
951 +
952 + /**
953 + * Decrypt a stored Search Atlas API key value.
954 + *
955 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
956 + * (returned unchanged, supporting installs that pre-date encryption). On any
957 + * decryption failure (salt change / corruption) returns false so callers can
958 + * surface a re-authenticate state instead of using a bad key.
959 + *
960 + * @param mixed $value
961 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
962 + */
963 + public static function decrypt_api_key($value)
964 + {
965 + if (!is_string($value) || $value === '') {
966 + return '';
967 + }
968 +
969 + if (!self::is_encrypted_api_key($value)) {
970 + // Legacy plaintext key.
971 + return $value;
972 + }
973 +
974 + if (!function_exists('openssl_decrypt')) {
975 + return false;
976 + }
977 +
978 + $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
979 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
980 + return false;
981 + }
982 +
983 + $iv = substr($raw, 0, 12);
984 + $tag = substr($raw, 12, 16);
985 + $ciphertext = substr($raw, 28);
986 +
987 + $key = self::api_key_crypto_key();
988 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
989 +
990 + if ($plaintext === false) {
991 + return false;
992 + }
993 +
994 + return $plaintext;
995 + }
996 +
997 + /**
998 + * Get the decrypted Search Atlas API key, memoized for the request.
999 + *
1000 + * Decrypts at most once per request and never persists the decrypted value
1001 + * anywhere. When a legacy plaintext key is found it is migrated to the
1002 + * encrypted format in place (one-time migration on load). Returns:
1003 + * - the plaintext key (string, possibly '')
1004 + * - false when an encrypted value exists but cannot be decrypted
1005 + * (salts changed / corrupt) — callers should treat this as a
1006 + * "please re-authenticate" state.
1007 + *
1008 + * @return string|false
1009 + */
1010 + public static function get_searchatlas_api_key()
1011 + {
1012 + if (self::$memo_api_key !== null) {
1013 + return self::$memo_api_key;
1014 + }
1015 +
1016 + $general = self::get_option('general');
1017 + $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
1018 +
1019 + // One-time migration: a non-empty legacy plaintext value is encrypted
1020 + // in place the first time it is read after this feature ships.
1021 + if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
1022 + $encrypted = self::encrypt_api_key($stored);
1023 + if (self::is_encrypted_api_key($encrypted)) {
1024 + $options = self::get_option();
1025 + if (!is_array($options)) {
1026 + $options = [];
1027 + }
1028 + $options['general']['searchatlas_api_key'] = $encrypted;
1029 + self::set_option($options);
1030 + }
1031 + }
1032 +
1033 + self::$memo_api_key = self::decrypt_api_key($stored);
1034 + return self::$memo_api_key;
1035 + }
1036 +
1037 + /**
1038 + * Clear the request-scoped decrypted-key memo.
1039 + *
1040 + * Call after any write that changes the stored searchatlas_api_key so a
1041 + * subsequent read in the same request reflects the new value.
1042 + */
1043 + public static function invalidate_api_key_cache()
1044 + {
1045 + self::$memo_api_key = null;
1046 + }
1047 +
1048 + /**
702 1049 * Read the heartbeat throttle state from its dedicated option.
703 1050 *
704 1051 * Backfills from the legacy location (`metasync_options['general']`) the
705 1052 * first time the dedicated option is empty, so existing installs keep
@@ -803,9 +1150,9 @@
803 1150 }
804 1151
805 1152 if (!function_exists('openssl_encrypt')) {
806 1153 // OpenSSL unavailable — store plaintext rather than lose the secret.
807 - 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
808 1155 return $plaintext;
809 1156 }
810 1157
811 1158 $key = self::secret_crypto_key();
@@ -814,9 +1161,9 @@
814 1161 $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
815 1162
816 1163 if ($ciphertext === false) {
817 1164 // Encryption failed — fall back to plaintext storage, but say so.
818 - 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
819 1166 return $plaintext;
820 1167 }
821 1168
822 1169 return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
@@ -1131,10 +1478,69 @@
1131 1478
1132 1479 // Priority 3: Return base_name as fallback
1133 1480 return $base_name;
1134 1481 }
1135 -
1482 +
1136 1483 /**
1484 + * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1485 + * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1486 + * Use this on any admin page whose fields are NOT rendered through that
1487 + * accordion field-loop (custom render_callback pages, standalone view files) —
1488 + * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1489 + * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1490 + * as this markup is present on a page where metasync-admin.js is enqueued
1491 + * (i.e. any admin page under this plugin's menu).
1492 + *
1493 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1494 + * @param string $text Plain-English help text (escaped internally).
1495 + */
1496 + public static function render_tooltip_icon($tooltip_id, $text)
1497 + {
1498 + echo self::get_tooltip_icon_html($tooltip_id, $text);
1499 + }
1500 +
1501 + /**
1502 + * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1503 + * instead of echoing it. Use this when the tooltip needs to be concatenated
1504 + * into another string — e.g. appended to the $title argument of
1505 + * add_settings_field(), which WordPress core echoes raw next to the label.
1506 + *
1507 + * The whole trigger+popup pair is wrapped in its own small
1508 + * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1509 + * `position: absolute; left: 100%` and positions itself relative to the
1510 + * nearest positioned ancestor — on the main settings accordion that's the
1511 + * `.metasync-field-label-wrapper` div, but standalone pages (custom
1512 + * render_callback templates, add_settings_field titles on plain
1513 + * do_settings_sections() pages, etc.) usually have no such ancestor, so
1514 + * the popup would escape to whatever distant positioned element exists
1515 + * on the page (rendering in the wrong corner of the screen). Wrapping
1516 + * here makes every tooltip self-contained regardless of where it's placed.
1517 + *
1518 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1519 + * @param string $text Plain-English help text (escaped internally).
1520 + * @return string HTML markup for the info-icon trigger + tooltip content.
1521 + */
1522 + public static function get_tooltip_icon_html($tooltip_id, $text)
1523 + {
1524 + $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1525 + $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1526 + $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">';
1527 + $html .= '<circle cx="12" cy="12" r="10"></circle>';
1528 + $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1529 + $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1530 + $html .= '</svg>';
1531 + $html .= '</button>';
1532 +
1533 + $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1534 + $html .= '<div class="metasync-tooltip-arrow"></div>';
1535 + $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1536 + $html .= '</div>';
1537 + $html .= '</span>';
1538 +
1539 + return $html;
1540 + }
1541 +
1542 + /**
1137 1543 * Centralized API Key Event Logging
1138 1544 * Provides structured logging for all API key related events with consistent formatting
1139 1545 *
1140 1546 * @since 1.0.0
@@ -1182,9 +1588,9 @@
1182 1588 // This could be extended to store in a dedicated log table
1183 1589
1184 1590 } catch (Exception $e) {
1185 1591 // Fallback logging if structured logging fails
1186 - 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
1187 1593 }
1188 1594 }
1189 1595
1190 1596 /**
@@ -1248,9 +1654,9 @@
1248 1654 $global_hits = get_transient($global_rate_key);
1249 1655 if ($global_hits !== false && $global_hits >= 50) {
1250 1656 // More than 50 404s per minute - stop logging to protect database
1251 1657 if ($global_hits === 50) {
1252 - 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
1253 1659 }
1254 1660 set_transient($global_rate_key, $global_hits + 1, 60);
1255 1661 return;
1256 1662 }
@@ -1275,9 +1681,9 @@
1275 1681 require_once plugin_dir_path(dirname(__FILE__)) . '404-monitor/class-metasync-404-monitor-database.php';
1276 1682 $db_404 = new Metasync_Error_Monitor_Database();
1277 1683
1278 1684 // Get user agent (sanitized)
1279 - $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'])) : '';
1280 1686
1281 1687 // Log the 404 error
1282 1688 $result = $db_404->update([
1283 1689 'uri' => $current_url,
@@ -1303,9 +1709,9 @@
1303 1709 $host = $_SERVER['SERVER_NAME'];
1304 1710 }
1305 1711 if (empty($host)) {
1306 1712 // Fallback to WordPress site URL if available
1307 - $host = parse_url(home_url(), PHP_URL_HOST);
1713 + $host = wp_parse_url(home_url(), PHP_URL_HOST);
1308 1714 }
1309 1715
1310 1716 // Safely get REQUEST_URI with fallback
1311 1717 $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';