PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.5.23 All 138 releases
← All changes | includes/class-metasync.php +575 -10 2.6.13trunk View file →
@@ -93,8 +93,27 @@
93 93 public const SUPPORT_EMAIL = "support@searchatlas.com";
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
@@ -145,8 +164,18 @@
145 164 } else {
146 165 error_log('MetaSync Google Index: google-index-init.php not found at ' . plugin_dir_path(dirname(__FILE__)) . 'google-index/google-index-init.php');
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
@@ -256,8 +293,16 @@
256 293 $post_meta_setting = new Metasync_Post_Meta_Settings();
257 294 $this->loader->add_action('admin_init', $post_meta_setting, 'add_post_meta_data', 2);
258 295 $this->loader->add_action('admin_init', $post_meta_setting, 'show_top_admin_bar', 9);
259 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 +
260 305 // SEO Health CSV export: must run on admin_init (before output).
261 306 // Cheap $_GET check avoids loading the class on every admin page.
262 307 if (
263 308 isset($_GET['page'], $_GET['export'], $_GET['_wpnonce']) &&
@@ -267,8 +312,22 @@
267 312 $this->loader->add_action('admin_init', Metasync_SEO_Health::get_instance(), 'handle_csv_export', 1);
268 313 }
269 314 $this->loader->add_action('wp', $post_meta_setting, 'show_top_admin_bar', 9);
270 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 +
271 330 // Initialize XML Sitemap auto-update hooks if enabled
272 331 // Note: Must not be gated by is_admin() because Gutenberg saves posts
273 332 // via the REST API where is_admin() returns false, and REST_REQUEST
274 333 // is not yet defined at plugin load time
@@ -319,14 +378,8 @@
319 378
320 379 $this->loader->add_action('wp_head', $code_snippets, 'get_header_snippet');
321 380 $this->loader->add_action('wp_footer', $code_snippets, 'get_footer_snippet');
322 381
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 382 $plugin_public = new Metasync_Public($this->get_plugin_name(), $this->get_version());
330 383 $rest_api = $plugin_public->get_rest_api();
331 384 $seo_output = $plugin_public->get_seo_output();
332 385 $get_plugin_basename = sprintf('%1$s/%1$s.php', $this->plugin_name);
@@ -354,15 +407,20 @@
354 407 // archives) into Yoast/Rank Math/AIOSEO term storage on every write.
355 408 $this->loader->add_action('updated_term_meta', $this, 'on_term_meta_updated', 10, 4);
356 409 $this->loader->add_action('added_term_meta', $this, 'on_term_meta_updated', 10, 4);
357 410
358 - // Post-level plugin sync (WP-196): propagate MetaSync post meta into
411 + // Post-level plugin sync: propagate MetaSync post meta into
359 412 // Yoast/Rank Math/AIOSEO post storage on every write.
360 413 $this->loader->add_action('updated_post_meta', $this, 'on_post_meta_updated', 10, 4);
361 414 $this->loader->add_action('added_post_meta', $this, 'on_post_meta_updated', 10, 4);
415 + // Deletes matter too: clearing the last checkbox in a meta box removes
416 + // the meta row, which fires neither hook above.
417 + $this->loader->add_action('deleted_post_meta', $this, 'on_post_meta_deleted', 10, 3);
362 418
363 419 // SEO Output hooks (Metasync_Seo_Output)
364 420 $this->loader->add_action('wp_head', $seo_output, 'hook_metasync_metatags', 1, 1);
421 + // LocalBusiness / Organization / Person JSON-LD from the Local Business page.
422 + $this->loader->add_action('wp_head', $seo_output, 'output_local_business_schema', 2, 1);
365 423 $this->loader->add_action('template_redirect', $seo_output, 'inject_archive_seo_controls');
366 424
367 425 // Hreflang / language alternates output (wp_head @ priority 2).
368 426 $plugin_hreflang = new Metasync_Hreflang_Output();
@@ -370,8 +428,16 @@
370 428
371 429 // Edge Cache: detect Cloudways Varnish and persist for settings UI
372 430 $this->loader->add_action('init', 'Metasync_Edge_Cache_Purge', 'detect_cloudways');
373 431
432 + // BookingPress starts a PHP session on every front-end request (init
433 + // at priority 1), which makes hosts skip page caching site-wide. Its
434 + // booking flows manage their own sessions inside their AJAX handlers,
435 + // so unhook the global start. Priority 0 keeps this ahead of theirs.
436 + // Registered with add_action directly (like Metasync_Oxygen_Compat)
437 + // because the loader's $component parameter is typed to objects.
438 + add_action('init', ['Metasync_BookingPress_Compat', 'neutralize_bookingpress_session'], 0);
439 +
374 440 // Sitemap exclusions for disabled archive types
375 441 $this->loader->add_filter('wp_sitemaps_taxonomies', $seo_output, 'filter_sitemap_taxonomies');
376 442 $this->loader->add_filter('wp_sitemaps_users_entry', $seo_output, 'filter_sitemap_users', 10, 2);
377 443 $this->loader->add_filter('wp_sitemaps_add_provider', $seo_output, 'filter_sitemap_providers', 10, 2);
@@ -404,8 +470,20 @@
404 470 $this->loader->add_action('plugin_action_links_' . $get_plugin_basename, $plugin_public, 'metasync_plugin_links');
405 471
406 472 // REST API hooks (Metasync_Rest_Api)
407 473 $this->loader->add_action('rest_api_init', $rest_api, 'metasync_register_rest_routes');
474 + // Coexist with third-party JWT auth plugins: clear their prior auth error
475 + // for metasync/v1 requests when our own API key validates. The Tmeister
476 + // "JWT Authentication for WP-API" plugin surfaces its jwt_auth_invalid_token
477 + // 403 via rest_pre_dispatch (priority 10), so we hook the same filter at a
478 + // later priority (11) to clear it for our namespace only.
479 + $this->loader->add_filter('rest_pre_dispatch', $rest_api, 'allow_metasync_rest_auth', 11, 3);
480 + // Coexist with site-wide "authenticated users only" REST restrictions.
481 + // Those hook rest_authentication_errors, which WP applies before dispatch,
482 + // so rest_pre_dispatch and permission_callback never run. Hook it late
483 + // (99) to clear the error for metasync/v1 requests that present a valid
484 + // plugin API key; every other route keeps the site's restriction.
485 + $this->loader->add_filter('rest_authentication_errors', $rest_api, 'allow_metasync_rest_authentication', 99, 1);
408 486 $this->loader->add_action('init', $plugin_public, 'metasync_plugin_init', 5);
409 487 $this->loader->add_action('wp_ajax_metasync_lglogin', $rest_api, 'linkgraph_login');
410 488
411 489 // Robots meta filter (Metasync_Seo_Output)
@@ -446,8 +524,27 @@
446 524 require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-html-to-markdown.php';
447 525 require_once plugin_dir_path(dirname(__FILE__)) . 'llms-txt/class-metasync-llms-txt-generator.php';
448 526 $llms_txt_generator = new Metasync_Llms_Txt_Generator();
449 527
528 + // Serve the IndexNow key file virtually at /{key}.txt so it works
529 + // on read-only web roots and nginx hosts that 403 direct static .txt access.
530 + require_once plugin_dir_path(dirname(__FILE__)) . 'bing-index/class-metasync-bing-instant-index.php';
531 + add_action('template_redirect', array('Metasync_Bing_Instant_Index', 'serve_virtual_key_file'), 0);
532 +
533 + // Scheduled IndexNow submissions, deferred from save_post so a slow
534 + // IndexNow round-trip cannot stall the editor or REST write. Registered
535 + // statically because the class is loaded but never instantiated on
536 + // cron loads. The hook name matches the uninstall cron sweep exactly.
537 + add_action('metasync_bing_indexnow_submit_event', array('Metasync_Bing_Instant_Index', 'run_scheduled_submit'), 10, 1);
538 +
539 + // Construct the robots.txt manager on the public load path so its
540 + // `robots_txt` filter is registered before a plain GET /robots.txt is
541 + // served. The filter is attached in the singleton's constructor, and
542 + // previously only admin screens built the instance — so the virtual
543 + // robots.txt rules and sitemap lines never rendered on the frontend.
544 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
545 + Metasync_Robots_Txt::get_instance();
546 +
450 547 // One-time upgrade: regenerate sitemap to remove any Beaver Builder template entries
451 548 if ( ! get_option( 'metasync_sitemap_bb_exclusion_applied' ) ) {
452 549 $this->loader->add_action('init', $this, 'maybe_regenerate_sitemap_after_upgrade');
453 550 }
@@ -606,16 +703,86 @@
606 703 * @param string $meta_key Meta key being written.
607 704 * @param mixed $meta_value Meta value being written.
608 705 */
609 706 public function on_post_meta_updated($meta_id, $post_id, $meta_key, $meta_value) {
610 - if (!class_exists('Metasync_Plugin_Sync')) {
707 + if (!self::is_metasync_meta_key($meta_key)) {
611 708 return;
612 709 }
613 710
711 + if (!self::sync_layer_handles('on_meta_updated')) {
712 + return;
713 + }
714 +
614 715 Metasync_Plugin_Sync::get_instance()->on_meta_updated($meta_id, $post_id, $meta_key, $meta_value);
615 716 }
616 717
617 718 /**
719 + * Bridge deleted_post_meta to the sync layer.
720 + *
721 + * Clearing the last checkbox in a meta box deletes its meta row rather than
722 + * updating it, so the update hooks above never fire and mirrored values can
723 + * go stale. Only the legacy robots keys are acted on; see
724 + * Metasync_Plugin_Sync::on_meta_deleted().
725 + *
726 + * @param array $meta_ids Meta row IDs (unused).
727 + * @param int $post_id Post ID.
728 + * @param string $meta_key Meta key being deleted.
729 + */
730 + public function on_post_meta_deleted($meta_ids, $post_id, $meta_key) {
731 + if (!self::is_metasync_meta_key($meta_key)) {
732 + return;
733 + }
734 +
735 + if (!self::sync_layer_handles('on_meta_deleted')) {
736 + return;
737 + }
738 +
739 + Metasync_Plugin_Sync::get_instance()->on_meta_deleted($meta_ids, $post_id, $meta_key);
740 + }
741 +
742 + /**
743 + * Is this meta key one the post sync layer could possibly care about?
744 + *
745 + * Cheap string gate, no autoload, no singleton. Every watched post key is
746 + * either `_metasync_*` (sidebar, OTTO and the mirrored robots JSON) or
747 + * `metasync_*` (the legacy meta box keys), so this keeps third-party meta
748 + * writes out of the sync layer entirely — `deleted_post_meta` and
749 + * `updated_post_meta` fire for every key on the site, including on front-end
750 + * requests, and crossing into the sync layer for keys it will only discard
751 + * costs an autoload plus a singleton on the hot path.
752 + *
753 + * Deliberately broader than the sync layer's own key maps: those stay the
754 + * single source of exact truth, so a new MetaSync key needs no change here.
755 + *
756 + * @param string $meta_key Meta key being written or deleted.
757 + * @return bool
758 + */
759 + private static function is_metasync_meta_key($meta_key) {
760 + return strncmp($meta_key, '_metasync_', 10) === 0
761 + || strncmp($meta_key, 'metasync_', 9) === 0;
762 + }
763 +
764 + /**
765 + * Can the post sync layer actually handle this hook right now?
766 + *
767 + * A partially updated install can leave a newer class-metasync.php beside an
768 + * older class-metasync-plugin-sync.php — stale opcache bytecode for one file
769 + * is enough. Calling a method the loaded class does not define is a fatal,
770 + * and because these bridges run on `wp_head` via third-party meta writes it
771 + * takes the front end down rather than degrading. Check before dispatching.
772 + *
773 + * Checked on the class, not an instance, so a mismatch skips the singleton.
774 + *
775 + * @param string $method Sync-layer method about to be called.
776 + * @return bool
777 + */
778 + private static function sync_layer_handles($method) {
779 + return class_exists('Metasync_Plugin_Sync')
780 + && method_exists('Metasync_Plugin_Sync', 'get_instance')
781 + && method_exists('Metasync_Plugin_Sync', $method);
782 + }
783 +
784 + /**
618 785 * Save current theme information to MetaSync options
619 786 * This runs in WordPress admin context, not during REST API requests
620 787 * Safe to use wp_get_theme() here
621 788 */
@@ -682,13 +849,183 @@
682 849 ]
683 850 );
684 851 }
685 852 }
686 -
853 +
687 854 return $result;
688 855 }
689 856
690 857 /**
858 + * Derive the 32-byte AES key from existing WordPress salts.
859 + *
860 + * No new secret is stored anywhere — the key material is the concatenation
861 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
862 + * (e.g. wp-config regenerated) the derived key changes and previously
863 + * encrypted values can no longer be decrypted, which is handled gracefully
864 + * by the callers (re-authenticate state) rather than fataling.
865 + *
866 + * @return string 32 raw bytes.
867 + */
868 + private static function api_key_crypto_key()
869 + {
870 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
871 + return hash('sha256', $material, true);
872 + }
873 +
874 + /**
875 + * Determine whether a stored value is in the encrypted-at-rest format.
876 + *
877 + * @param mixed $value
878 + * @return bool
879 + */
880 + public static function is_encrypted_api_key($value)
881 + {
882 + return is_string($value) && strncmp($value, self::API_KEY_ENC_PREFIX, strlen(self::API_KEY_ENC_PREFIX)) === 0;
883 + }
884 +
885 + /**
886 + * Encrypt a plaintext Search Atlas API key for storage at rest.
887 + *
888 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
889 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
890 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no key set).
891 + *
892 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
893 + * unchanged (availability over hard-fail) and the degradation is logged so
894 + * it cannot go unnoticed.
895 + *
896 + * @param string $plaintext
897 + * @return string Encrypted blob, or '' when $plaintext is empty.
898 + */
899 + public static function encrypt_api_key($plaintext)
900 + {
901 + $plaintext = (string) $plaintext;
902 + if ($plaintext === '') {
903 + return '';
904 + }
905 +
906 + // Already encrypted — do not double-encrypt.
907 + if (self::is_encrypted_api_key($plaintext)) {
908 + return $plaintext;
909 + }
910 +
911 + if (!function_exists('openssl_encrypt')) {
912 + // OpenSSL unavailable — store plaintext rather than lose the key.
913 + error_log('MetaSync: OpenSSL is unavailable — the Search Atlas API key was stored WITHOUT encryption at rest.');
914 + return $plaintext;
915 + }
916 +
917 + $key = self::api_key_crypto_key();
918 + $iv = random_bytes(12);
919 + $tag = '';
920 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
921 +
922 + if ($ciphertext === false) {
923 + // Encryption failed — fall back to plaintext storage, but say so.
924 + error_log('MetaSync: API key encryption failed — the Search Atlas API key was stored WITHOUT encryption at rest.');
925 + return $plaintext;
926 + }
927 +
928 + return self::API_KEY_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
929 + }
930 +
931 + /**
932 + * Decrypt a stored Search Atlas API key value.
933 + *
934 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
935 + * (returned unchanged, supporting installs that pre-date encryption). On any
936 + * decryption failure (salt change / corruption) returns false so callers can
937 + * surface a re-authenticate state instead of using a bad key.
938 + *
939 + * @param mixed $value
940 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
941 + */
942 + public static function decrypt_api_key($value)
943 + {
944 + if (!is_string($value) || $value === '') {
945 + return '';
946 + }
947 +
948 + if (!self::is_encrypted_api_key($value)) {
949 + // Legacy plaintext key.
950 + return $value;
951 + }
952 +
953 + if (!function_exists('openssl_decrypt')) {
954 + return false;
955 + }
956 +
957 + $raw = base64_decode(substr($value, strlen(self::API_KEY_ENC_PREFIX)), true);
958 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
959 + return false;
960 + }
961 +
962 + $iv = substr($raw, 0, 12);
963 + $tag = substr($raw, 12, 16);
964 + $ciphertext = substr($raw, 28);
965 +
966 + $key = self::api_key_crypto_key();
967 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
968 +
969 + if ($plaintext === false) {
970 + return false;
971 + }
972 +
973 + return $plaintext;
974 + }
975 +
976 + /**
977 + * Get the decrypted Search Atlas API key, memoized for the request.
978 + *
979 + * Decrypts at most once per request and never persists the decrypted value
980 + * anywhere. When a legacy plaintext key is found it is migrated to the
981 + * encrypted format in place (one-time migration on load). Returns:
982 + * - the plaintext key (string, possibly '')
983 + * - false when an encrypted value exists but cannot be decrypted
984 + * (salts changed / corrupt) — callers should treat this as a
985 + * "please re-authenticate" state.
986 + *
987 + * @return string|false
988 + */
989 + public static function get_searchatlas_api_key()
990 + {
991 + if (self::$memo_api_key !== null) {
992 + return self::$memo_api_key;
993 + }
994 +
995 + $general = self::get_option('general');
996 + $stored = is_array($general) ? ($general['searchatlas_api_key'] ?? '') : '';
997 +
998 + // One-time migration: a non-empty legacy plaintext value is encrypted
999 + // in place the first time it is read after this feature ships.
1000 + if ($stored !== '' && is_string($stored) && !self::is_encrypted_api_key($stored)) {
1001 + $encrypted = self::encrypt_api_key($stored);
1002 + if (self::is_encrypted_api_key($encrypted)) {
1003 + $options = self::get_option();
1004 + if (!is_array($options)) {
1005 + $options = [];
1006 + }
1007 + $options['general']['searchatlas_api_key'] = $encrypted;
1008 + self::set_option($options);
1009 + }
1010 + }
1011 +
1012 + self::$memo_api_key = self::decrypt_api_key($stored);
1013 + return self::$memo_api_key;
1014 + }
1015 +
1016 + /**
1017 + * Clear the request-scoped decrypted-key memo.
1018 + *
1019 + * Call after any write that changes the stored searchatlas_api_key so a
1020 + * subsequent read in the same request reflects the new value.
1021 + */
1022 + public static function invalidate_api_key_cache()
1023 + {
1024 + self::$memo_api_key = null;
1025 + }
1026 +
1027 + /**
691 1028 * Read the heartbeat throttle state from its dedicated option.
692 1029 *
693 1030 * Backfills from the legacy location (`metasync_options['general']`) the
694 1031 * first time the dedicated option is empty, so existing installs keep
@@ -731,8 +1068,177 @@
731 1068 update_option(self::heartbeat_throttle_option, $merged);
732 1069 }
733 1070
734 1071 /**
1072 + * Storage prefix marking a secret value as encrypted at rest.
1073 + */
1074 + private const SECRET_ENC_PREFIX = 'enc_v1:';
1075 +
1076 + /**
1077 + * Derive the 32-byte AES key from existing WordPress salts.
1078 + *
1079 + * No new secret is stored anywhere — the key material is the concatenation
1080 + * of three WordPress salts, hashed to a fixed 32 bytes. If the salts change
1081 + * (e.g. wp-config regenerated) the derived key changes and previously
1082 + * encrypted values can no longer be decrypted, which callers handle
1083 + * gracefully rather than fataling.
1084 + *
1085 + * @return string 32 raw bytes.
1086 + */
1087 + private static function secret_crypto_key()
1088 + {
1089 + $material = wp_salt('secure_auth') . wp_salt('logged_in') . wp_salt('nonce');
1090 + return hash('sha256', $material, true);
1091 + }
1092 +
1093 + /**
1094 + * Determine whether a stored value is in the encrypted-at-rest format.
1095 + *
1096 + * @param mixed $value
1097 + * @return bool
1098 + */
1099 + public static function is_encrypted_secret($value)
1100 + {
1101 + return is_string($value) && strncmp($value, self::SECRET_ENC_PREFIX, strlen(self::SECRET_ENC_PREFIX)) === 0;
1102 + }
1103 +
1104 + /**
1105 + * Encrypt a plaintext secret (e.g. the whitelabel settings password) for
1106 + * storage at rest.
1107 + *
1108 + * Uses AES-256-GCM (authenticated) with a random 12-byte IV. The IV, the
1109 + * 16-byte GCM tag and the ciphertext are concatenated and base64-encoded
1110 + * behind an `enc_v1:` prefix. An empty string is stored as-is (no secret).
1111 + *
1112 + * When OpenSSL is unavailable or encryption fails the plaintext is stored
1113 + * unchanged (availability over hard-fail) and the degradation is logged so
1114 + * it cannot go unnoticed.
1115 + *
1116 + * @param string $plaintext
1117 + * @return string Encrypted blob, or '' when $plaintext is empty.
1118 + */
1119 + public static function encrypt_secret($plaintext)
1120 + {
1121 + $plaintext = (string) $plaintext;
1122 + if ($plaintext === '') {
1123 + return '';
1124 + }
1125 +
1126 + // Already encrypted — do not double-encrypt.
1127 + if (self::is_encrypted_secret($plaintext)) {
1128 + return $plaintext;
1129 + }
1130 +
1131 + if (!function_exists('openssl_encrypt')) {
1132 + // OpenSSL unavailable — store plaintext rather than lose the secret.
1133 + error_log('MetaSync: OpenSSL is unavailable — a secret was stored WITHOUT encryption at rest.');
1134 + return $plaintext;
1135 + }
1136 +
1137 + $key = self::secret_crypto_key();
1138 + $iv = random_bytes(12);
1139 + $tag = '';
1140 + $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
1141 +
1142 + if ($ciphertext === false) {
1143 + // Encryption failed — fall back to plaintext storage, but say so.
1144 + error_log('MetaSync: Secret encryption failed — a secret was stored WITHOUT encryption at rest.');
1145 + return $plaintext;
1146 + }
1147 +
1148 + return self::SECRET_ENC_PREFIX . base64_encode($iv . $tag . $ciphertext);
1149 + }
1150 +
1151 + /**
1152 + * Decrypt a stored secret value.
1153 + *
1154 + * Accepts either the encrypted `enc_v1:` format or a legacy plaintext value
1155 + * (returned unchanged, supporting installs that pre-date encryption). On any
1156 + * decryption failure (salt change / corruption) returns false so callers can
1157 + * degrade gracefully instead of using a bad value.
1158 + *
1159 + * @param mixed $value
1160 + * @return string|false Plaintext, or false when an encrypted value cannot be decrypted.
1161 + */
1162 + public static function decrypt_secret($value)
1163 + {
1164 + if (!is_string($value) || $value === '') {
1165 + return '';
1166 + }
1167 +
1168 + if (!self::is_encrypted_secret($value)) {
1169 + // Legacy plaintext value.
1170 + return $value;
1171 + }
1172 +
1173 + if (!function_exists('openssl_decrypt')) {
1174 + return false;
1175 + }
1176 +
1177 + $raw = base64_decode(substr($value, strlen(self::SECRET_ENC_PREFIX)), true);
1178 + if ($raw === false || strlen($raw) < 12 + 16 + 1) {
1179 + return false;
1180 + }
1181 +
1182 + $iv = substr($raw, 0, 12);
1183 + $tag = substr($raw, 12, 16);
1184 + $ciphertext = substr($raw, 28);
1185 +
1186 + $key = self::secret_crypto_key();
1187 + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
1188 +
1189 + if ($plaintext === false) {
1190 + return false;
1191 + }
1192 +
1193 + return $plaintext;
1194 + }
1195 +
1196 + /**
1197 + * Get the decrypted whitelabel settings password.
1198 + *
1199 + * Reads the stored (encrypted) value and returns the plaintext for
1200 + * verification or authorized display. A legacy plaintext value found in
1201 + * storage is migrated to the encrypted format in place (one-time migration
1202 + * on read). Returns '' when no password is set or when an encrypted value
1203 + * can no longer be decrypted (salts changed / corrupt) — in that case the
1204 + * stored value still counts as "password set" for protection checks, but
1205 + * the user password cannot authenticate until it is reset.
1206 + *
1207 + * @return string
1208 + */
1209 + public static function get_whitelabel_password()
1210 + {
1211 + $whitelabel = self::get_whitelabel_settings();
1212 + $stored = $whitelabel['settings_password'] ?? '';
1213 +
1214 + if (!is_string($stored) || $stored === '') {
1215 + return '';
1216 + }
1217 +
1218 + // One-time migration: encrypt a legacy plaintext value in place.
1219 + // This is a whole-blob read-modify-write of metasync_options during a
1220 + // read request; a concurrent settings save could theoretically clobber
1221 + // it, but it fires at most once per legacy install so the window is
1222 + // accepted rather than adding a dedicated option.
1223 + if (!self::is_encrypted_secret($stored)) {
1224 + $encrypted = self::encrypt_secret($stored);
1225 + if (self::is_encrypted_secret($encrypted)) {
1226 + $options = self::get_option();
1227 + if (!is_array($options)) {
1228 + $options = [];
1229 + }
1230 + $options['whitelabel']['settings_password'] = $encrypted;
1231 + self::set_option($options);
1232 + }
1233 + return $stored;
1234 + }
1235 +
1236 + $plaintext = self::decrypt_secret($stored);
1237 + return $plaintext === false ? '' : $plaintext;
1238 + }
1239 +
1240 + /**
735 1241 * Get whitelabel settings
736 1242 * Helper method to retrieve whitelabel configuration
737 1243 */
738 1244 public static function get_whitelabel_settings()
@@ -951,9 +1457,68 @@
951 1457
952 1458 // Priority 3: Return base_name as fallback
953 1459 return $base_name;
954 1460 }
955 -
1461 +
1462 + /**
1463 + * Render a standalone info-icon tooltip (the same visual/JS pattern used by
1464 + * get_field_tooltips() + render_accordion_sections() in Metasync_Settings_Fields).
1465 + * Use this on any admin page whose fields are NOT rendered through that
1466 + * accordion field-loop (custom render_callback pages, standalone view files) —
1467 + * the trigger/hover/positioning JS in admin/js/metasync-admin.js binds to
1468 + * `.metasync-tooltip-trigger` globally, so no extra wiring is needed as long
1469 + * as this markup is present on a page where metasync-admin.js is enqueued
1470 + * (i.e. any admin page under this plugin's menu).
1471 + *
1472 + * @param string $tooltip_id Unique id for this tooltip (unique per page).
1473 + * @param string $text Plain-English help text (escaped internally).
1474 + */
1475 + public static function render_tooltip_icon($tooltip_id, $text)
1476 + {
1477 + echo self::get_tooltip_icon_html($tooltip_id, $text);
1478 + }
1479 +
1480 + /**
1481 + * Same tooltip markup as render_tooltip_icon(), but RETURNS the HTML string
1482 + * instead of echoing it. Use this when the tooltip needs to be concatenated
1483 + * into another string — e.g. appended to the $title argument of
1484 + * add_settings_field(), which WordPress core echoes raw next to the label.
1485 + *
1486 + * The whole trigger+popup pair is wrapped in its own small
1487 + * `position: relative` anchor span. The popup CSS (.metasync-tooltip) is
1488 + * `position: absolute; left: 100%` and positions itself relative to the
1489 + * nearest positioned ancestor — on the main settings accordion that's the
1490 + * `.metasync-field-label-wrapper` div, but standalone pages (custom
1491 + * render_callback templates, add_settings_field titles on plain
1492 + * do_settings_sections() pages, etc.) usually have no such ancestor, so
1493 + * the popup would escape to whatever distant positioned element exists
1494 + * on the page (rendering in the wrong corner of the screen). Wrapping
1495 + * here makes every tooltip self-contained regardless of where it's placed.
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 + * @return string HTML markup for the info-icon trigger + tooltip content.
1500 + */
1501 + public static function get_tooltip_icon_html($tooltip_id, $text)
1502 + {
1503 + $html = '<span class="metasync-tooltip-anchor" style="position:relative;display:inline-block;vertical-align:middle;margin-left:8px;">';
1504 + $html .= '<button type="button" class="metasync-tooltip-trigger" data-tooltip-id="' . esc_attr($tooltip_id) . '" aria-label="More information">';
1505 + $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">';
1506 + $html .= '<circle cx="12" cy="12" r="10"></circle>';
1507 + $html .= '<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>';
1508 + $html .= '<line x1="12" y1="17" x2="12.01" y2="17"></line>';
1509 + $html .= '</svg>';
1510 + $html .= '</button>';
1511 +
1512 + $html .= '<div class="metasync-tooltip" id="tooltip-' . esc_attr($tooltip_id) . '" role="tooltip">';
1513 + $html .= '<div class="metasync-tooltip-arrow"></div>';
1514 + $html .= '<div class="metasync-tooltip-content">' . esc_html($text) . '</div>';
1515 + $html .= '</div>';
1516 + $html .= '</span>';
1517 +
1518 + return $html;
1519 + }
1520 +
956 1521 /**
957 1522 * Centralized API Key Event Logging
958 1523 * Provides structured logging for all API key related events with consistent formatting
959 1524 *