PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-admin-ajax.php +314 -66 2.6.23 → 2.7.1 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.']);
@@ -82,9 +179,9 @@
82 179 public function ajax_import_seo_metadata()
83 180 {
84 181 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
85 182 if (function_exists('set_time_limit')) {
86 - @set_time_limit($execution_time);
183 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
87 184 }
88 185
89 186 Metasync_Settings_Fields::instance()->apply_memory_limit();
90 187
@@ -93,9 +190,9 @@
93 190 if (!Metasync::current_user_has_plugin_access()) {
94 191 wp_send_json_error(['message' => 'Insufficient permissions.']);
95 192 }
96 193
97 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
194 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
98 195 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
99 196 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
100 197 $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
101 198 $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
@@ -466,10 +563,10 @@
466 563 if (!Metasync::current_user_has_plugin_access()) {
467 564 wp_die('Insufficient permissions');
468 565 }
469 566
470 - $uri = sanitize_text_field($_POST['uri']);
471 - $redirect_url = sanitize_url($_POST['redirect_url']);
567 + $uri = sanitize_text_field(wp_unslash($_POST['uri']));
568 + $redirect_url = sanitize_url(wp_unslash($_POST['redirect_url']));
472 569
473 570 if (empty($uri) || empty($redirect_url)) {
474 571 wp_send_json_error('Missing required parameters');
475 572 }
@@ -538,9 +635,9 @@
538 635 try {
539 636 delete_expired_transients(true);
540 637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
541 638
542 - $plugin_transients = $wpdb->get_results(
639 + $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 640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
544 641 ARRAY_A
545 642 );
546 643
@@ -549,9 +646,9 @@
549 646 delete_transient($transient_name);
550 647 $cleanup_stats['plugin_transients']++;
551 648 }
552 649
553 - $rate_limit_transients = $wpdb->get_results(
650 + $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 651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
555 652 ARRAY_A
556 653 );
557 654
@@ -560,9 +657,9 @@
560 657 delete_transient($transient_name);
561 658 $cleanup_stats['rate_limit_transients']++;
562 659 }
563 660
564 - $telemetry_transients = $wpdb->get_results(
661 + $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 662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
566 663 ARRAY_A
567 664 );
568 665
@@ -571,9 +668,9 @@
571 668 delete_transient($transient_name);
572 669 $cleanup_stats['telemetry_transients']++;
573 670 }
574 671
575 - $sa_connect_success_transients = $wpdb->get_results(
672 + $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 673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
577 674 ARRAY_A
578 675 );
579 676
@@ -584,14 +681,12 @@
584 681 }
585 682
586 683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
587 684 $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';
685 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
589 686
590 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
591 -
592 687 } catch (Exception $e) {
593 - error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage());
688 + error_log('MetaSync: Transient cleanup failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
594 689 }
595 690 }
596 691
597 692 public function ajax_submit_issue_report()
@@ -721,9 +816,9 @@
721 816 # Check if Sentry feedback function exists
722 817 if (!function_exists('metasync_sentry_capture_feedback')) {
723 818 # Log warning if function doesn't exist
724 819 if (defined('WP_DEBUG') && WP_DEBUG) {
725 - error_log('MetaSync: Sentry feedback function not available for report submission.');
820 + 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 821 }
727 822 } else {
728 823 $feedback_data = array(
729 824 'message' => $issue_message,
@@ -750,13 +845,11 @@
750 845 ));
751 846 } else {
752 847 # Fallback: Log locally if Sentry fails or is unavailable
753 848 if (defined('WP_DEBUG') && WP_DEBUG) {
754 - error_log(sprintf(
755 - 'MetaSync Client Report (Fallback): UUID: %s | Title: %s | Message: %s | Severity: %s',
849 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
850 + 'MetaSync Client Report (Fallback): UUID: %s | Severity: %s',
756 851 $project_uuid,
757 - $issue_title,
758 - $issue_message,
759 852 $issue_severity
760 853 ));
761 854 }
762 855
@@ -770,9 +863,9 @@
770 863
771 864 } catch (Exception $e) {
772 865 # Log the error securely (only in debug mode)
773 866 if (defined('WP_DEBUG') && WP_DEBUG) {
774 - error_log(sprintf(
867 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
775 868 'MetaSync Report Submission Error: %s in %s on line %d',
776 869 $e->getMessage(),
777 870 $e->getFile(),
778 871 $e->getLine()
@@ -785,8 +878,9 @@
785 878 }
786 879
787 880 public function ajax_recover_password()
788 881 {
882 + $rate_owner = '';
789 883 try {
790 884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
791 885 wp_send_json_error(array('message' => 'Security verification failed.'));
792 886 return;
@@ -791,26 +885,87 @@
791 885 wp_send_json_error(array('message' => 'Security verification failed.'));
792 886 return;
793 887 }
794 888
889 + if (!current_user_can('manage_options')) {
890 + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
891 + return;
892 + }
893 +
894 + if (!$this->acquire_recovery_lock($rate_owner)) {
895 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
896 + return;
897 + }
898 + register_shutdown_function(function () use (&$rate_owner) {
899 + if ($rate_owner !== '') {
900 + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
901 + }
902 + });
903 + $this->refresh_recovery_state_cache();
904 +
795 905 $whitelabel_settings = Metasync::get_whitelabel_settings();
796 - $password = Metasync::get_whitelabel_password();
797 906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
798 907
799 - if (empty($password)) {
908 + $stored_password = $whitelabel_settings['settings_password'] ?? '';
909 + if (!is_string($stored_password) || $stored_password === '') {
910 + $this->release_recovery_lock($rate_owner);
911 + $rate_owner = '';
800 912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
801 913 return;
802 914 }
803 915
804 916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 + $this->release_recovery_lock($rate_owner);
918 + $rate_owner = '';
805 919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
806 920 return;
807 921 }
808 922
923 + // Bound recovery requests per user and per site to limit abuse.
924 + $user_id = get_current_user_id();
925 + $user_rate_key = 'metasync_pw_recovery_user_' . $user_id;
926 + $site_rate_key = 'metasync_pw_recovery_site';
927 + $this->refresh_recovery_state_cache($user_rate_key);
928 + $this->refresh_recovery_state_cache($site_rate_key);
929 + $user_attempts = (int) get_transient($user_rate_key);
930 + $site_attempts = (int) get_transient($site_rate_key);
931 + if ($user_attempts >= 3 || $site_attempts >= 10) {
932 + $this->release_recovery_lock($rate_owner);
933 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
934 + return;
935 + }
936 +
937 + if (!set_transient($user_rate_key, $user_attempts + 1, HOUR_IN_SECONDS) || !set_transient($site_rate_key, $site_attempts + 1, HOUR_IN_SECONDS)) {
938 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
939 + else delete_transient($user_rate_key);
940 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
941 + else delete_transient($site_rate_key);
942 + $this->release_recovery_lock($rate_owner);
943 + wp_send_json_error(array('message' => 'Unable to process recovery request. Please try again later.'));
944 + return;
945 + }
946 +
947 + // Keep only a hash in the database; the raw token is sent once by email.
948 + $token = bin2hex(random_bytes(32));
949 + $token_hash = hash('sha256', $token);
950 + $reset_key = 'metasync_pw_reset_' . $token_hash;
951 + if (!set_transient($reset_key, array('created_by' => $user_id), 30 * MINUTE_IN_SECONDS)) {
952 + if ($user_attempts > 0) set_transient($user_rate_key, $user_attempts, HOUR_IN_SECONDS);
953 + else delete_transient($user_rate_key);
954 + if ($site_attempts > 0) set_transient($site_rate_key, $site_attempts, HOUR_IN_SECONDS);
955 + else delete_transient($site_rate_key);
956 + $this->release_recovery_lock($rate_owner);
957 + $rate_owner = '';
958 + wp_send_json_error(array('message' => 'Unable to create a recovery request. Please try again later.'));
959 + return;
960 + }
961 + $this->release_recovery_lock($rate_owner);
962 + $rate_owner = '';
963 +
809 964 $site_name = get_bloginfo('name');
810 965 $site_url = home_url();
811 966 $plugin_name = Metasync::get_effective_plugin_name('');
812 - $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel');
967 + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '&tab=whitelabel&metasync_password_reset=' . rawurlencode($token));
813 968 $to = $recovery_email;
814 969
815 970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
816 971
@@ -816,16 +971,14 @@
816 971
817 972 $message = sprintf(
818 973 "Hello,\n\n" .
819 974 "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" .
975 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
822 976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
823 977 "---\n" .
824 978 "This is an automated message from %s\n%s",
825 979 $plugin_name,
826 980 $site_name,
827 - $password,
828 981 $settings_url,
829 982 $site_name,
830 983 $site_url
831 984 );
@@ -838,13 +991,8 @@
838 991 'Content-Type: text/plain; charset=UTF-8',
839 992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
840 993 );
841 994
842 - $mail_error = '';
843 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
844 - $mail_error = $error->get_error_message();
845 - });
846 -
847 995 $sent = wp_mail($to, $subject, $message, $headers);
848 996
849 997 if ($sent) {
850 998 wp_send_json_success(array(
@@ -850,21 +998,19 @@
850 998 wp_send_json_success(array(
851 999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
852 1000 ));
853 1001 } 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));
1002 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
862 1003 }
863 1004
864 1005 } catch (Exception $e) {
1006 + // Always release the bounded issuance lock if an unexpected
1007 + // exception occurs after it was acquired.
1008 + if ($rate_owner !== '') {
1009 + $this->release_recovery_lock($rate_owner);
1010 + }
865 1011 if (defined('WP_DEBUG') && WP_DEBUG) {
866 - error_log(sprintf(
1012 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
867 1013 'MetaSync Password Recovery Error: %s in %s on line %d',
868 1014 $e->getMessage(),
869 1015 $e->getFile(),
870 1016 $e->getLine()
@@ -874,8 +1020,113 @@
874 1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
875 1021 }
876 1022 }
877 1023
1024 + /** Render and process the administrator-only password reset screen. */
1025 + public function render_password_reset_page()
1026 + {
1027 + if (!current_user_can('manage_options')) {
1028 + wp_die('You do not have permission to reset this password.', '', array('response' => 403));
1029 + }
1030 +
1031 + $token = isset($_REQUEST['metasync_password_reset']) ? (string) wp_unslash($_REQUEST['metasync_password_reset']) : '';
1032 + $token_hash = ($token !== '' && preg_match('/^[a-f0-9]{64}$/', $token)) ? hash('sha256', $token) : '';
1033 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1034 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1035 + $error = '';
1036 + $success = false;
1037 +
1038 + if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1039 + if (!isset($_POST['metasync_reset_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['metasync_reset_nonce'])), 'metasync_reset_password')) {
1040 + $error = 'Security verification failed.';
1041 + } elseif ($token === '' || false === get_transient($transient_key)) {
1042 + $error = 'This recovery link is invalid or has expired.';
1043 + } else {
1044 + $password = isset($_POST['new_password']) ? (string) wp_unslash($_POST['new_password']) : '';
1045 + $confirm = isset($_POST['confirm_password']) ? (string) wp_unslash($_POST['confirm_password']) : '';
1046 + if (strlen($password) < 8) {
1047 + $error = 'Password must be at least 8 characters long.';
1048 + } elseif (!hash_equals($password, $confirm)) {
1049 + $error = 'Passwords do not match.';
1050 + } elseif (strpos($password, 'enc_v1:') === 0) {
1051 + $error = 'Unable to save this password. Please choose a different password.';
1052 + } else {
1053 + // add_option is an atomic insert in WordPress; it closes the
1054 + // race where two requests attempt to redeem the same token.
1055 + $lock_key = '';
1056 + if (!$this->acquire_recovery_lock($lock_key)) {
1057 + $error = 'Another recovery request is being processed. Please wait a moment and try again.';
1058 + } else {
1059 + register_shutdown_function(function () use (&$lock_key) {
1060 + if ($lock_key !== '') {
1061 + Metasync_Admin_Ajax::instance()->release_recovery_lock($lock_key);
1062 + }
1063 + });
1064 + $this->refresh_recovery_state_cache($transient_key);
1065 + // Refresh the settings version while holding the shared
1066 + // lock. Token keys are independent so issuing or using
1067 + // one link does not invalidate another unexpired link.
1068 + $generation = (int) get_option('metasync_pw_reset_generation', 0);
1069 + $transient_key = $token_hash !== '' ? 'metasync_pw_reset_' . $token_hash : '';
1070 + // Re-check after claiming the lock. Consume only after
1071 + // every required write succeeds, allowing a retry after
1072 + // a temporary database or revocation failure.
1073 + if (false === get_transient($transient_key)) {
1074 + $error = 'This recovery link is invalid or has already been used.';
1075 + $this->release_recovery_lock($lock_key);
1076 + $lock_key = '';
1077 + echo '<div class="wrap"><h1>Reset Settings Password</h1><div class="notice notice-error"><p>' . esc_html($error) . '</p></div></div>';
1078 + return;
1079 + }
1080 + {
1081 + $encrypted = Metasync::encrypt_secret($password);
1082 + if (!Metasync::is_encrypted_secret($encrypted) || $encrypted === $password) {
1083 + $error = 'Unable to securely save this password. Please try again later.';
1084 + } elseif (!update_option('metasync_pw_reset_generation', $generation + 1, false)) {
1085 + $error = 'Unable to prepare the password reset. Please try again later.';
1086 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1087 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1088 + $error = 'Unable to revoke existing access. Please try again later.';
1089 + } elseif (!$this->persist_recovery_password($encrypted)) {
1090 + $error = 'Unable to save the new password. Please try again later.';
1091 + /** @phpstan-ignore-next-line booleanNot.alwaysFalse */
1092 + } elseif (!Metasync_Auth_Manager::revoke_all_access('whitelabel')) {
1093 + $error = 'Unable to finalize access revocation. Please try again later.';
1094 + } elseif (!delete_transient($transient_key)) {
1095 + $error = 'Unable to consume this recovery link. Please try again later.';
1096 + } else {
1097 + $success = true;
1098 + }
1099 + }
1100 + $this->release_recovery_lock($lock_key);
1101 + $lock_key = '';
1102 + }
1103 + }
1104 + }
1105 + }
1106 +
1107 + echo '<div class="wrap"><h1>Reset Settings Password</h1>';
1108 + if ($success) {
1109 + echo '<div class="notice notice-success"><p>Password reset successfully. You can now return to the White Label settings.</p></div>';
1110 + } else {
1111 + if ($error !== '') {
1112 + echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
1113 + }
1114 + if ($token === '' || false === get_transient($transient_key)) {
1115 + if ($error === '') {
1116 + echo '<div class="notice notice-error"><p>This recovery link is invalid or has expired.</p></div>';
1117 + }
1118 + } else {
1119 + echo '<form method="post" style="max-width:420px">';
1120 + wp_nonce_field('metasync_reset_password', 'metasync_reset_nonce');
1121 + 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>';
1122 + 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>';
1123 + echo '<p><button class="button button-primary" type="submit">Save new password</button></p></form>';
1124 + }
1125 + }
1126 + echo '</div>';
1127 + }
1128 +
878 1129 public function ajax_save_theme()
879 1130 {
880 1131 try {
881 1132 # Verify nonce for security
@@ -880,15 +1131,13 @@
880 1131 try {
881 1132 # Verify nonce for security
882 1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
883 1134 wp_send_json_error(array('message' => 'Security verification failed.'));
884 - return;
885 1135 }
886 1136
887 1137 # Check user capabilities
888 1138 if (!Metasync::current_user_has_plugin_access()) {
889 1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
890 - return;
891 1140 }
892 1141
893 1142 # Get and validate theme value
894 1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -895,9 +1144,8 @@
895 1144
896 1145 # Validate theme is either 'light' or 'dark'
897 1146 if (!in_array($theme, array('light', 'dark'), true)) {
898 1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
899 - return;
900 1148 }
901 1149
902 1150 # Save theme preference to WordPress options
903 1151 update_option('metasync_theme', $theme, true);
@@ -910,9 +1158,9 @@
910 1158
911 1159 } catch (Exception $e) {
912 1160 # Log error if debug is enabled
913 1161 if (defined('WP_DEBUG') && WP_DEBUG) {
914 - error_log('MetaSync Theme Save Error: ' . $e->getMessage());
1162 + error_log('MetaSync Theme Save Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
915 1163 }
916 1164
917 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
918 1166 }
@@ -923,9 +1171,8 @@
923 1171 check_ajax_referer('metasync_nonce', 'nonce');
924 1172 # Check user capabilities
925 1173 if (!Metasync::current_user_has_plugin_access()) {
926 1174 wp_send_json_error(['message' => 'Insufficient permissions']);
927 - return;
928 1175 }
929 1176
930 1177 # Get parameters
931 1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1054,9 +1301,9 @@
1054 1301 wp_mkdir_p($temp_dir);
1055 1302 }
1056 1303
1057 1304 # Generate unique filename
1058 - $timestamp = date('Y-m-d_H-i-s');
1305 + $timestamp = gmdate('Y-m-d_H-i-s');
1059 1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1060 1307 $json_filename = 'whitelabel-settings.json';
1061 1308 $zip_path = $temp_dir . '/' . $zip_filename;
1062 1309
@@ -1117,14 +1364,14 @@
1117 1364 header('Pragma: no-cache');
1118 1365 header('Expires: 0');
1119 1366
1120 1367 # Output file and clean up
1121 - readfile($zip_path);
1122 - unlink($zip_path);
1368 + readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streams a large file to output without loading it into memory
1369 + wp_delete_file($zip_path);
1123 1370
1124 1371 # Clean up temp directory if empty
1125 1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1126 - rmdir($temp_dir);
1373 + rmdir($temp_dir); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem would prompt for FTP credentials on non-direct hosts
1127 1374 }
1128 1375
1129 1376 # Exit to prevent WordPress from adding anything to the response
1130 1377 exit;
@@ -1131,12 +1378,12 @@
1131 1378
1132 1379 } catch (Exception $e) {
1133 1380 # Log error if debug is enabled
1134 1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1135 - error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage());
1382 + error_log('MetaSync Whitelabel Export Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1136 1383 }
1137 1384
1138 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1385 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1139 1386 }
1140 1387 }
1141 1388
1142 1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1181,10 +1428,10 @@
1181 1428 public function render_html_pages_dashboard_widget()
1182 1429 {
1183 1430 global $wpdb;
1184 1431
1185 - $query = "
1186 - SELECT p.ID, p.post_title, p.post_modified, p.post_type
1432 + $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
1433 + "SELECT p.ID, p.post_title, p.post_modified, p.post_type
1187 1434 FROM {$wpdb->posts} p
1188 1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1189 1436 WHERE p.post_status = 'publish'
1190 1437 AND p.post_type IN ('post', 'page')
@@ -1191,11 +1438,10 @@
1191 1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1192 1439 GROUP BY p.ID
1193 1440 ORDER BY p.post_modified DESC
1194 1441 LIMIT 10
1195 - ";
1196 -
1197 - $html_pages = $wpdb->get_results($query);
1442 + "
1443 + );
1198 1444 $total_count = count($html_pages);
1199 1445
1200 1446 $label = $this->get_html_source_label();
1201 1447
@@ -1202,13 +1448,14 @@
1202 1448 if (empty($html_pages)) {
1203 1449 echo '<div class="metasync-dashboard-widget-empty">';
1204 1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1205 1451 echo '<p style="text-align: center; color: #666;">';
1452 + /* translators: %s: HTML source label. */
1206 1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1207 1454 echo '</p>';
1208 1455 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');
1456 + echo '<a href="' . esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug)) . '" class="button button-primary">';
1457 + echo esc_html__('Get Started', 'metasync');
1211 1458 echo '</a>';
1212 1459 echo '</p>';
1213 1460 echo '</div>';
1214 1461 return;
@@ -1217,15 +1464,15 @@
1217 1464 echo '<div class="metasync-dashboard-widget">';
1218 1465
1219 1466 echo '<div class="metasync-widget-stats">';
1220 1467 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>';
1468 + echo '<span class="metasync-stat-number">' . esc_html((string) $total_count) . '</span>';
1469 + echo '<span class="metasync-stat-label">' . esc_html__('AI-Generated Pages', 'metasync') . '</span>';
1223 1470 echo '</div>';
1224 1471 echo '</div>';
1225 1472
1226 1473 echo '<div class="metasync-widget-list">';
1227 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1474 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1228 1475 echo '<ul>';
1229 1476
1230 1477 foreach ($html_pages as $page) {
1231 1478 $edit_link = get_edit_post_link($page->ID);
@@ -1238,11 +1485,12 @@
1238 1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1239 1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1240 1487 echo '</a>';
1241 1488 echo '<span class="metasync-page-meta">';
1242 - echo sprintf(__('Updated %s ago', 'metasync'), $time_ago);
1489 + /* translators: %s: human-readable time difference. */
1490 + echo esc_html(sprintf(__('Updated %s ago', 'metasync'), $time_ago));
1243 1491 echo ' • ';
1244 - echo '<a href="' . esc_url($view_link) . '" target="_blank">' . __('View', 'metasync') . '</a>';
1492 + echo '<a href="' . esc_url($view_link) . '" target="_blank">' . esc_html__('View', 'metasync') . '</a>';
1245 1493 echo '</span>';
1246 1494 echo '</div>';
1247 1495 echo '</li>';
1248 1496 }
@@ -1250,10 +1498,10 @@
1250 1498 echo '</ul>';
1251 1499 echo '</div>';
1252 1500
1253 1501 echo '<div class="metasync-widget-footer">';
1254 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1255 - echo __('View All Pages', 'metasync') . ' →';
1502 + echo '<a href="' . esc_url(admin_url("edit.php?post_type=page")) . '">';
1503 + echo esc_html__('View All Pages', 'metasync') . ' →';
1256 1504 echo '</a>';
1257 1505 echo '</div>';
1258 1506
1259 1507 echo '</div>';