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