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 +389 -100 2.6.24 → 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;
@@ -796,26 +956,82 @@
796 956 wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
797 957 return;
798 958 }
799 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 +
800 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
801 - $password = Metasync::get_whitelabel_password();
802 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
803 973
804 - 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 = '';
805 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
806 979 return;
807 980 }
808 981
809 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
810 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
811 986 return;
812 987 }
813 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 +
814 1030 $site_name = get_bloginfo('name');
815 1031 $site_url = home_url();
816 1032 $plugin_name = Metasync::get_effective_plugin_name('');
817 - $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));
818 1034 $to = $recovery_email;
819 1035
820 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
821 1037
@@ -821,16 +1037,14 @@
821 1037
822 1038 $message = sprintf(
823 1039 "Hello,\n\n" .
824 1040 "A password recovery request was made for the %s settings on %s.\n\n" .
825 - "Your Settings Password is:\n%s\n\n" .
826 - "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" .
827 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
828 1043 "---\n" .
829 1044 "This is an automated message from %s\n%s",
830 1045 $plugin_name,
831 1046 $site_name,
832 - $password,
833 1047 $settings_url,
834 1048 $site_name,
835 1049 $site_url
836 1050 );
@@ -843,13 +1057,8 @@
843 1057 'Content-Type: text/plain; charset=UTF-8',
844 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
845 1059 );
846 1060
847 - $mail_error = '';
848 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
849 - $mail_error = $error->get_error_message();
850 - });
851 -
852 1061 $sent = wp_mail($to, $subject, $message, $headers);
853 1062
854 1063 if ($sent) {
855 1064 wp_send_json_success(array(
@@ -855,21 +1064,19 @@
855 1064 wp_send_json_success(array(
856 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
857 1066 ));
858 1067 } else {
859 - $error_message = 'Failed to send recovery email. ';
860 - if (!empty($mail_error)) {
861 - $error_message .= 'Error: ' . $mail_error;
862 - } else {
863 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
864 - }
865 -
866 - 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.'));
867 1069 }
868 1070
869 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 + }
870 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
871 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
872 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
873 1080 $e->getMessage(),
874 1081 $e->getFile(),
875 1082 $e->getLine()
@@ -879,8 +1086,113 @@
879 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
880 1087 }
881 1088 }
882 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 +
883 1195 public function ajax_save_theme()
884 1196 {
885 1197 try {
886 1198 # Verify nonce for security
@@ -885,15 +1197,13 @@
885 1197 try {
886 1198 # Verify nonce for security
887 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
888 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
889 - return;
890 1201 }
891 1202
892 1203 # Check user capabilities
893 1204 if (!Metasync::current_user_has_plugin_access()) {
894 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
895 - return;
896 1206 }
897 1207
898 1208 # Get and validate theme value
899 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -900,9 +1210,8 @@
900 1210
901 1211 # Validate theme is either 'light' or 'dark'
902 1212 if (!in_array($theme, array('light', 'dark'), true)) {
903 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
904 - return;
905 1214 }
906 1215
907 1216 # Save theme preference to WordPress options
908 1217 update_option('metasync_theme', $theme, true);
@@ -915,9 +1224,9 @@
915 1224
916 1225 } catch (Exception $e) {
917 1226 # Log error if debug is enabled
918 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
919 - 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
920 1229 }
921 1230
922 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
923 1232 }
@@ -928,9 +1237,8 @@
928 1237 check_ajax_referer('metasync_nonce', 'nonce');
929 1238 # Check user capabilities
930 1239 if (!Metasync::current_user_has_plugin_access()) {
931 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
932 - return;
933 1241 }
934 1242
935 1243 # Get parameters
936 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -972,40 +1280,20 @@
972 1280 wp_die('Security verification failed.');
973 1281 }
974 1282
975 1283 try {
976 - # Get all whitelabel settings
977 - $whitelabel_settings = Metasync::get_whitelabel_settings();
978 -
979 - # Export the settings password as plaintext (not the encrypted blob):
980 - # the encryption key derives from this site's salts, so the blob
981 - # would be undecryptable after import on a different site. The
982 - # importer re-encrypts it at rest with the destination site's salts.
983 - if (!empty($whitelabel_settings['settings_password'])) {
984 - $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';
985 1292 }
986 -
987 - # Get general settings that relate to whitelabel
988 - $general_settings = Metasync::get_option('general');
989 - $whitelabel_related_general = array();
990 -
991 - # Include ALL whitelabel-related general settings
992 - $whitelabel_keys = array(
993 - 'white_label_plugin_name',
994 - 'white_label_plugin_description',
995 - 'white_label_plugin_author',
996 - 'white_label_plugin_author_uri',
997 - 'white_label_plugin_uri',
998 - 'white_label_plugin_menu_slug',
999 - 'white_label_plugin_menu_icon',
1000 - 'whitelabel_otto_name'
1001 - );
1002 -
1003 - foreach ($whitelabel_keys as $key) {
1004 - if (isset($general_settings[$key])) {
1005 - $whitelabel_related_general[$key] = $general_settings[$key];
1006 - }
1007 - }
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'];
1008 1296
1009 1297 # Bundle the menu icon file so it survives import on a different site.
1010 1298 # If the icon is a local URL (media library), replace it with a special
1011 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1059,9 +1347,9 @@
1059 1347 wp_mkdir_p($temp_dir);
1060 1348 }
1061 1349
1062 1350 # Generate unique filename
1063 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1064 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1065 1353 $json_filename = 'whitelabel-settings.json';
1066 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1067 1355
@@ -1122,14 +1410,14 @@
1122 1410 header('Pragma: no-cache');
1123 1411 header('Expires: 0');
1124 1412
1125 1413 # Output file and clean up
1126 - readfile($zip_path);
1127 - 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);
1128 1416
1129 1417 # Clean up temp directory if empty
1130 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1131 - 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
1132 1420 }
1133 1421
1134 1422 # Exit to prevent WordPress from adding anything to the response
1135 1423 exit;
@@ -1136,12 +1424,12 @@
1136 1424
1137 1425 } catch (Exception $e) {
1138 1426 # Log error if debug is enabled
1139 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1140 - 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
1141 1429 }
1142 1430
1143 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1144 1432 }
1145 1433 }
1146 1434
1147 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1186,10 +1474,10 @@
1186 1474 public function render_html_pages_dashboard_widget()
1187 1475 {
1188 1476 global $wpdb;
1189 1477
1190 - $query = "
1191 - 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
1192 1480 FROM {$wpdb->posts} p
1193 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1194 1482 WHERE p.post_status = 'publish'
1195 1483 AND p.post_type IN ('post', 'page')
@@ -1196,11 +1484,10 @@
1196 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1197 1485 GROUP BY p.ID
1198 1486 ORDER BY p.post_modified DESC
1199 1487 LIMIT 10
1200 - ";
1201 -
1202 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1203 1490 $total_count = count($html_pages);
1204 1491
1205 1492 $label = $this->get_html_source_label();
1206 1493
@@ -1207,13 +1494,14 @@
1207 1494 if (empty($html_pages)) {
1208 1495 echo '<div class="metasync-dashboard-widget-empty">';
1209 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1210 1497 echo '<p style="text-align: center; color: #666;">';
1211 - 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>');
1212 1500 echo '</p>';
1213 1501 echo '<p style="text-align: center;">';
1214 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1215 - 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');
1216 1504 echo '</a>';
1217 1505 echo '</p>';
1218 1506 echo '</div>';
1219 1507 return;
@@ -1222,15 +1510,15 @@
1222 1510 echo '<div class="metasync-dashboard-widget">';
1223 1511
1224 1512 echo '<div class="metasync-widget-stats">';
1225 1513 echo '<div class="metasync-stat-box">';
1226 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1227 - 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>';
1228 1516 echo '</div>';
1229 1517 echo '</div>';
1230 1518
1231 1519 echo '<div class="metasync-widget-list">';
1232 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1233 1521 echo '<ul>';
1234 1522
1235 1523 foreach ($html_pages as $page) {
1236 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1243,11 +1531,12 @@
1243 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1244 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1245 1533 echo '</a>';
1246 1534 echo '<span class="metasync-page-meta">';
1247 - 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));
1248 1537 echo ' • ';
1249 - 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>';
1250 1539 echo '</span>';
1251 1540 echo '</div>';
1252 1541 echo '</li>';
1253 1542 }
@@ -1255,10 +1544,10 @@
1255 1544 echo '</ul>';
1256 1545 echo '</div>';
1257 1546
1258 1547 echo '<div class="metasync-widget-footer">';
1259 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1260 - 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') . ' →';
1261 1550 echo '</a>';
1262 1551 echo '</div>';
1263 1552
1264 1553 echo '</div>';