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 +335 -226 2.6.20 → 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 }
@@ -487,91 +584,33 @@
487 584 wp_send_json_error('Failed to create redirect');
488 585 }
489 586 }
490 587
588 + /**
589 + * Manual "Host Blocking Test" — GET leg.
590 + *
591 + * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the
592 + * automatic activation/weekly check share one implementation.
593 + */
491 594 public function ajax_test_host_blocking_get()
492 595 {
493 - check_ajax_referer('metasync_nonce', 'nonce');
494 - if (!Metasync::current_user_has_plugin_access()) {
495 - wp_send_json_error('Insufficient permissions');
496 - return;
497 - }
596 + $this->send_host_blocking_result('GET');
597 + }
498 598
499 - $endpoint = 'https://wp-check.searchatlas.com/ping';
500 - $start_time = microtime(true);
501 -
502 - $response = wp_remote_get($endpoint, array(
503 - 'timeout' => 30,
504 - 'user-agent' => 'MetaSync Plugin Host Test',
505 - 'headers' => array(
506 - 'Accept' => 'application/json',
507 - 'Content-Type' => 'application/json',
508 - 'Origin' => home_url(),
509 - 'Referer' => admin_url(),
510 - 'X-WordPress-Site' => home_url()
511 - )
512 - ));
513 -
514 - $end_time = microtime(true);
515 - $response_time = round(($end_time - $start_time) * 1000, 2);
516 -
517 - if (is_wp_error($response)) {
518 - wp_send_json_success(array(
519 - 'method' => 'GET',
520 - 'status' => 'error',
521 - 'response_time' => $response_time . 'ms',
522 - 'error' => $response->get_error_message(),
523 - 'blocked' => true,
524 - 'details' => 'Request failed - possible blocking detected'
525 - ));
526 - } else {
527 - $status_code = wp_remote_retrieve_response_code($response);
528 - $body = wp_remote_retrieve_body($response);
529 - $headers = wp_remote_retrieve_headers($response);
530 -
531 - $is_blocked = false;
532 - $status_text = 'success';
533 - $details = 'GET request completed successfully';
534 - $parsed_response = null;
535 -
536 - if ($status_code === 200) {
537 - $parsed_response = json_decode($body, true);
538 -
539 - if ($parsed_response && isset($parsed_response['results']['get'])) {
540 - $get_result = $parsed_response['results']['get'];
541 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
542 -
543 - if ($get_status_code !== 200) {
544 - $is_blocked = true;
545 - $status_text = 'error';
546 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
547 - }
548 - } else {
549 - $is_blocked = true;
550 - $status_text = 'error';
551 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
552 - }
553 - } else {
554 - $is_blocked = true;
555 - $status_text = 'error';
556 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
557 - }
558 -
559 - wp_send_json_success(array(
560 - 'method' => 'GET',
561 - 'status' => $status_text,
562 - 'response_time' => $response_time . 'ms',
563 - 'status_code' => $status_code,
564 - 'body' => $body,
565 - 'headers' => $headers->getAll(),
566 - 'blocked' => $is_blocked,
567 - 'details' => $details,
568 - 'parsed_response' => $parsed_response
569 - ));
570 - }
599 + /**
600 + * Manual "Host Blocking Test" — POST leg.
601 + */
602 + public function ajax_test_host_blocking_post()
603 + {
604 + $this->send_host_blocking_result('POST');
571 605 }
572 606
573 - public function ajax_test_host_blocking_post()
607 + /**
608 + * Shared auth + response wrapper for both manual host blocking legs.
609 + *
610 + * @param string $method 'GET' or 'POST'.
611 + */
612 + private function send_host_blocking_result($method)
574 613 {
575 614 check_ajax_referer('metasync_nonce', 'nonce');
576 615 if (!Metasync::current_user_has_plugin_access()) {
577 616 wp_send_json_error('Insufficient permissions');
@@ -577,90 +616,9 @@
577 616 wp_send_json_error('Insufficient permissions');
578 617 return;
579 618 }
580 619
581 - $endpoint = 'https://wp-check.searchatlas.com/ping';
582 - $start_time = microtime(true);
583 -
584 - $test_data = array(
585 - 'test' => 'host_blocking_test',
586 - 'timestamp' => current_time('mysql'),
587 - 'source' => 'metasync_plugin',
588 - 'method' => 'POST'
589 - );
590 -
591 - $response = wp_remote_post($endpoint, array(
592 - 'timeout' => 30,
593 - 'user-agent' => 'MetaSync Plugin Host Test',
594 - 'headers' => array(
595 - 'Accept' => 'application/json',
596 - 'Content-Type' => 'application/json',
597 - 'Origin' => home_url(),
598 - 'Referer' => admin_url(),
599 - 'X-WordPress-Site' => home_url()
600 - ),
601 - 'body' => json_encode($test_data)
602 - ));
603 -
604 - $end_time = microtime(true);
605 - $response_time = round(($end_time - $start_time) * 1000, 2);
606 -
607 - if (is_wp_error($response)) {
608 - wp_send_json_success(array(
609 - 'method' => 'POST',
610 - 'status' => 'error',
611 - 'response_time' => $response_time . 'ms',
612 - 'error' => $response->get_error_message(),
613 - 'blocked' => true,
614 - 'details' => 'Request failed - possible blocking detected',
615 - 'sent_data' => $test_data
616 - ));
617 - } else {
618 - $status_code = wp_remote_retrieve_response_code($response);
619 - $body = wp_remote_retrieve_body($response);
620 - $headers = wp_remote_retrieve_headers($response);
621 -
622 - $is_blocked = false;
623 - $status_text = 'success';
624 - $details = 'POST request completed successfully';
625 - $parsed_response = null;
626 -
627 - if ($status_code === 200) {
628 - $parsed_response = json_decode($body, true);
629 -
630 - if ($parsed_response && isset($parsed_response['results']['post'])) {
631 - $post_result = $parsed_response['results']['post'];
632 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
633 -
634 - if ($post_status_code !== 200) {
635 - $is_blocked = true;
636 - $status_text = 'error';
637 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
638 - }
639 - } else {
640 - $is_blocked = true;
641 - $status_text = 'error';
642 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
643 - }
644 - } else {
645 - $is_blocked = true;
646 - $status_text = 'error';
647 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
648 - }
649 -
650 - wp_send_json_success(array(
651 - 'method' => 'POST',
652 - 'status' => $status_text,
653 - 'response_time' => $response_time . 'ms',
654 - 'status_code' => $status_code,
655 - 'body' => $body,
656 - 'headers' => $headers->getAll(),
657 - 'blocked' => $is_blocked,
658 - 'details' => $details,
659 - 'sent_data' => $test_data,
660 - 'parsed_response' => $parsed_response
661 - ));
662 - }
620 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
663 621 }
664 622
665 623 public function execute_transient_cleanup()
666 624 {
@@ -677,9 +635,9 @@
677 635 try {
678 636 delete_expired_transients(true);
679 637 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
680 638
681 - $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
682 640 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
683 641 ARRAY_A
684 642 );
685 643
@@ -688,9 +646,9 @@
688 646 delete_transient($transient_name);
689 647 $cleanup_stats['plugin_transients']++;
690 648 }
691 649
692 - $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
693 651 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
694 652 ARRAY_A
695 653 );
696 654
@@ -699,9 +657,9 @@
699 657 delete_transient($transient_name);
700 658 $cleanup_stats['rate_limit_transients']++;
701 659 }
702 660
703 - $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
704 662 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
705 663 ARRAY_A
706 664 );
707 665
@@ -710,9 +668,9 @@
710 668 delete_transient($transient_name);
711 669 $cleanup_stats['telemetry_transients']++;
712 670 }
713 671
714 - $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
715 673 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
716 674 ARRAY_A
717 675 );
718 676
@@ -723,14 +681,12 @@
723 681 }
724 682
725 683 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
726 684 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
727 - 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';
728 686
729 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
730 -
731 687 } catch (Exception $e) {
732 - 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
733 689 }
734 690 }
735 691
736 692 public function ajax_submit_issue_report()
@@ -860,9 +816,9 @@
860 816 # Check if Sentry feedback function exists
861 817 if (!function_exists('metasync_sentry_capture_feedback')) {
862 818 # Log warning if function doesn't exist
863 819 if (defined('WP_DEBUG') && WP_DEBUG) {
864 - 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
865 821 }
866 822 } else {
867 823 $feedback_data = array(
868 824 'message' => $issue_message,
@@ -889,13 +845,11 @@
889 845 ));
890 846 } else {
891 847 # Fallback: Log locally if Sentry fails or is unavailable
892 848 if (defined('WP_DEBUG') && WP_DEBUG) {
893 - error_log(sprintf(
894 - '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',
895 851 $project_uuid,
896 - $issue_title,
897 - $issue_message,
898 852 $issue_severity
899 853 ));
900 854 }
901 855
@@ -909,9 +863,9 @@
909 863
910 864 } catch (Exception $e) {
911 865 # Log the error securely (only in debug mode)
912 866 if (defined('WP_DEBUG') && WP_DEBUG) {
913 - error_log(sprintf(
867 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
914 868 'MetaSync Report Submission Error: %s in %s on line %d',
915 869 $e->getMessage(),
916 870 $e->getFile(),
917 871 $e->getLine()
@@ -924,8 +878,9 @@
924 878 }
925 879
926 880 public function ajax_recover_password()
927 881 {
882 + $rate_owner = '';
928 883 try {
929 884 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
930 885 wp_send_json_error(array('message' => 'Security verification failed.'));
931 886 return;
@@ -930,26 +885,87 @@
930 885 wp_send_json_error(array('message' => 'Security verification failed.'));
931 886 return;
932 887 }
933 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 +
934 905 $whitelabel_settings = Metasync::get_whitelabel_settings();
935 - $password = Metasync::get_whitelabel_password();
936 906 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
937 907
938 - 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 = '';
939 912 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
940 913 return;
941 914 }
942 915
943 916 if (empty($recovery_email) || !is_email($recovery_email)) {
917 + $this->release_recovery_lock($rate_owner);
918 + $rate_owner = '';
944 919 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
945 920 return;
946 921 }
947 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 +
948 964 $site_name = get_bloginfo('name');
949 965 $site_url = home_url();
950 966 $plugin_name = Metasync::get_effective_plugin_name('');
951 - $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));
952 968 $to = $recovery_email;
953 969
954 970 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
955 971
@@ -955,16 +971,14 @@
955 971
956 972 $message = sprintf(
957 973 "Hello,\n\n" .
958 974 "A password recovery request was made for the %s settings on %s.\n\n" .
959 - "Your Settings Password is:\n%s\n\n" .
960 - "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" .
961 976 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
962 977 "---\n" .
963 978 "This is an automated message from %s\n%s",
964 979 $plugin_name,
965 980 $site_name,
966 - $password,
967 981 $settings_url,
968 982 $site_name,
969 983 $site_url
970 984 );
@@ -977,13 +991,8 @@
977 991 'Content-Type: text/plain; charset=UTF-8',
978 992 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
979 993 );
980 994
981 - $mail_error = '';
982 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
983 - $mail_error = $error->get_error_message();
984 - });
985 -
986 995 $sent = wp_mail($to, $subject, $message, $headers);
987 996
988 997 if ($sent) {
989 998 wp_send_json_success(array(
@@ -989,21 +998,19 @@
989 998 wp_send_json_success(array(
990 999 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
991 1000 ));
992 1001 } else {
993 - $error_message = 'Failed to send recovery email. ';
994 - if (!empty($mail_error)) {
995 - $error_message .= 'Error: ' . $mail_error;
996 - } else {
997 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
998 - }
999 -
1000 - 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.'));
1001 1003 }
1002 1004
1003 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 + }
1004 1011 if (defined('WP_DEBUG') && WP_DEBUG) {
1005 - error_log(sprintf(
1012 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1006 1013 'MetaSync Password Recovery Error: %s in %s on line %d',
1007 1014 $e->getMessage(),
1008 1015 $e->getFile(),
1009 1016 $e->getLine()
@@ -1013,8 +1020,113 @@
1013 1020 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
1014 1021 }
1015 1022 }
1016 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 +
1017 1129 public function ajax_save_theme()
1018 1130 {
1019 1131 try {
1020 1132 # Verify nonce for security
@@ -1019,15 +1131,13 @@
1019 1131 try {
1020 1132 # Verify nonce for security
1021 1133 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
1022 1134 wp_send_json_error(array('message' => 'Security verification failed.'));
1023 - return;
1024 1135 }
1025 1136
1026 1137 # Check user capabilities
1027 1138 if (!Metasync::current_user_has_plugin_access()) {
1028 1139 wp_send_json_error(array('message' => 'Insufficient permissions.'));
1029 - return;
1030 1140 }
1031 1141
1032 1142 # Get and validate theme value
1033 1143 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -1034,9 +1144,8 @@
1034 1144
1035 1145 # Validate theme is either 'light' or 'dark'
1036 1146 if (!in_array($theme, array('light', 'dark'), true)) {
1037 1147 wp_send_json_error(array('message' => 'Invalid theme value.'));
1038 - return;
1039 1148 }
1040 1149
1041 1150 # Save theme preference to WordPress options
1042 1151 update_option('metasync_theme', $theme, true);
@@ -1049,9 +1158,9 @@
1049 1158
1050 1159 } catch (Exception $e) {
1051 1160 # Log error if debug is enabled
1052 1161 if (defined('WP_DEBUG') && WP_DEBUG) {
1053 - 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
1054 1163 }
1055 1164
1056 1165 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1057 1166 }
@@ -1062,9 +1171,8 @@
1062 1171 check_ajax_referer('metasync_nonce', 'nonce');
1063 1172 # Check user capabilities
1064 1173 if (!Metasync::current_user_has_plugin_access()) {
1065 1174 wp_send_json_error(['message' => 'Insufficient permissions']);
1066 - return;
1067 1175 }
1068 1176
1069 1177 # Get parameters
1070 1178 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1193,9 +1301,9 @@
1193 1301 wp_mkdir_p($temp_dir);
1194 1302 }
1195 1303
1196 1304 # Generate unique filename
1197 - $timestamp = date('Y-m-d_H-i-s');
1305 + $timestamp = gmdate('Y-m-d_H-i-s');
1198 1306 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1199 1307 $json_filename = 'whitelabel-settings.json';
1200 1308 $zip_path = $temp_dir . '/' . $zip_filename;
1201 1309
@@ -1256,14 +1364,14 @@
1256 1364 header('Pragma: no-cache');
1257 1365 header('Expires: 0');
1258 1366
1259 1367 # Output file and clean up
1260 - readfile($zip_path);
1261 - 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);
1262 1370
1263 1371 # Clean up temp directory if empty
1264 1372 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1265 - 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
1266 1374 }
1267 1375
1268 1376 # Exit to prevent WordPress from adding anything to the response
1269 1377 exit;
@@ -1270,12 +1378,12 @@
1270 1378
1271 1379 } catch (Exception $e) {
1272 1380 # Log error if debug is enabled
1273 1381 if (defined('WP_DEBUG') && WP_DEBUG) {
1274 - 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
1275 1383 }
1276 1384
1277 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1385 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1278 1386 }
1279 1387 }
1280 1388
1281 1389 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1320,10 +1428,10 @@
1320 1428 public function render_html_pages_dashboard_widget()
1321 1429 {
1322 1430 global $wpdb;
1323 1431
1324 - $query = "
1325 - 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
1326 1434 FROM {$wpdb->posts} p
1327 1435 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1328 1436 WHERE p.post_status = 'publish'
1329 1437 AND p.post_type IN ('post', 'page')
@@ -1330,11 +1438,10 @@
1330 1438 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1331 1439 GROUP BY p.ID
1332 1440 ORDER BY p.post_modified DESC
1333 1441 LIMIT 10
1334 - ";
1335 -
1336 - $html_pages = $wpdb->get_results($query);
1442 + "
1443 + );
1337 1444 $total_count = count($html_pages);
1338 1445
1339 1446 $label = $this->get_html_source_label();
1340 1447
@@ -1341,13 +1448,14 @@
1341 1448 if (empty($html_pages)) {
1342 1449 echo '<div class="metasync-dashboard-widget-empty">';
1343 1450 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1344 1451 echo '<p style="text-align: center; color: #666;">';
1452 + /* translators: %s: HTML source label. */
1345 1453 echo sprintf(__('No pages created with %s yet.', 'metasync'), '<strong>' . esc_html($label) . '</strong>');
1346 1454 echo '</p>';
1347 1455 echo '<p style="text-align: center;">';
1348 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1349 - 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');
1350 1458 echo '</a>';
1351 1459 echo '</p>';
1352 1460 echo '</div>';
1353 1461 return;
@@ -1356,15 +1464,15 @@
1356 1464 echo '<div class="metasync-dashboard-widget">';
1357 1465
1358 1466 echo '<div class="metasync-widget-stats">';
1359 1467 echo '<div class="metasync-stat-box">';
1360 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1361 - 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>';
1362 1470 echo '</div>';
1363 1471 echo '</div>';
1364 1472
1365 1473 echo '<div class="metasync-widget-list">';
1366 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1474 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1367 1475 echo '<ul>';
1368 1476
1369 1477 foreach ($html_pages as $page) {
1370 1478 $edit_link = get_edit_post_link($page->ID);
@@ -1377,11 +1485,12 @@
1377 1485 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1378 1486 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1379 1487 echo '</a>';
1380 1488 echo '<span class="metasync-page-meta">';
1381 - 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));
1382 1491 echo ' • ';
1383 - 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>';
1384 1493 echo '</span>';
1385 1494 echo '</div>';
1386 1495 echo '</li>';
1387 1496 }
@@ -1389,10 +1498,10 @@
1389 1498 echo '</ul>';
1390 1499 echo '</div>';
1391 1500
1392 1501 echo '<div class="metasync-widget-footer">';
1393 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1394 - 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') . ' →';
1395 1504 echo '</a>';
1396 1505 echo '</div>';
1397 1506
1398 1507 echo '</div>';