PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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-admin-ajax.php +394 -100 2.6.23 → 2.7.2 View file →
@@ -18,8 +18,105 @@
18 18 }
19 19
20 20 private function __construct() {}
21 21
22 + /**
23 + * Discard request-local recovery state after acquiring the shared lock.
24 + *
25 + * A request can wait for another recovery/settings request while its
26 + * WordPress option or transient cache still contains the pre-lock state.
27 + * Refreshing here makes every decision under the lock use current storage.
28 + */
29 + public function refresh_recovery_state_cache($transient_key = '')
30 + {
31 + if (!function_exists('wp_cache_delete')) {
32 + return;
33 + }
34 +
35 + wp_cache_delete('alloptions', 'options');
36 + wp_cache_delete(Metasync::option_name, 'options');
37 + wp_cache_delete('metasync_pw_reset_generation', 'options');
38 +
39 + if ($transient_key !== '') {
40 + wp_cache_delete($transient_key, 'transient');
41 + wp_cache_delete('_transient_' . $transient_key, 'options');
42 + wp_cache_delete('_transient_timeout_' . $transient_key, 'options');
43 + }
44 + }
45 +
46 + public function acquire_recovery_lock(&$owner)
47 + {
48 + $owner = bin2hex(random_bytes(16));
49 + $key = 'metasync_pw_recovery_rate_lock';
50 + if (add_option($key, array('owner' => $owner, 'time' => time()), '', false)) return true;
51 + $held = get_option($key, array());
52 + $held_time = is_array($held) ? (int) ($held['time'] ?? 0) : (int) $held;
53 + // Settings requests may run for up to five minutes. Keep the lease
54 + // comfortably beyond that bound; shutdown cleanup releases it
55 + // promptly during normal completion and handled failures.
56 + if ($held_time > 0 && time() - $held_time > 900) {
57 + $replacement = array('owner' => $owner, 'time' => time());
58 + global $wpdb;
59 + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
60 + $serialize = function ($value) { return function_exists('maybe_serialize') ? maybe_serialize($value) : serialize($value); };
61 + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
62 + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
63 + $serialize($replacement), $key, $serialize($held)
64 + ));
65 + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
66 + } else {
67 + update_option($key, $replacement, false);
68 + }
69 + $claimed = get_option($key, array());
70 + return is_array($claimed) && isset($claimed['owner']) && hash_equals((string) $claimed['owner'], (string) $owner);
71 + }
72 + return false;
73 + }
74 +
75 + public function release_recovery_lock($owner)
76 + {
77 + $key = 'metasync_pw_recovery_rate_lock';
78 + $held = get_option($key, array());
79 + if (is_array($held) && isset($held['owner']) && hash_equals((string) $held['owner'], (string) $owner)) {
80 + global $wpdb;
81 + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
82 + $serialized = function_exists('maybe_serialize') ? maybe_serialize($held) : serialize($held);
83 + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
84 + "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
85 + $key,
86 + $serialized
87 + ));
88 + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
89 + return;
90 + }
91 +
92 + // Test/bootstrap fallback. Production uses the owner-checked SQL
93 + // delete above so expired-owner cleanup cannot remove a new lock.
94 + delete_option($key);
95 + }
96 + }
97 +
98 + private function persist_recovery_password($encrypted)
99 + {
100 + for ($attempt = 0; $attempt < 3; $attempt++) {
101 + $options = Metasync::get_option();
102 + if (!is_array($options)) $options = array();
103 + if (!isset($options['whitelabel']) || !is_array($options['whitelabel'])) $options['whitelabel'] = array();
104 + $options['whitelabel']['settings_password'] = $encrypted;
105 + Metasync_Settings_Registration::authorize_recovery_password_write(true);
106 + try {
107 + $saved = Metasync::set_option($options);
108 + } finally {
109 + Metasync_Settings_Registration::authorize_recovery_password_write(false);
110 + }
111 + if ($saved) {
112 + $stored = Metasync::get_option('whitelabel');
113 + if (is_array($stored) && isset($stored['settings_password']) && hash_equals((string) $encrypted, (string) $stored['settings_password'])) return true;
114 + }
115 + }
116 + return false;
117 + }
118 +
22 119 private function get_db_redirection()
23 120 {
24 121 if (null === $this->db_redirection) {
25 122 $this->db_redirection = new Metasync_Redirection_Database();
@@ -30,9 +127,9 @@
30 127 public function ajax_import_external_data()
31 128 {
32 129 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
33 130 if (function_exists('set_time_limit')) {
34 - @set_time_limit($execution_time);
131 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
35 132 }
36 133
37 134 Metasync_Settings_Fields::instance()->apply_memory_limit();
38 135
@@ -41,10 +138,10 @@
41 138 if (!Metasync::current_user_has_plugin_access()) {
42 139 wp_send_json_error(['message' => 'Insufficient permissions.']);
43 140 }
44 141
45 - $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
46 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
142 + $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : '';
143 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
47 144 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
48 145
49 146 if (empty($type) || empty($plugin)) {
50 147 wp_send_json_error(['message' => 'Missing required parameters.']);
@@ -71,8 +168,17 @@
71 168 $result = $importer->import_schema($plugin);
72 169 break;
73 170 }
74 171
172 + // A schema import that only found already-migrated or unsupported
173 + // records is an accurate report, not a failure — keep the breakdown
174 + // with the response so the UI can show each category separately.
175 + if (isset($result['breakdown'])) {
176 + $result['breakdown'] = array_map('intval', (array) $result['breakdown']);
177 + }
178 +
179 + Metasync_PostHog::import_batch($plugin, $type, $result, $offset);
180 +
75 181 if ($result['success']) {
76 182 wp_send_json_success($result);
77 183 } else {
78 184 wp_send_json_error($result);
@@ -82,9 +188,9 @@
82 188 public function ajax_import_seo_metadata()
83 189 {
84 190 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
85 191 if (function_exists('set_time_limit')) {
86 - @set_time_limit($execution_time);
192 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
87 193 }
88 194
89 195 Metasync_Settings_Fields::instance()->apply_memory_limit();
90 196
@@ -93,9 +199,9 @@
93 199 if (!Metasync::current_user_has_plugin_access()) {
94 200 wp_send_json_error(['message' => 'Insufficient permissions.']);
95 201 }
96 202
97 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
203 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
98 204 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
99 205 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
100 206 $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
101 207 $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
@@ -120,8 +226,12 @@
120 226 ];
121 227
122 228 $result = $importer->import_seo_metadata($plugin, $options);
123 229
230 + Metasync_PostHog::import_batch($plugin, 'seo_metadata', $result, $offset, array(
231 + 'overwrite_existing' => $overwrite_existing,
232 + ));
233 +
124 234 if ($result['success']) {
125 235 wp_send_json_success($result);
126 236 } else {
127 237 wp_send_json_error($result);
@@ -222,9 +332,9 @@
222 332 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
223 333 $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
224 334
225 335 // Allowlist top-level wizard data keys to prevent mass assignment
226 - $allowed_wizard_keys = array('verification', 'seo_settings', 'schema');
336 + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema', 'analytics');
227 337 $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
228 338
229 339 $options = get_option('metasync_options', array());
230 340
@@ -254,8 +364,13 @@
254 364 $options['general']['enable_schema_markup'] = $data['schema']['enabled'];
255 365 $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']);
256 366 }
257 367
368 + if (isset($data['analytics']) && is_array($data['analytics'])) {
369 + update_option('metasync_analytics_opt_in', !empty($data['analytics']['enabled']) ? 'yes' : 'no');
370 + update_option('metasync_analytics_consent_explicit', 'yes');
371 + }
372 +
258 373 update_option('metasync_options', $options);
259 374
260 375 wp_send_json_success(array('message' => 'Progress saved'));
261 376 }
@@ -277,8 +392,14 @@
277 392
278 393 $user_id = get_current_user_id();
279 394 delete_transient("metasync_wizard_state_{$user_id}");
280 395
396 + // Consent-gated product analytics: setup funnel completion.
397 + $general_settings = Metasync::get_option('general');
398 + Metasync_PostHog::feature('setup_wizard_completed', array(
399 + 'connected' => !empty($general_settings['searchatlas_api_key']),
400 + ));
401 +
281 402 wp_send_json_success(array('message' => 'Wizard completed'));
282 403 }
283 404
284 405 public function ajax_validate_robots()
@@ -466,10 +587,10 @@
466 587 if (!Metasync::current_user_has_plugin_access()) {
467 588 wp_die('Insufficient permissions');
468 589 }
469 590
470 - $uri = sanitize_text_field($_POST['uri']);
471 - $redirect_url = sanitize_url($_POST['redirect_url']);
591 + $uri = sanitize_text_field(wp_unslash($_POST['uri']));
592 + $redirect_url = sanitize_url(wp_unslash($_POST['redirect_url']));
472 593
473 594 if (empty($uri) || empty($redirect_url)) {
474 595 wp_send_json_error('Missing required parameters');
475 596 }
@@ -538,9 +659,9 @@
538 659 try {
539 660 delete_expired_transients(true);
540 661 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
541 662
542 - $plugin_transients = $wpdb->get_results(
663 + $plugin_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
543 664 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
544 665 ARRAY_A
545 666 );
546 667
@@ -549,9 +670,9 @@
549 670 delete_transient($transient_name);
550 671 $cleanup_stats['plugin_transients']++;
551 672 }
552 673
553 - $rate_limit_transients = $wpdb->get_results(
674 + $rate_limit_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
554 675 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
555 676 ARRAY_A
556 677 );
557 678
@@ -560,9 +681,9 @@
560 681 delete_transient($transient_name);
561 682 $cleanup_stats['rate_limit_transients']++;
562 683 }
563 684
564 - $telemetry_transients = $wpdb->get_results(
685 + $telemetry_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
565 686 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
566 687 ARRAY_A
567 688 );
568 689
@@ -571,9 +692,9 @@
571 692 delete_transient($transient_name);
572 693 $cleanup_stats['telemetry_transients']++;
573 694 }
574 695
575 - $sa_connect_success_transients = $wpdb->get_results(
696 + $sa_connect_success_transients = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- transient enumeration/cleanup on the options table — no API to scan or bulk-delete transient rows
576 697 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
577 698 ARRAY_A
578 699 );
579 700
@@ -584,17 +705,50 @@
584 705 }
585 706
586 707 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
587 708 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
588 - date('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
709 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
589 710
590 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
591 -
592 711 } catch (Exception $e) {
593 - error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage());
712 + error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
594 713 }
595 714 }
596 715
716 + /**
717 + * Collect the locally stored white-label settings for an issue-report payload.
718 + *
719 + * The values are attached to the Sentry event so support can tell from the
720 + * event alone whether the reporting site is white-labeled. Local options
721 + * can be stale — the reply-drafting process must still cross-check the
722 + * backend account record before sending.
723 + *
724 + * Static and public so the unit suite can exercise the reads directly; the
725 + * AJAX handler itself dies on wp_send_json_* before returning.
726 + *
727 + * @return array Keys: is_whitelabel, whitelabel_brand_name, whitelabel_support_email, site_url.
728 + */
729 + public static function build_issue_report_whitelabel_context()
730 + {
731 + $whitelabel_settings = Metasync::get_whitelabel_settings();
732 + if (!is_array($whitelabel_settings)) {
733 + $whitelabel_settings = array();
734 + }
735 + $whitelabel_support_email = '';
736 + foreach (array('support_email_override', 'support_email', 'recovery_email') as $email_key) {
737 + if (!empty($whitelabel_settings[$email_key])) {
738 + $whitelabel_support_email = sanitize_email($whitelabel_settings[$email_key]);
739 + break;
740 + }
741 + }
742 + return array(
743 + 'is_whitelabel' => Metasync::is_whitelabel_enabled(),
744 + 'whitelabel_brand_name' => !empty($whitelabel_settings['company_name'])
745 + ? sanitize_text_field($whitelabel_settings['company_name']) : '',
746 + 'whitelabel_support_email' => $whitelabel_support_email,
747 + 'site_url' => esc_url_raw(home_url()),
748 + );
749 + }
750 +
597 751 public function ajax_submit_issue_report()
598 752 {
599 753 try {
600 754 # Verify nonce
@@ -665,8 +819,11 @@
665 819 );
666 820 }
667 821 }
668 822
823 + # Read the locally stored white-label settings for the report payload.
824 + $whitelabel_context = self::build_issue_report_whitelabel_context();
825 +
669 826 # Get general options (same way as used throughout the plugin)
670 827 $general_options = Metasync::get_option('general');
671 828 if (!is_array($general_options)) {
672 829 $general_options = array();
@@ -721,14 +878,18 @@
721 878 # Check if Sentry feedback function exists
722 879 if (!function_exists('metasync_sentry_capture_feedback')) {
723 880 # Log warning if function doesn't exist
724 881 if (defined('WP_DEBUG') && WP_DEBUG) {
725 - error_log('MetaSync: Sentry feedback function not available for report submission.');
882 + error_log('MetaSync: Sentry feedback function not available for report submission.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
726 883 }
727 884 } else {
728 885 $feedback_data = array(
729 886 'message' => $issue_message,
730 - 'severity' => $issue_severity
887 + 'severity' => $issue_severity,
888 + 'is_whitelabel' => $whitelabel_context['is_whitelabel'],
889 + 'whitelabel_brand_name' => $whitelabel_context['whitelabel_brand_name'],
890 + 'whitelabel_support_email' => $whitelabel_context['whitelabel_support_email'],
891 + 'site_url' => $whitelabel_context['site_url']
731 892 );
732 893
733 894 # Add user information if requested
734 895 if ($include_user_info) {
@@ -750,13 +911,11 @@
750 911 ));
751 912 } else {
752 913 # Fallback: Log locally if Sentry fails or is unavailable
753 914 if (defined('WP_DEBUG') && WP_DEBUG) {
754 - error_log(sprintf(
755 - 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s',
915 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
916 + 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s',
756 917 $project_uuid,
757 - $issue_title,
758 - $issue_message,
759 918 $issue_severity
760 919 ));
761 920 }
762 921
@@ -770,9 +929,9 @@
770 929
771 930 } catch (Exception $e) {
772 931 # Log the error securely (only in debug mode)
773 932 if (defined('WP_DEBUG') && WP_DEBUG) {
774 - error_log(sprintf(
933 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
775 934 'MetaSync Report Submission Error: %s in %s on line %d',
776 935 $e->getMessage(),
777 936 $e->getFile(),
778 937 $e->getLine()
@@ -785,8 +944,9 @@
785 944 }
786 945
787 946 public function ajax_recover_password()
788 947 {
948 + $rate_owner = '';
789 949 try {
790 950 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
791 951 wp_send_json_error(array('message' => 'Security verification failed.'));
792 952 return;
@@ -791,26 +951,87 @@
791 951 wp_send_json_error(array('message' => 'Security verification failed.'));
792 952 return;
793 953 }
794 954
955 + if (!current_user_can('manage_options')) {
956 + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
957 + return;
958 + }
959 +
960 + if (!$this->acquire_recovery_lock($rate_owner)) {
961 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
962 + return;
963 + }
964 + register_shutdown_function(function () use (&$rate_owner) {
965 + if ($rate_owner !== '') {
966 + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
967 + }
968 + });
969 + $this->refresh_recovery_state_cache();
970 +
795 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
796 - $password = Metasync::get_whitelabel_password();
797 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
798 973
799 - if (empty($password)) {
974 + $stored_password = $whitelabel_settings['settings_password'] ?? '';
975 + if (!is_string($stored_password) || $stored_password === '') {
976 + $this->release_recovery_lock($rate_owner);
977 + $rate_owner = '';
800 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
801 979 return;
802 980 }
803 981
804 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
805 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
806 986 return;
807 987 }
808 988
989 + // Bound recovery requests per user and per site to limit abuse.
990 + $user_id = get_current_user_id();
991 + $user_rate_key = 'metasync_pw_recovery_user_' . $user_id;
992 + $site_rate_key = 'metasync_pw_recovery_site';
993 + $this->refresh_recovery_state_cache($user_rate_key);
994 + $this->refresh_recovery_state_cache($site_rate_key);
995 + $user_attempts = (int) get_transient($user_rate_key);
996 + $site_attempts = (int) get_transient($site_rate_key);
997 + if ($user_attempts >= 3 || $site_attempts >= 10) {
998 + $this->release_recovery_lock($rate_owner);
999 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
1000 + return;
1001 + }
1002 +
1003 + if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) {
1004 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
1005 + else delete_transient($user_rate_key);
1006 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
1007 + else delete_transient($site_rate_key);
1008 + $this->release_recovery_lock($rate_owner);
1009 + wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.'));
1010 + return;
1011 + }
1012 +
1013 + // Keep only a hash in the database; the raw token is sent once by email.
1014 + $token = bin2hex(random_bytes(32));
1015 + $token_hash = hash('sha256', $token);
1016 + $reset_key = 'metasync_pw_reset_' . $token_hash;
1017 + if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) {
1018 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
1019 + else delete_transient($user_rate_key);
1020 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
1021 + else delete_transient($site_rate_key);
1022 + $this->release_recovery_lock($rate_owner);
1023 + $rate_owner = '';
1024 + wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.'));
1025 + return;
1026 + }
1027 + $this->release_recovery_lock($rate_owner);
1028 + $rate_owner = '';
1029 +
809 1030 $site_name = get_bloginfo('name');
810 1031 $site_url = home_url();
811 1032 $plugin_name = Metasync::get_effective_plugin_name('');
812 - $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel');
1033 + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token));
813 1034 $to = $recovery_email;
814 1035
815 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
816 1037
@@ -816,16 +1037,14 @@
816 1037
817 1038 $message = sprintf(
818 1039 "Hello,\n\n" .
819 1040 "A password recovery request was made for the %s settings on %s.\n\n" .
820 - "Your Settings Password is:\n%s\n\n" .
821 - "You can use this password to access the protected settings at:\n%s\n\n" .
1041 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
822 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
823 1043 "---\n" .
824 1044 "This is an automated message from %s\n%s",
825 1045 $plugin_name,
826 1046 $site_name,
827 - $password,
828 1047 $settings_url,
829 1048 $site_name,
830 1049 $site_url
831 1050 );
@@ -838,13 +1057,8 @@
838 1057 'Content-Type: text/plain; charset=UTF-8',
839 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
840 1059 );
841 1060
842 - $mail_error = '';
843 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
844 - $mail_error = $error->get_error_message();
845 - });
846 -
847 1061 $sent = wp_mail($to, $subject, $message, $headers);
848 1062
849 1063 if ($sent) {
850 1064 wp_send_json_success(array(
@@ -850,21 +1064,19 @@
850 1064 wp_send_json_success(array(
851 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
852 1066 ));
853 1067 } else {
854 - $error_message = 'Failed to send recovery email. ';
855 - if (!empty($mail_error)) {
856 - $error_message .= 'Error: ' . $mail_error;
857 - } else {
858 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
859 - }
860 -
861 - wp_send_json_error(array('message' => $error_message));
1068 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
862 1069 }
863 1070
864 1071 } catch (Exception $e) {
1072 + // Always release the bounded issuance lock if an unexpected
1073 + // exception occurs after it was acquired.
1074 + if ($rate_owner !== '') {
1075 + $this->release_recovery_lock($rate_owner);
1076 + }
865 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
866 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
867 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
868 1080 $e->getMessage(),
869 1081 $e->getFile(),
870 1082 $e->getLine()
@@ -874,8 +1086,113 @@
874 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
875 1087 }
876 1088 }
877 1089
1090 + /** Render and process the administrator-only password reset screen. */
1091 + public function render_password_reset_page()
1092 + {
1093 + if (!current_user_can('manage_options')) {
1094 + wp_die('You do not have permission to reset this password.', '', array('response' => 403));
1095 + }
1096 +
1097 + $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : '';
1098 + $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : '';
1099 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1100 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1101 + $error = '';
1102 + $success = false;
1103 +
1104 + if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1105 + if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) {
1106 + $error = 'Security verification failed.';
1107 + } elseif ($token === '' || false === get_transient($transient_key)) {
1108 + $error = 'This recovery link is invalid or has expired.';
1109 + } else {
1110 + $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : '';
1111 + $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : '';
1112 + if (strlen($password) < 8) {
1113 + $error = 'Password must be at least 8 characters long.';
1114 + } elseif (!hash_equals($password, $confirm)) {
1115 + $error = 'Passwords do not match.';
1116 + } elseif (strpos($password, 'enc_v1:') === 0) {
1117 + $error = 'Unable to save this password. Please choose a different password.';
1118 + } else {
1119 + // add_option is an atomic insert in WordPress; it closes the
1120 + // race where two requests attempt to redeem the same token.
1121 + $lock_key = '';
1122 + if (!$this->acquire_recovery_lock($lock_key)) {
1123 + $error = 'Another recovery request is being processed. Please wait a moment and try again.';
1124 + } else {
1125 + register_shutdown_function(function () use (&$lock_key) {
1126 + if ($lock_key !== '') {
1127 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key);
1128 + }
1129 + });
1130 + $this->refresh_recovery_state_cache($transient_key);
1131 + // Refresh the settings version while holding the shared
1132 + // lock. Token keys are independent so issuing or using
1133 + // one link does not invalidate another unexpired link.
1134 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1135 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1136 + // Re-check after claiming the lock. Consume only after
1137 + // every required write succeeds, allowing a retry after
1138 + // a temporary database or revocation failure.
1139 + if (false === get_transient($transient_key)) {
1140 + $error = 'This recovery link is invalid or has already been used.';
1141 + $this->release_recovery_lock($lock_key);
1142 + $lock_key = '';
1143 + echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>';
1144 + return;
1145 + }
1146 + {
1147 + $encrypted = Metasync::encrypt_secret($password);
1148 + if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) {
1149 + $error = 'Unable to securely save this password. Please try again later.';
1150 + } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) {
1151 + $error = 'Unable to prepare the password reset. Please try again later.';
1152 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1153 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1154 + $error = 'Unable to revoke existing access. Please try again later.';
1155 + } elseif (!$this->persist_recovery_password($encrypted)) {
1156 + $error = 'Unable to save the new password. Please try again later.';
1157 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1158 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1159 + $error = 'Unable to finalize access revocation. Please try again later.';
1160 + } elseif (!delete_transient($transient_key)) {
1161 + $error = 'Unable to consume this recovery link. Please try again later.';
1162 + } else {
1163 + $success = true;
1164 + }
1165 + }
1166 + $this->release_recovery_lock($lock_key);
1167 + $lock_key = '';
1168 + }
1169 + }
1170 + }
1171 + }
1172 +
1173 + echo '<div class="wrap"><h1>Reset Settings Password</h1>';
1174 + if ($success) {
1175 + echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>';
1176 + } else {
1177 + if ($error !== '') {
1178 + echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
1179 + }
1180 + if ($token === '' || false === get_transient($transient_key)) {
1181 + if ($error === '') {
1182 + echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>';
1183 + }
1184 + } else {
1185 + echo '<form method="post" style="max-width:420px">';
1186 + wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce');
1187 + echo '<p><label for="new_password">New password</label><br><input class="regular-text" type="password" id="new_password" name="new_password" minlength="8" required autocomplete="new-password"></p>';
1188 + echo '<p><label for="confirm_password">Confirm new password</label><br><input class="regular-text" type="password" id="confirm_password" name="confirm_password" minlength="8" required autocomplete="new-password"></p>';
1189 + echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>';
1190 + }
1191 + }
1192 + echo '</div>';
1193 + }
1194 +
878 1195 public function ajax_save_theme()
879 1196 {
880 1197 try {
881 1198 # Verify nonce for security
@@ -880,15 +1197,13 @@
880 1197 try {
881 1198 # Verify nonce for security
882 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
883 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
884 - return;
885 1201 }
886 1202
887 1203 # Check user capabilities
888 1204 if (!Metasync::current_user_has_plugin_access()) {
889 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
890 - return;
891 1206 }
892 1207
893 1208 # Get and validate theme value
894 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -895,9 +1210,8 @@
895 1210
896 1211 # Validate theme is either 'light' or 'dark'
897 1212 if (!in_array($theme, array('light', 'dark'), true)) {
898 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
899 - return;
900 1214 }
901 1215
902 1216 # Save theme preference to WordPress options
903 1217 update_option('metasync_theme', $theme, true);
@@ -910,9 +1224,9 @@
910 1224
911 1225 } catch (Exception $e) {
912 1226 # Log error if debug is enabled
913 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
914 - error_log('MetaSync Theme Save Error: ' . $e->getMessage());
1228 + error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
915 1229 }
916 1230
917 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
918 1232 }
@@ -923,9 +1237,8 @@
923 1237 check_ajax_referer('metasync_nonce', 'nonce');
924 1238 # Check user capabilities
925 1239 if (!Metasync::current_user_has_plugin_access()) {
926 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
927 - return;
928 1241 }
929 1242
930 1243 # Get parameters
931 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -967,40 +1280,20 @@
967 1280 wp_die('Security verification failed.');
968 1281 }
969 1282
970 1283 try {
971 - # Get all whitelabel settings
972 - $whitelabel_settings = Metasync::get_whitelabel_settings();
973 -
974 - # Export the settings password as plaintext (not the encrypted blob):
975 - # the encryption key derives from this site's salts, so the blob
976 - # would be undecryptable after import on a different site. The
977 - # importer re-encrypts it at rest with the destination site's salts.
978 - if (!empty($whitelabel_settings['settings_password'])) {
979 - $whitelabel_settings['settings_password'] = Metasync::get_whitelabel_password();
1284 + # Source the white-label export payload from the shared helper so
1285 + # reset and export can never drift apart: whatever the reset
1286 + # preserves is exactly what the export serialises. No API
1287 + # credentials (apikey / searchatlas_api_key / otto_pixel_uuid / ...)
1288 + # are ever serialised - the helper only captures the whitelabel
1289 + # blob and the general branding keys.
1290 + if (!class_exists('Metasync_Whitelabel_Preservation')) {
1291 + require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-metasync-whitelabel-preservation.php';
980 1292 }
981 -
982 - # Get general settings that relate to whitelabel
983 - $general_settings = Metasync::get_option('general');
984 - $whitelabel_related_general = array();
985 -
986 - # Include ALL whitelabel-related general settings
987 - $whitelabel_keys = array(
988 - 'white_label_plugin_name',
989 - 'white_label_plugin_description',
990 - 'white_label_plugin_author',
991 - 'white_label_plugin_author_uri',
992 - 'white_label_plugin_uri',
993 - 'white_label_plugin_menu_slug',
994 - 'white_label_plugin_menu_icon',
995 - 'whitelabel_otto_name'
996 - );
997 -
998 - foreach ($whitelabel_keys as $key) {
999 - if (isset($general_settings[$key])) {
1000 - $whitelabel_related_general[$key] = $general_settings[$key];
1001 - }
1002 - }
1293 + $export_payload = Metasync_Whitelabel_Preservation::build_whitelabel_export_data();
1294 + $whitelabel_settings = $export_payload['whitelabel_settings'];
1295 + $whitelabel_related_general = $export_payload['general_settings'];
1003 1296
1004 1297 # Bundle the menu icon file so it survives import on a different site.
1005 1298 # If the icon is a local URL (media library), replace it with a special
1006 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1054,9 +1347,9 @@
1054 1347 wp_mkdir_p($temp_dir);
1055 1348 }
1056 1349
1057 1350 # Generate unique filename
1058 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1059 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1060 1353 $json_filename = 'whitelabel-settings.json';
1061 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1062 1355
@@ -1117,14 +1410,14 @@
1117 1410 header('Pragma: no-cache');
1118 1411 header('Expires: 0');
1119 1412
1120 1413 # Output file and clean up
1121 - readfile($zip_path);
1122 - unlink($zip_path);
1414 + readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory
1415 + wp_delete_file($zip_path);
1123 1416
1124 1417 # Clean up temp directory if empty
1125 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1126 - rmdir($temp_dir);
1419 + rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
1127 1420 }
1128 1421
1129 1422 # Exit to prevent WordPress from adding anything to the response
1130 1423 exit;
@@ -1131,12 +1424,12 @@
1131 1424
1132 1425 } catch (Exception $e) {
1133 1426 # Log error if debug is enabled
1134 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1135 - error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage());
1428 + error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1136 1429 }
1137 1430
1138 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1139 1432 }
1140 1433 }
1141 1434
1142 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1181,10 +1474,10 @@
1181 1474 public function render_html_pages_dashboard_widget()
1182 1475 {
1183 1476 global $wpdb;
1184 1477
1185 - $query = "
1186 - SELECT p.ID, p.post_title, p.post_modified, p.post_type
1478 + $html_pages = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- admin dashboard widget join over posts+postmeta — aggregate view, no WordPress API for the join
1479 + "SELECT p.ID, p.post_title, p.post_modified, p.post_type
1187 1480 FROM {$wpdb->posts} p
1188 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1189 1482 WHERE p.post_status = 'publish'
1190 1483 AND p.post_type IN ('post', 'page')
@@ -1191,11 +1484,10 @@
1191 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1192 1485 GROUP BY p.ID
1193 1486 ORDER BY p.post_modified DESC
1194 1487 LIMIT 10
1195 - ";
1196 -
1197 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1198 1490 $total_count = count($html_pages);
1199 1491
1200 1492 $label = $this->get_html_source_label();
1201 1493
@@ -1202,13 +1494,14 @@
1202 1494 if (empty($html_pages)) {
1203 1495 echo '<div class="metasync-dashboard-widget-empty">';
1204 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1205 1497 echo '<p style="text-align: center; color: #666;">';
1206 - echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1498 + /* translators: %s: HTML source label. */
1499 + echo sprintf(esc_html__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1207 1500 echo '</p>';
1208 1501 echo '<p style="text-align: center;">';
1209 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1210 - echo __('Get Started', 'metasync');
1502 + echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">';
1503 + echo esc_html__('Get Started', 'metasync');
1211 1504 echo '</a>';
1212 1505 echo '</p>';
1213 1506 echo '</div>';
1214 1507 return;
@@ -1217,15 +1510,15 @@
1217 1510 echo '<div class="metasync-dashboard-widget">';
1218 1511
1219 1512 echo '<div class="metasync-widget-stats">';
1220 1513 echo '<div class="metasync-stat-box">';
1221 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1222 - echo '<span class="metasync-stat-label">' . __('AI-Generated Pages', 'metasync') . '</span>';
1514 + echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>';
1515 + echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>';
1223 1516 echo '</div>';
1224 1517 echo '</div>';
1225 1518
1226 1519 echo '<div class="metasync-widget-list">';
1227 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1228 1521 echo '<ul>';
1229 1522
1230 1523 foreach ($html_pages as $page) {
1231 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1238,11 +1531,12 @@
1238 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1239 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1240 1533 echo '</a>';
1241 1534 echo '<span class="metasync-page-meta">';
1242 - echo sprintf(__('Updated %s ago', 'metasync'), $time_ago);
1535 + /* translators: %s: human-readable time difference. */
1536 + echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago));
1243 1537 echo ' • ';
1244 - echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>';
1538 + echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>';
1245 1539 echo '</span>';
1246 1540 echo '</div>';
1247 1541 echo '</li>';
1248 1542 }
@@ -1250,10 +1544,10 @@
1250 1544 echo '</ul>';
1251 1545 echo '</div>';
1252 1546
1253 1547 echo '<div class="metasync-widget-footer">';
1254 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1255 - echo __('View All Pages', 'metasync') . ' →';
1548 + echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">';
1549 + echo esc_html__('View All Pages', 'metasync') . ' →';
1256 1550 echo '</a>';
1257 1551 echo '</div>';
1258 1552
1259 1553 echo '</div>';