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 +415 -260 2.6.19 → 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 }
@@ -487,91 +608,33 @@
487 608 wp_send_json_error('Failed to create redirect');
488 609 }
489 610 }
490 611
612 + /**
613 + * Manual "Host Blocking Test" — GET leg.
614 + *
615 + * The probe itself lives in Metasync_Host_Blocking_Check so the manual button and the
616 + * automatic activation/weekly check share one implementation.
617 + */
491 618 public function ajax_test_host_blocking_get()
492 619 {
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 - }
620 + $this->send_host_blocking_result('GET');
621 + }
498 622
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 - }
623 + /**
624 + * Manual "Host Blocking Test" — POST leg.
625 + */
626 + public function ajax_test_host_blocking_post()
627 + {
628 + $this->send_host_blocking_result('POST');
571 629 }
572 630
573 - public function ajax_test_host_blocking_post()
631 + /**
632 + * Shared auth + response wrapper for both manual host blocking legs.
633 + *
634 + * @param string $method 'GET' or 'POST'.
635 + */
636 + private function send_host_blocking_result($method)
574 637 {
575 638 check_ajax_referer('metasync_nonce', 'nonce');
576 639 if (!Metasync::current_user_has_plugin_access()) {
577 640 wp_send_json_error('Insufficient permissions');
@@ -577,90 +640,9 @@
577 640 wp_send_json_error('Insufficient permissions');
578 641 return;
579 642 }
580 643
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 - }
644 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
663 645 }
664 646
665 647 public function execute_transient_cleanup()
666 648 {
@@ -677,9 +659,9 @@
677 659 try {
678 660 delete_expired_transients(true);
679 661 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
680 662
681 - $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
682 664 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
683 665 ARRAY_A
684 666 );
685 667
@@ -688,9 +670,9 @@
688 670 delete_transient($transient_name);
689 671 $cleanup_stats['plugin_transients']++;
690 672 }
691 673
692 - $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
693 675 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
694 676 ARRAY_A
695 677 );
696 678
@@ -699,9 +681,9 @@
699 681 delete_transient($transient_name);
700 682 $cleanup_stats['rate_limit_transients']++;
701 683 }
702 684
703 - $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
704 686 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
705 687 ARRAY_A
706 688 );
707 689
@@ -710,9 +692,9 @@
710 692 delete_transient($transient_name);
711 693 $cleanup_stats['telemetry_transients']++;
712 694 }
713 695
714 - $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
715 697 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
716 698 ARRAY_A
717 699 );
718 700
@@ -723,17 +705,50 @@
723 705 }
724 706
725 707 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
726 708 $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';
709 + gmdate('Y-m-d H:i:s T', wp_next_scheduled('metasync_cleanup_transients')) : 'N/A';
728 710
729 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
730 -
731 711 } catch (Exception $e) {
732 - 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
733 713 }
734 714 }
735 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 +
736 751 public function ajax_submit_issue_report()
737 752 {
738 753 try {
739 754 # Verify nonce
@@ -804,8 +819,11 @@
804 819 );
805 820 }
806 821 }
807 822
823 + # Read the locally stored white-label settings for the report payload.
824 + $whitelabel_context = self::build_issue_report_whitelabel_context();
825 +
808 826 # Get general options (same way as used throughout the plugin)
809 827 $general_options = Metasync::get_option('general');
810 828 if (!is_array($general_options)) {
811 829 $general_options = array();
@@ -860,14 +878,18 @@
860 878 # Check if Sentry feedback function exists
861 879 if (!function_exists('metasync_sentry_capture_feedback')) {
862 880 # Log warning if function doesn't exist
863 881 if (defined('WP_DEBUG') && WP_DEBUG) {
864 - 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
865 883 }
866 884 } else {
867 885 $feedback_data = array(
868 886 'message' => $issue_message,
869 - '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']
870 892 );
871 893
872 894 # Add user information if requested
873 895 if ($include_user_info) {
@@ -889,13 +911,11 @@
889 911 ));
890 912 } else {
891 913 # Fallback: Log locally if Sentry fails or is unavailable
892 914 if (defined('WP_DEBUG') && WP_DEBUG) {
893 - error_log(sprintf(
894 - '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',
895 917 $project_uuid,
896 - $issue_title,
897 - $issue_message,
898 918 $issue_severity
899 919 ));
900 920 }
901 921
@@ -909,9 +929,9 @@
909 929
910 930 } catch (Exception $e) {
911 931 # Log the error securely (only in debug mode)
912 932 if (defined('WP_DEBUG') && WP_DEBUG) {
913 - error_log(sprintf(
933 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
914 934 'MetaSync Report Submission Error: %s in %s on line %d',
915 935 $e->getMessage(),
916 936 $e->getFile(),
917 937 $e->getLine()
@@ -924,8 +944,9 @@
924 944 }
925 945
926 946 public function ajax_recover_password()
927 947 {
948 + $rate_owner = '';
928 949 try {
929 950 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
930 951 wp_send_json_error(array('message' => 'Security verification failed.'));
931 952 return;
@@ -930,26 +951,87 @@
930 951 wp_send_json_error(array('message' => 'Security verification failed.'));
931 952 return;
932 953 }
933 954
955 + if (!current_user_can('manage_options')) {
956 + wp_send_json_error(array('message' => 'You do not have permission to request a password recovery.'), 403);
957 + return;
958 + }
959 +
960 + if (!$this->acquire_recovery_lock($rate_owner)) {
961 + wp_send_json_error(array('message' => 'Too many recovery requests. Please try again later.'), 429);
962 + return;
963 + }
964 + register_shutdown_function(function () use (&$rate_owner) {
965 + if ($rate_owner !== '') {
966 + Metasync_Admin_Ajax::instance()->release_recovery_lock($rate_owner);
967 + }
968 + });
969 + $this->refresh_recovery_state_cache();
970 +
934 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
935 - $password = Metasync::get_whitelabel_password();
936 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
937 973
938 - 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 = '';
939 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
940 979 return;
941 980 }
942 981
943 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
944 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
945 986 return;
946 987 }
947 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 +
948 1030 $site_name = get_bloginfo('name');
949 1031 $site_url = home_url();
950 1032 $plugin_name = Metasync::get_effective_plugin_name('');
951 - $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));
952 1034 $to = $recovery_email;
953 1035
954 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
955 1037
@@ -955,16 +1037,14 @@
955 1037
956 1038 $message = sprintf(
957 1039 "Hello,\n\n" .
958 1040 "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" .
1041 + "Use this one-time link to choose a new Settings Password (valid for 30 minutes):\n%s\n\n" .
961 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
962 1043 "---\n" .
963 1044 "This is an automated message from %s\n%s",
964 1045 $plugin_name,
965 1046 $site_name,
966 - $password,
967 1047 $settings_url,
968 1048 $site_name,
969 1049 $site_url
970 1050 );
@@ -977,13 +1057,8 @@
977 1057 'Content-Type: text/plain; charset=UTF-8',
978 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
979 1059 );
980 1060
981 - $mail_error = '';
982 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
983 - $mail_error = $error->get_error_message();
984 - });
985 -
986 1061 $sent = wp_mail($to, $subject, $message, $headers);
987 1062
988 1063 if ($sent) {
989 1064 wp_send_json_success(array(
@@ -989,21 +1064,19 @@
989 1064 wp_send_json_success(array(
990 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
991 1066 ));
992 1067 } 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));
1068 + wp_send_json_error(array('message' => 'Failed to send recovery email. Please check your email configuration or contact your administrator.'));
1001 1069 }
1002 1070
1003 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 + }
1004 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
1005 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1006 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
1007 1080 $e->getMessage(),
1008 1081 $e->getFile(),
1009 1082 $e->getLine()
@@ -1013,8 +1086,113 @@
1013 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
1014 1087 }
1015 1088 }
1016 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 +
1017 1195 public function ajax_save_theme()
1018 1196 {
1019 1197 try {
1020 1198 # Verify nonce for security
@@ -1019,15 +1197,13 @@
1019 1197 try {
1020 1198 # Verify nonce for security
1021 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
1022 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
1023 - return;
1024 1201 }
1025 1202
1026 1203 # Check user capabilities
1027 1204 if (!Metasync::current_user_has_plugin_access()) {
1028 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
1029 - return;
1030 1206 }
1031 1207
1032 1208 # Get and validate theme value
1033 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -1034,9 +1210,8 @@
1034 1210
1035 1211 # Validate theme is either 'light' or 'dark'
1036 1212 if (!in_array($theme, array('light', 'dark'), true)) {
1037 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
1038 - return;
1039 1214 }
1040 1215
1041 1216 # Save theme preference to WordPress options
1042 1217 update_option('metasync_theme', $theme, true);
@@ -1049,9 +1224,9 @@
1049 1224
1050 1225 } catch (Exception $e) {
1051 1226 # Log error if debug is enabled
1052 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
1053 - 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
1054 1229 }
1055 1230
1056 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1057 1232 }
@@ -1062,9 +1237,8 @@
1062 1237 check_ajax_referer('metasync_nonce', 'nonce');
1063 1238 # Check user capabilities
1064 1239 if (!Metasync::current_user_has_plugin_access()) {
1065 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
1066 - return;
1067 1241 }
1068 1242
1069 1243 # Get parameters
1070 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1106,40 +1280,20 @@
1106 1280 wp_die('Security verification failed.');
1107 1281 }
1108 1282
1109 1283 try {
1110 - # Get all whitelabel settings
1111 - $whitelabel_settings = Metasync::get_whitelabel_settings();
1112 -
1113 - # Export the settings password as plaintext (not the encrypted blob):
1114 - # the encryption key derives from this site's salts, so the blob
1115 - # would be undecryptable after import on a different site. The
1116 - # importer re-encrypts it at rest with the destination site's salts.
1117 - if (!empty($whitelabel_settings['settings_password'])) {
1118 - $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';
1119 1292 }
1120 -
1121 - # Get general settings that relate to whitelabel
1122 - $general_settings = Metasync::get_option('general');
1123 - $whitelabel_related_general = array();
1124 -
1125 - # Include ALL whitelabel-related general settings
1126 - $whitelabel_keys = array(
1127 - 'white_label_plugin_name',
1128 - 'white_label_plugin_description',
1129 - 'white_label_plugin_author',
1130 - 'white_label_plugin_author_uri',
1131 - 'white_label_plugin_uri',
1132 - 'white_label_plugin_menu_slug',
1133 - 'white_label_plugin_menu_icon',
1134 - 'whitelabel_otto_name'
1135 - );
1136 -
1137 - foreach ($whitelabel_keys as $key) {
1138 - if (isset($general_settings[$key])) {
1139 - $whitelabel_related_general[$key] = $general_settings[$key];
1140 - }
1141 - }
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'];
1142 1296
1143 1297 # Bundle the menu icon file so it survives import on a different site.
1144 1298 # If the icon is a local URL (media library), replace it with a special
1145 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1193,9 +1347,9 @@
1193 1347 wp_mkdir_p($temp_dir);
1194 1348 }
1195 1349
1196 1350 # Generate unique filename
1197 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1198 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1199 1353 $json_filename = 'whitelabel-settings.json';
1200 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1201 1355
@@ -1256,14 +1410,14 @@
1256 1410 header('Pragma: no-cache');
1257 1411 header('Expires: 0');
1258 1412
1259 1413 # Output file and clean up
1260 - readfile($zip_path);
1261 - 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);
1262 1416
1263 1417 # Clean up temp directory if empty
1264 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1265 - 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
1266 1420 }
1267 1421
1268 1422 # Exit to prevent WordPress from adding anything to the response
1269 1423 exit;
@@ -1270,12 +1424,12 @@
1270 1424
1271 1425 } catch (Exception $e) {
1272 1426 # Log error if debug is enabled
1273 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1274 - 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
1275 1429 }
1276 1430
1277 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1278 1432 }
1279 1433 }
1280 1434
1281 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1320,10 +1474,10 @@
1320 1474 public function render_html_pages_dashboard_widget()
1321 1475 {
1322 1476 global $wpdb;
1323 1477
1324 - $query = "
1325 - 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
1326 1480 FROM {$wpdb->posts} p
1327 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1328 1482 WHERE p.post_status = 'publish'
1329 1483 AND p.post_type IN ('post', 'page')
@@ -1330,11 +1484,10 @@
1330 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1331 1485 GROUP BY p.ID
1332 1486 ORDER BY p.post_modified DESC
1333 1487 LIMIT 10
1334 - ";
1335 -
1336 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1337 1490 $total_count = count($html_pages);
1338 1491
1339 1492 $label = $this->get_html_source_label();
1340 1493
@@ -1341,13 +1494,14 @@
1341 1494 if (empty($html_pages)) {
1342 1495 echo '<div class="metasync-dashboard-widget-empty">';
1343 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1344 1497 echo '<p style="text-align: center; color: #666;">';
1345 - 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>');
1346 1500 echo '</p>';
1347 1501 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');
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');
1350 1504 echo '</a>';
1351 1505 echo '</p>';
1352 1506 echo '</div>';
1353 1507 return;
@@ -1356,15 +1510,15 @@
1356 1510 echo '<div class="metasync-dashboard-widget">';
1357 1511
1358 1512 echo '<div class="metasync-widget-stats">';
1359 1513 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>';
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>';
1362 1516 echo '</div>';
1363 1517 echo '</div>';
1364 1518
1365 1519 echo '<div class="metasync-widget-list">';
1366 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1367 1521 echo '<ul>';
1368 1522
1369 1523 foreach ($html_pages as $page) {
1370 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1377,11 +1531,12 @@
1377 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1378 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1379 1533 echo '</a>';
1380 1534 echo '<span class="metasync-page-meta">';
1381 - 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));
1382 1537 echo ' • ';
1383 - 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>';
1384 1539 echo '</span>';
1385 1540 echo '</div>';
1386 1541 echo '</li>';
1387 1542 }
@@ -1389,10 +1544,10 @@
1389 1544 echo '</ul>';
1390 1545 echo '</div>';
1391 1546
1392 1547 echo '<div class="metasync-widget-footer">';
1393 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1394 - 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') . ' →';
1395 1550 echo '</a>';
1396 1551 echo '</div>';
1397 1552
1398 1553 echo '</div>';