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 +466 -254 2.6.10 → 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,11 @@
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'])) : '';
144 + $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
47 145
48 146 if (empty($type) || empty($plugin)) {
49 147 wp_send_json_error(['message' => 'Missing required parameters.']);
50 148 }
@@ -63,9 +161,9 @@
63 161 case 'robots':
64 162 $result = $importer->import_robots($plugin);
65 163 break;
66 164 case 'indexation':
67 - $result = $importer->import_indexation($plugin);
165 + $result = $importer->import_indexation($plugin, ['batch_size' => 50, 'offset' => $offset]);
68 166 break;
69 167 case 'schema':
70 168 $result = $importer->import_schema($plugin);
71 169 break;
@@ -70,8 +168,17 @@
70 168 $result = $importer->import_schema($plugin);
71 169 break;
72 170 }
73 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 +
74 181 if ($result['success']) {
75 182 wp_send_json_success($result);
76 183 } else {
77 184 wp_send_json_error($result);
@@ -81,9 +188,9 @@
81 188 public function ajax_import_seo_metadata()
82 189 {
83 190 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
84 191 if (function_exists('set_time_limit')) {
85 - @set_time_limit($execution_time);
192 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
86 193 }
87 194
88 195 Metasync_Settings_Fields::instance()->apply_memory_limit();
89 196
@@ -92,11 +199,13 @@
92 199 if (!Metasync::current_user_has_plugin_access()) {
93 200 wp_send_json_error(['message' => 'Insufficient permissions.']);
94 201 }
95 202
96 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
203 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
97 204 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
98 205 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
206 + $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
207 + $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
99 208 $overwrite_existing = isset($_POST['overwrite_existing']) ? (bool) intval($_POST['overwrite_existing']) : false;
100 209 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
101 210
102 211 if (empty($plugin)) {
@@ -108,8 +217,10 @@
108 217
109 218 $options = [
110 219 'import_titles' => $import_titles,
111 220 'import_descriptions' => $import_descriptions,
221 + 'import_social_text' => $import_social_text,
222 + 'import_social_images' => $import_social_images,
112 223 'overwrite_existing' => $overwrite_existing,
113 224 'batch_size' => 50,
114 225 'offset' => $offset
115 226 ];
@@ -115,8 +226,12 @@
115 226 ];
116 227
117 228 $result = $importer->import_seo_metadata($plugin, $options);
118 229
230 + Metasync_PostHog::import_batch($plugin, 'seo_metadata', $result, $offset, array(
231 + 'overwrite_existing' => $overwrite_existing,
232 + ));
233 +
119 234 if ($result['success']) {
120 235 wp_send_json_success($result);
121 236 } else {
122 237 wp_send_json_error($result);
@@ -135,10 +250,14 @@
135 250 # use the existing apikey for backward compatibility
136 251 $general_options = Metasync::get_option('general') ?? [];
137 252 $token = $general_options['apikey'] ?? null;
138 253
254 + # declare the call context explicitly so only the Settings
255 + # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
256 + $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
257 +
139 258 # get the response
140 - $response = $sync_request->SyncCustomerParams($token);
259 + $response = $sync_request->SyncCustomerParams($token, $is_heartbeat_tick ? 'heartbeat' : 'manual');
141 260
142 261 // Check if response is a throttling error object
143 262 if (is_object($response) && isset($response->throttled) && $response->throttled === true) {
144 263 wp_send_json($response);
@@ -213,9 +332,9 @@
213 332 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
214 333 $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
215 334
216 335 // Allowlist top-level wizard data keys to prevent mass assignment
217 - $allowed_wizard_keys = array('verification', 'seo_settings', 'schema');
336 + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema', 'analytics');
218 337 $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
219 338
220 339 $options = get_option('metasync_options', array());
221 340
@@ -245,8 +364,13 @@
245 364 $options['general']['enable_schema_markup'] = $data['schema']['enabled'];
246 365 $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']);
247 366 }
248 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 +
249 373 update_option('metasync_options', $options);
250 374
251 375 wp_send_json_success(array('message' => 'Progress saved'));
252 376 }
@@ -268,8 +392,14 @@
268 392
269 393 $user_id = get_current_user_id();
270 394 delete_transient("metasync_wizard_state_{$user_id}");
271 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 +
272 402 wp_send_json_success(array('message' => 'Wizard completed'));
273 403 }
274 404
275 405 public function ajax_validate_robots()
@@ -407,8 +537,48 @@
407 537 }
408 538 }
409 539 }
410 540
541 + public function ajax_get_robots_backups()
542 + {
543 + if (!Metasync::current_user_has_plugin_access()) {
544 + wp_send_json_error(array('message' => esc_html__('Insufficient permissions', 'metasync')));
545 + return;
546 + }
547 +
548 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'metasync_get_robots_backups')) {
549 + wp_send_json_error(array('message' => esc_html__('Security check failed', 'metasync')));
550 + return;
551 + }
552 +
553 + require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
554 + $robots_txt = Metasync_Robots_Txt::get_instance();
555 +
556 + $backup_per_page = Metasync_Robots_Txt_Database::BACKUPS_PER_PAGE;
557 + $backup_total = $robots_txt->get_backup_count();
558 + $backup_total_pages = max(1, (int) ceil($backup_total / $backup_per_page));
559 +
560 + $backup_page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
561 + if ($backup_page > $backup_total_pages) {
562 + $backup_page = $backup_total_pages;
563 + }
564 +
565 + $offset = ($backup_page - 1) * $backup_per_page;
566 + $backups = $robots_txt->get_backup_history($backup_per_page, $offset);
567 +
568 + // Render the shared list + pagination partial to a string.
569 + ob_start();
570 + require plugin_dir_path(dirname(__FILE__)) . 'robots-txt/views/backup-history.php';
571 + $html = ob_get_clean();
572 +
573 + wp_send_json_success(array(
574 + 'html' => $html,
575 + 'total' => $backup_total,
576 + 'page' => $backup_page,
577 + 'total_pages' => $backup_total_pages,
578 + ));
579 + }
580 +
411 581 public function ajax_create_redirect_from_404()
412 582 {
413 583 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
414 584 wp_die('Security check failed');
@@ -417,10 +587,10 @@
417 587 if (!Metasync::current_user_has_plugin_access()) {
418 588 wp_die('Insufficient permissions');
419 589 }
420 590
421 - $uri = sanitize_text_field($_POST['uri']);
422 - $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']));
423 593
424 594 if (empty($uri) || empty($redirect_url)) {
425 595 wp_send_json_error('Missing required parameters');
426 596 }
@@ -438,91 +608,33 @@
438 608 wp_send_json_error('Failed to create redirect');
439 609 }
440 610 }
441 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 + */
442 618 public function ajax_test_host_blocking_get()
443 619 {
444 - check_ajax_referer('metasync_nonce', 'nonce');
445 - if (!Metasync::current_user_has_plugin_access()) {
446 - wp_send_json_error('Insufficient permissions');
447 - return;
448 - }
620 + $this->send_host_blocking_result('GET');
621 + }
449 622
450 - $endpoint = 'https://wp-check.searchatlas.com/ping';
451 - $start_time = microtime(true);
452 -
453 - $response = wp_remote_get($endpoint, array(
454 - 'timeout' => 30,
455 - 'user-agent' => 'MetaSync Plugin Host Test',
456 - 'headers' => array(
457 - 'Accept' => 'application/json',
458 - 'Content-Type' => 'application/json',
459 - 'Origin' => home_url(),
460 - 'Referer' => admin_url(),
461 - 'X-WordPress-Site' => home_url()
462 - )
463 - ));
464 -
465 - $end_time = microtime(true);
466 - $response_time = round(($end_time - $start_time) * 1000, 2);
467 -
468 - if (is_wp_error($response)) {
469 - wp_send_json_success(array(
470 - 'method' => 'GET',
471 - 'status' => 'error',
472 - 'response_time' => $response_time . 'ms',
473 - 'error' => $response->get_error_message(),
474 - 'blocked' => true,
475 - 'details' => 'Request failed - possible blocking detected'
476 - ));
477 - } else {
478 - $status_code = wp_remote_retrieve_response_code($response);
479 - $body = wp_remote_retrieve_body($response);
480 - $headers = wp_remote_retrieve_headers($response);
481 -
482 - $is_blocked = false;
483 - $status_text = 'success';
484 - $details = 'GET request completed successfully';
485 - $parsed_response = null;
486 -
487 - if ($status_code === 200) {
488 - $parsed_response = json_decode($body, true);
489 -
490 - if ($parsed_response && isset($parsed_response['results']['get'])) {
491 - $get_result = $parsed_response['results']['get'];
492 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
493 -
494 - if ($get_status_code !== 200) {
495 - $is_blocked = true;
496 - $status_text = 'error';
497 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
498 - }
499 - } else {
500 - $is_blocked = true;
501 - $status_text = 'error';
502 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
503 - }
504 - } else {
505 - $is_blocked = true;
506 - $status_text = 'error';
507 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
508 - }
509 -
510 - wp_send_json_success(array(
511 - 'method' => 'GET',
512 - 'status' => $status_text,
513 - 'response_time' => $response_time . 'ms',
514 - 'status_code' => $status_code,
515 - 'body' => $body,
516 - 'headers' => $headers->getAll(),
517 - 'blocked' => $is_blocked,
518 - 'details' => $details,
519 - 'parsed_response' => $parsed_response
520 - ));
521 - }
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');
522 629 }
523 630
524 - 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)
525 637 {
526 638 check_ajax_referer('metasync_nonce', 'nonce');
527 639 if (!Metasync::current_user_has_plugin_access()) {
528 640 wp_send_json_error('Insufficient permissions');
@@ -528,90 +640,9 @@
528 640 wp_send_json_error('Insufficient permissions');
529 641 return;
530 642 }
531 643
532 - $endpoint = 'https://wp-check.searchatlas.com/ping';
533 - $start_time = microtime(true);
534 -
535 - $test_data = array(
536 - 'test' => 'host_blocking_test',
537 - 'timestamp' => current_time('mysql'),
538 - 'source' => 'metasync_plugin',
539 - 'method' => 'POST'
540 - );
541 -
542 - $response = wp_remote_post($endpoint, array(
543 - 'timeout' => 30,
544 - 'user-agent' => 'MetaSync Plugin Host Test',
545 - 'headers' => array(
546 - 'Accept' => 'application/json',
547 - 'Content-Type' => 'application/json',
548 - 'Origin' => home_url(),
549 - 'Referer' => admin_url(),
550 - 'X-WordPress-Site' => home_url()
551 - ),
552 - 'body' => json_encode($test_data)
553 - ));
554 -
555 - $end_time = microtime(true);
556 - $response_time = round(($end_time - $start_time) * 1000, 2);
557 -
558 - if (is_wp_error($response)) {
559 - wp_send_json_success(array(
560 - 'method' => 'POST',
561 - 'status' => 'error',
562 - 'response_time' => $response_time . 'ms',
563 - 'error' => $response->get_error_message(),
564 - 'blocked' => true,
565 - 'details' => 'Request failed - possible blocking detected',
566 - 'sent_data' => $test_data
567 - ));
568 - } else {
569 - $status_code = wp_remote_retrieve_response_code($response);
570 - $body = wp_remote_retrieve_body($response);
571 - $headers = wp_remote_retrieve_headers($response);
572 -
573 - $is_blocked = false;
574 - $status_text = 'success';
575 - $details = 'POST request completed successfully';
576 - $parsed_response = null;
577 -
578 - if ($status_code === 200) {
579 - $parsed_response = json_decode($body, true);
580 -
581 - if ($parsed_response && isset($parsed_response['results']['post'])) {
582 - $post_result = $parsed_response['results']['post'];
583 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
584 -
585 - if ($post_status_code !== 200) {
586 - $is_blocked = true;
587 - $status_text = 'error';
588 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
589 - }
590 - } else {
591 - $is_blocked = true;
592 - $status_text = 'error';
593 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
594 - }
595 - } else {
596 - $is_blocked = true;
597 - $status_text = 'error';
598 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
599 - }
600 -
601 - wp_send_json_success(array(
602 - 'method' => 'POST',
603 - 'status' => $status_text,
604 - 'response_time' => $response_time . 'ms',
605 - 'status_code' => $status_code,
606 - 'body' => $body,
607 - 'headers' => $headers->getAll(),
608 - 'blocked' => $is_blocked,
609 - 'details' => $details,
610 - 'sent_data' => $test_data,
611 - 'parsed_response' => $parsed_response
612 - ));
613 - }
644 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
614 645 }
615 646
616 647 public function execute_transient_cleanup()
617 648 {
@@ -628,9 +659,9 @@
628 659 try {
629 660 delete_expired_transients(true);
630 661 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
631 662
632 - $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
633 664 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
634 665 ARRAY_A
635 666 );
636 667
@@ -639,9 +670,9 @@
639 670 delete_transient($transient_name);
640 671 $cleanup_stats['plugin_transients']++;
641 672 }
642 673
643 - $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
644 675 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
645 676 ARRAY_A
646 677 );
647 678
@@ -650,9 +681,9 @@
650 681 delete_transient($transient_name);
651 682 $cleanup_stats['rate_limit_transients']++;
652 683 }
653 684
654 - $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
655 686 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
656 687 ARRAY_A
657 688 );
658 689
@@ -661,9 +692,9 @@
661 692 delete_transient($transient_name);
662 693 $cleanup_stats['telemetry_transients']++;
663 694 }
664 695
665 - $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
666 697 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
667 698 ARRAY_A
668 699 );
669 700
@@ -674,17 +705,50 @@
674 705 }
675 706
676 707 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
677 708 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
678 - 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';
679 710
680 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
681 -
682 711 } catch (Exception $e) {
683 - 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
684 713 }
685 714 }
686 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 +
687 751 public function ajax_submit_issue_report()
688 752 {
689 753 try {
690 754 # Verify nonce
@@ -755,8 +819,11 @@
755 819 );
756 820 }
757 821 }
758 822
823 + # Read the locally stored white-label settings for the report payload.
824 + $whitelabel_context = self::build_issue_report_whitelabel_context();
825 +
759 826 # Get general options (same way as used throughout the plugin)
760 827 $general_options = Metasync::get_option('general');
761 828 if (!is_array($general_options)) {
762 829 $general_options = array();
@@ -811,14 +878,18 @@
811 878 # Check if Sentry feedback function exists
812 879 if (!function_exists('metasync_sentry_capture_feedback')) {
813 880 # Log warning if function doesn't exist
814 881 if (defined('WP_DEBUG') && WP_DEBUG) {
815 - 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
816 883 }
817 884 } else {
818 885 $feedback_data = array(
819 886 'message' => $issue_message,
820 - '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']
821 892 );
822 893
823 894 # Add user information if requested
824 895 if ($include_user_info) {
@@ -840,13 +911,11 @@
840 911 ));
841 912 } else {
842 913 # Fallback: Log locally if Sentry fails or is unavailable
843 914 if (defined('WP_DEBUG') && WP_DEBUG) {
844 - error_log(sprintf(
845 - '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',
846 917 $project_uuid,
847 - $issue_title,
848 - $issue_message,
849 918 $issue_severity
850 919 ));
851 920 }
852 921
@@ -860,9 +929,9 @@
860 929
861 930 } catch (Exception $e) {
862 931 # Log the error securely (only in debug mode)
863 932 if (defined('WP_DEBUG') && WP_DEBUG) {
864 - error_log(sprintf(
933 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
865 934 'MetaSync Report Submission Error: %s in %s on line %d',
866 935 $e->getMessage(),
867 936 $e->getFile(),
868 937 $e->getLine()
@@ -875,8 +944,9 @@
875 944 }
876 945
877 946 public function ajax_recover_password()
878 947 {
948 + $rate_owner = '';
879 949 try {
880 950 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
881 951 wp_send_json_error(array('message' => 'Security verification failed.'));
882 952 return;
@@ -881,26 +951,87 @@
881 951 wp_send_json_error(array('message' => 'Security verification failed.'));
882 952 return;
883 953 }
884 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 +
885 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
886 - $password = $whitelabel_settings['settings_password'] ?? '';
887 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
888 973
889 - 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 = '';
890 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
891 979 return;
892 980 }
893 981
894 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
895 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
896 986 return;
897 987 }
898 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 +
899 1030 $site_name = get_bloginfo('name');
900 1031 $site_url = home_url();
901 1032 $plugin_name = Metasync::get_effective_plugin_name('');
902 - $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));
903 1034 $to = $recovery_email;
904 1035
905 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
906 1037
@@ -906,16 +1037,14 @@
906 1037
907 1038 $message = sprintf(
908 1039 "Hello,\n\n" .
909 1040 "A password recovery request was made for the %s settings on %s.\n\n" .
910 - "Your Settings Password is:\n%s\n\n" .
911 - "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" .
912 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
913 1043 "---\n" .
914 1044 "This is an automated message from %s\n%s",
915 1045 $plugin_name,
916 1046 $site_name,
917 - $password,
918 1047 $settings_url,
919 1048 $site_name,
920 1049 $site_url
921 1050 );
@@ -928,13 +1057,8 @@
928 1057 'Content-Type: text/plain; charset=UTF-8',
929 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
930 1059 );
931 1060
932 - $mail_error = '';
933 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
934 - $mail_error = $error->get_error_message();
935 - });
936 -
937 1061 $sent = wp_mail($to, $subject, $message, $headers);
938 1062
939 1063 if ($sent) {
940 1064 wp_send_json_success(array(
@@ -940,21 +1064,19 @@
940 1064 wp_send_json_success(array(
941 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
942 1066 ));
943 1067 } else {
944 - $error_message = 'Failed to send recovery email. ';
945 - if (!empty($mail_error)) {
946 - $error_message .= 'Error: ' . $mail_error;
947 - } else {
948 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
949 - }
950 -
951 - 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.'));
952 1069 }
953 1070
954 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 + }
955 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
956 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
957 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
958 1080 $e->getMessage(),
959 1081 $e->getFile(),
960 1082 $e->getLine()
@@ -964,8 +1086,113 @@
964 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
965 1087 }
966 1088 }
967 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 +
968 1195 public function ajax_save_theme()
969 1196 {
970 1197 try {
971 1198 # Verify nonce for security
@@ -970,15 +1197,13 @@
970 1197 try {
971 1198 # Verify nonce for security
972 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
973 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
974 - return;
975 1201 }
976 1202
977 1203 # Check user capabilities
978 1204 if (!Metasync::current_user_has_plugin_access()) {
979 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
980 - return;
981 1206 }
982 1207
983 1208 # Get and validate theme value
984 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -985,9 +1210,8 @@
985 1210
986 1211 # Validate theme is either 'light' or 'dark'
987 1212 if (!in_array($theme, array('light', 'dark'), true)) {
988 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
989 - return;
990 1214 }
991 1215
992 1216 # Save theme preference to WordPress options
993 1217 update_option('metasync_theme', $theme, true);
@@ -1000,9 +1224,9 @@
1000 1224
1001 1225 } catch (Exception $e) {
1002 1226 # Log error if debug is enabled
1003 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
1004 - 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
1005 1229 }
1006 1230
1007 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1008 1232 }
@@ -1013,9 +1237,8 @@
1013 1237 check_ajax_referer('metasync_nonce', 'nonce');
1014 1238 # Check user capabilities
1015 1239 if (!Metasync::current_user_has_plugin_access()) {
1016 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
1017 - return;
1018 1241 }
1019 1242
1020 1243 # Get parameters
1021 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1057,32 +1280,20 @@
1057 1280 wp_die('Security verification failed.');
1058 1281 }
1059 1282
1060 1283 try {
1061 - # Get all whitelabel settings
1062 - $whitelabel_settings = Metasync::get_whitelabel_settings();
1063 -
1064 - # Get general settings that relate to whitelabel
1065 - $general_settings = Metasync::get_option('general');
1066 - $whitelabel_related_general = array();
1067 -
1068 - # Include ALL whitelabel-related general settings
1069 - $whitelabel_keys = array(
1070 - 'white_label_plugin_name',
1071 - 'white_label_plugin_description',
1072 - 'white_label_plugin_author',
1073 - 'white_label_plugin_author_uri',
1074 - 'white_label_plugin_uri',
1075 - 'white_label_plugin_menu_slug',
1076 - 'white_label_plugin_menu_icon',
1077 - 'whitelabel_otto_name'
1078 - );
1079 -
1080 - foreach ($whitelabel_keys as $key) {
1081 - if (isset($general_settings[$key])) {
1082 - $whitelabel_related_general[$key] = $general_settings[$key];
1083 - }
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';
1084 1292 }
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'];
1085 1296
1086 1297 # Bundle the menu icon file so it survives import on a different site.
1087 1298 # If the icon is a local URL (media library), replace it with a special
1088 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1136,9 +1347,9 @@
1136 1347 wp_mkdir_p($temp_dir);
1137 1348 }
1138 1349
1139 1350 # Generate unique filename
1140 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1141 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1142 1353 $json_filename = 'whitelabel-settings.json';
1143 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1144 1355
@@ -1199,14 +1410,14 @@
1199 1410 header('Pragma: no-cache');
1200 1411 header('Expires: 0');
1201 1412
1202 1413 # Output file and clean up
1203 - readfile($zip_path);
1204 - 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);
1205 1416
1206 1417 # Clean up temp directory if empty
1207 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1208 - 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
1209 1420 }
1210 1421
1211 1422 # Exit to prevent WordPress from adding anything to the response
1212 1423 exit;
@@ -1213,12 +1424,12 @@
1213 1424
1214 1425 } catch (Exception $e) {
1215 1426 # Log error if debug is enabled
1216 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1217 - 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
1218 1429 }
1219 1430
1220 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1221 1432 }
1222 1433 }
1223 1434
1224 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1263,10 +1474,10 @@
1263 1474 public function render_html_pages_dashboard_widget()
1264 1475 {
1265 1476 global $wpdb;
1266 1477
1267 - $query = "
1268 - 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
1269 1480 FROM {$wpdb->posts} p
1270 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1271 1482 WHERE p.post_status = 'publish'
1272 1483 AND p.post_type IN ('post', 'page')
@@ -1273,11 +1484,10 @@
1273 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1274 1485 GROUP BY p.ID
1275 1486 ORDER BY p.post_modified DESC
1276 1487 LIMIT 10
1277 - ";
1278 -
1279 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1280 1490 $total_count = count($html_pages);
1281 1491
1282 1492 $label = $this->get_html_source_label();
1283 1493
@@ -1284,13 +1494,14 @@
1284 1494 if (empty($html_pages)) {
1285 1495 echo '<div class="metasync-dashboard-widget-empty">';
1286 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1287 1497 echo '<p style="text-align: center; color: #666;">';
1288 - 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>');
1289 1500 echo '</p>';
1290 1501 echo '<p style="text-align: center;">';
1291 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1292 - 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');
1293 1504 echo '</a>';
1294 1505 echo '</p>';
1295 1506 echo '</div>';
1296 1507 return;
@@ -1299,15 +1510,15 @@
1299 1510 echo '<div class="metasync-dashboard-widget">';
1300 1511
1301 1512 echo '<div class="metasync-widget-stats">';
1302 1513 echo '<div class="metasync-stat-box">';
1303 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1304 - 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>';
1305 1516 echo '</div>';
1306 1517 echo '</div>';
1307 1518
1308 1519 echo '<div class="metasync-widget-list">';
1309 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1310 1521 echo '<ul>';
1311 1522
1312 1523 foreach ($html_pages as $page) {
1313 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1320,11 +1531,12 @@
1320 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1321 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1322 1533 echo '</a>';
1323 1534 echo '<span class="metasync-page-meta">';
1324 - 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));
1325 1537 echo ' • ';
1326 - 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>';
1327 1539 echo '</span>';
1328 1540 echo '</div>';
1329 1541 echo '</li>';
1330 1542 }
@@ -1332,10 +1544,10 @@
1332 1544 echo '</ul>';
1333 1545 echo '</div>';
1334 1546
1335 1547 echo '<div class="metasync-widget-footer">';
1336 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1337 - 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') . ' →';
1338 1550 echo '</a>';
1339 1551 echo '</div>';
1340 1552
1341 1553 echo '</div>';