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 +456 -253 2.6.13 → 2.7.2 View file →
@@ -18,8 +18,105 @@
18 18 }
19 19
20 20 private function __construct() {}
21 21
22 + /**
23 + * Discard request-local recovery state after acquiring the shared lock.
24 + *
25 + * A request can wait for another recovery/settings request while its
26 + * WordPress option or transient cache still contains the pre-lock state.
27 + * Refreshing here makes every decision under the lock use current storage.
28 + */
29 + public function refresh_recovery_state_cache($transient_key = '')
30 + {
31 + if (!function_exists('wp_cache_delete')) {
32 + return;
33 + }
34 +
35 + wp_cache_delete('alloptions', 'options');
36 + wp_cache_delete(Metasync::option_name, 'options');
37 + wp_cache_delete('metasync_pw_reset_generation', 'options');
38 +
39 + if ($transient_key !== '') {
40 + wp_cache_delete($transient_key, 'transient');
41 + wp_cache_delete('_transient_' . $transient_key, 'options');
42 + wp_cache_delete('_transient_timeout_' . $transient_key, 'options');
43 + }
44 + }
45 +
46 + public function acquire_recovery_lock(&$owner)
47 + {
48 + $owner = bin2hex(random_bytes(16));
49 + $key = 'metasync_pw_recovery_rate_lock';
50 + if (add_option($key, array('owner' => $owner, 'time' => time()), '', false)) return true;
51 + $held = get_option($key, array());
52 + $held_time = is_array($held) ? (int) ($held['time'] ?? 0) : (int) $held;
53 + // Settings requests may run for up to five minutes. Keep the lease
54 + // comfortably beyond that bound; shutdown cleanup releases it
55 + // promptly during normal completion and handled failures.
56 + if ($held_time > 0 && time() - $held_time > 900) {
57 + $replacement = array('owner' => $owner, 'time' => time());
58 + global $wpdb;
59 + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
60 + $serialize = function ($value) { return function_exists('maybe_serialize') ? maybe_serialize($value) : serialize($value); };
61 + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
62 + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
63 + $serialize($replacement), $key, $serialize($held)
64 + ));
65 + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
66 + } else {
67 + update_option($key, $replacement, false);
68 + }
69 + $claimed = get_option($key, array());
70 + return is_array($claimed) && isset($claimed['owner']) && hash_equals((string) $claimed['owner'], (string) $owner);
71 + }
72 + return false;
73 + }
74 +
75 + public function release_recovery_lock($owner)
76 + {
77 + $key = 'metasync_pw_recovery_rate_lock';
78 + $held = get_option($key, array());
79 + if (is_array($held) && isset($held['owner']) && hash_equals((string) $held['owner'], (string) $owner)) {
80 + global $wpdb;
81 + if (isset($wpdb) && is_object($wpdb) && method_exists($wpdb, 'query') && method_exists($wpdb, 'prepare')) {
82 + $serialized = function_exists('maybe_serialize') ? maybe_serialize($held) : serialize($held);
83 + $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic compare-and-swap on option rows — the WHERE-guarded UPDATE/DELETE is the lock primitive
84 + "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
85 + $key,
86 + $serialized
87 + ));
88 + if (function_exists('wp_cache_delete')) wp_cache_delete($key, 'options');
89 + return;
90 + }
91 +
92 + // Test/bootstrap fallback. Production uses the owner-checked SQL
93 + // delete above so expired-owner cleanup cannot remove a new lock.
94 + delete_option($key);
95 + }
96 + }
97 +
98 + private function persist_recovery_password($encrypted)
99 + {
100 + for ($attempt = 0; $attempt < 3; $attempt++) {
101 + $options = Metasync::get_option();
102 + if (!is_array($options)) $options = array();
103 + if (!isset($options['whitelabel']) || !is_array($options['whitelabel'])) $options['whitelabel'] = array();
104 + $options['whitelabel']['settings_password'] = $encrypted;
105 + Metasync_Settings_Registration::authorize_recovery_password_write(true);
106 + try {
107 + $saved = Metasync::set_option($options);
108 + } finally {
109 + Metasync_Settings_Registration::authorize_recovery_password_write(false);
110 + }
111 + if ($saved) {
112 + $stored = Metasync::get_option('whitelabel');
113 + if (is_array($stored) && isset($stored['settings_password']) && hash_equals((string) $encrypted, (string) $stored['settings_password'])) return true;
114 + }
115 + }
116 + return false;
117 + }
118 +
22 119 private function get_db_redirection()
23 120 {
24 121 if (null === $this->db_redirection) {
25 122 $this->db_redirection = new Metasync_Redirection_Database();
@@ -30,9 +127,9 @@
30 127 public function ajax_import_external_data()
31 128 {
32 129 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
33 130 if (function_exists('set_time_limit')) {
34 - @set_time_limit($execution_time);
131 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
35 132 }
36 133
37 134 Metasync_Settings_Fields::instance()->apply_memory_limit();
38 135
@@ -41,10 +138,10 @@
41 138 if (!Metasync::current_user_has_plugin_access()) {
42 139 wp_send_json_error(['message' => 'Insufficient permissions.']);
43 140 }
44 141
45 - $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
46 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
142 + $type = isset($_POST['type']) ? sanitize_text_field(wp_unslash($_POST['type'])) : '';
143 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
47 144 $offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
48 145
49 146 if (empty($type) || empty($plugin)) {
50 147 wp_send_json_error(['message' => 'Missing required parameters.']);
@@ -71,8 +168,17 @@
71 168 $result = $importer->import_schema($plugin);
72 169 break;
73 170 }
74 171
172 + // A schema import that only found already-migrated or unsupported
173 + // records is an accurate report, not a failure — keep the breakdown
174 + // with the response so the UI can show each category separately.
175 + if (isset($result['breakdown'])) {
176 + $result['breakdown'] = array_map('intval', (array) $result['breakdown']);
177 + }
178 +
179 + Metasync_PostHog::import_batch($plugin, $type, $result, $offset);
180 +
75 181 if ($result['success']) {
76 182 wp_send_json_success($result);
77 183 } else {
78 184 wp_send_json_error($result);
@@ -82,9 +188,9 @@
82 188 public function ajax_import_seo_metadata()
83 189 {
84 190 $execution_time = Metasync_Settings_Fields::instance()->get_execution_setting('max_execution_time');
85 191 if (function_exists('set_time_limit')) {
86 - @set_time_limit($execution_time);
192 + @set_time_limit($execution_time); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- runtime PHP setting with no WordPress API
87 193 }
88 194
89 195 Metasync_Settings_Fields::instance()->apply_memory_limit();
90 196
@@ -93,9 +199,9 @@
93 199 if (!Metasync::current_user_has_plugin_access()) {
94 200 wp_send_json_error(['message' => 'Insufficient permissions.']);
95 201 }
96 202
97 - $plugin = isset($_POST['plugin']) ? sanitize_text_field($_POST['plugin']) : '';
203 + $plugin = isset($_POST['plugin']) ? sanitize_text_field(wp_unslash($_POST['plugin'])) : '';
98 204 $import_titles = isset($_POST['import_titles']) ? (bool) intval($_POST['import_titles']) : true;
99 205 $import_descriptions = isset($_POST['import_descriptions']) ? (bool) intval($_POST['import_descriptions']) : true;
100 206 $import_social_text = isset($_POST['import_social_text']) ? (bool) intval($_POST['import_social_text']) : true;
101 207 $import_social_images = isset($_POST['import_social_images']) ? (bool) intval($_POST['import_social_images']) : true;
@@ -120,8 +226,12 @@
120 226 ];
121 227
122 228 $result = $importer->import_seo_metadata($plugin, $options);
123 229
230 + Metasync_PostHog::import_batch($plugin, 'seo_metadata', $result, $offset, array(
231 + 'overwrite_existing' => $overwrite_existing,
232 + ));
233 +
124 234 if ($result['success']) {
125 235 wp_send_json_success($result);
126 236 } else {
127 237 wp_send_json_error($result);
@@ -140,9 +250,9 @@
140 250 # use the existing apikey for backward compatibility
141 251 $general_options = Metasync::get_option('general') ?? [];
142 252 $token = $general_options['apikey'] ?? null;
143 253
144 - # WP-426: declare the call context explicitly so only the Settings
254 + # declare the call context explicitly so only the Settings
145 255 # "Sync Now" button consumes/stamps the 5-minute manual cooldown.
146 256 $is_heartbeat_tick = filter_var($_POST['is_heart_beat'] ?? false, FILTER_VALIDATE_BOOLEAN);
147 257
148 258 # get the response
@@ -222,9 +332,9 @@
222 332 $step = isset($_POST['step']) ? intval($_POST['step']) : 0;
223 333 $raw_data = isset($_POST['data']) ? $_POST['data'] : array();
224 334
225 335 // Allowlist top-level wizard data keys to prevent mass assignment
226 - $allowed_wizard_keys = array('verification', 'seo_settings', 'schema');
336 + $allowed_wizard_keys = array('verification', 'seo_settings', 'schema', 'analytics');
227 337 $data = is_array($raw_data) ? array_intersect_key($raw_data, array_flip($allowed_wizard_keys)) : array();
228 338
229 339 $options = get_option('metasync_options', array());
230 340
@@ -254,8 +364,13 @@
254 364 $options['general']['enable_schema_markup'] = $data['schema']['enabled'];
255 365 $options['general']['default_schema_type'] = sanitize_text_field($data['schema']['default_type']);
256 366 }
257 367
368 + if (isset($data['analytics']) && is_array($data['analytics'])) {
369 + update_option('metasync_analytics_opt_in', !empty($data['analytics']['enabled']) ? 'yes' : 'no');
370 + update_option('metasync_analytics_consent_explicit', 'yes');
371 + }
372 +
258 373 update_option('metasync_options', $options);
259 374
260 375 wp_send_json_success(array('message' => 'Progress saved'));
261 376 }
@@ -277,8 +392,14 @@
277 392
278 393 $user_id = get_current_user_id();
279 394 delete_transient("metasync_wizard_state_{$user_id}");
280 395
396 + // Consent-gated product analytics: setup funnel completion.
397 + $general_settings = Metasync::get_option('general');
398 + Metasync_PostHog::feature('setup_wizard_completed', array(
399 + 'connected' => !empty($general_settings['searchatlas_api_key']),
400 + ));
401 +
281 402 wp_send_json_success(array('message' => 'Wizard completed'));
282 403 }
283 404
284 405 public function ajax_validate_robots()
@@ -416,8 +537,48 @@
416 537 }
417 538 }
418 539 }
419 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 +
420 581 public function ajax_create_redirect_from_404()
421 582 {
422 583 if (empty($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_404_redirect')) {
423 584 wp_die('Security check failed');
@@ -426,10 +587,10 @@
426 587 if (!Metasync::current_user_has_plugin_access()) {
427 588 wp_die('Insufficient permissions');
428 589 }
429 590
430 - $uri = sanitize_text_field($_POST['uri']);
431 - $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']));
432 593
433 594 if (empty($uri) || empty($redirect_url)) {
434 595 wp_send_json_error('Missing required parameters');
435 596 }
@@ -447,91 +608,33 @@
447 608 wp_send_json_error('Failed to create redirect');
448 609 }
449 610 }
450 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 + */
451 618 public function ajax_test_host_blocking_get()
452 619 {
453 - check_ajax_referer('metasync_nonce', 'nonce');
454 - if (!Metasync::current_user_has_plugin_access()) {
455 - wp_send_json_error('Insufficient permissions');
456 - return;
457 - }
620 + $this->send_host_blocking_result('GET');
621 + }
458 622
459 - $endpoint = 'https://wp-check.searchatlas.com/ping';
460 - $start_time = microtime(true);
461 -
462 - $response = wp_remote_get($endpoint, array(
463 - 'timeout' => 30,
464 - 'user-agent' => 'MetaSync Plugin Host Test',
465 - 'headers' => array(
466 - 'Accept' => 'application/json',
467 - 'Content-Type' => 'application/json',
468 - 'Origin' => home_url(),
469 - 'Referer' => admin_url(),
470 - 'X-WordPress-Site' => home_url()
471 - )
472 - ));
473 -
474 - $end_time = microtime(true);
475 - $response_time = round(($end_time - $start_time) * 1000, 2);
476 -
477 - if (is_wp_error($response)) {
478 - wp_send_json_success(array(
479 - 'method' => 'GET',
480 - 'status' => 'error',
481 - 'response_time' => $response_time . 'ms',
482 - 'error' => $response->get_error_message(),
483 - 'blocked' => true,
484 - 'details' => 'Request failed - possible blocking detected'
485 - ));
486 - } else {
487 - $status_code = wp_remote_retrieve_response_code($response);
488 - $body = wp_remote_retrieve_body($response);
489 - $headers = wp_remote_retrieve_headers($response);
490 -
491 - $is_blocked = false;
492 - $status_text = 'success';
493 - $details = 'GET request completed successfully';
494 - $parsed_response = null;
495 -
496 - if ($status_code === 200) {
497 - $parsed_response = json_decode($body, true);
498 -
499 - if ($parsed_response && isset($parsed_response['results']['get'])) {
500 - $get_result = $parsed_response['results']['get'];
501 - $get_status_code = isset($get_result['statusCode']) ? $get_result['statusCode'] : null;
502 -
503 - if ($get_status_code !== 200) {
504 - $is_blocked = true;
505 - $status_text = 'error';
506 - $details = "GET request to target site returned status code {$get_status_code} - host blocking detected";
507 - }
508 - } else {
509 - $is_blocked = true;
510 - $status_text = 'error';
511 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
512 - }
513 - } else {
514 - $is_blocked = true;
515 - $status_text = 'error';
516 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
517 - }
518 -
519 - wp_send_json_success(array(
520 - 'method' => 'GET',
521 - 'status' => $status_text,
522 - 'response_time' => $response_time . 'ms',
523 - 'status_code' => $status_code,
524 - 'body' => $body,
525 - 'headers' => $headers->getAll(),
526 - 'blocked' => $is_blocked,
527 - 'details' => $details,
528 - 'parsed_response' => $parsed_response
529 - ));
530 - }
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');
531 629 }
532 630
533 - 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)
534 637 {
535 638 check_ajax_referer('metasync_nonce', 'nonce');
536 639 if (!Metasync::current_user_has_plugin_access()) {
537 640 wp_send_json_error('Insufficient permissions');
@@ -537,90 +640,9 @@
537 640 wp_send_json_error('Insufficient permissions');
538 641 return;
539 642 }
540 643
541 - $endpoint = 'https://wp-check.searchatlas.com/ping';
542 - $start_time = microtime(true);
543 -
544 - $test_data = array(
545 - 'test' => 'host_blocking_test',
546 - 'timestamp' => current_time('mysql'),
547 - 'source' => 'metasync_plugin',
548 - 'method' => 'POST'
549 - );
550 -
551 - $response = wp_remote_post($endpoint, array(
552 - 'timeout' => 30,
553 - 'user-agent' => 'MetaSync Plugin Host Test',
554 - 'headers' => array(
555 - 'Accept' => 'application/json',
556 - 'Content-Type' => 'application/json',
557 - 'Origin' => home_url(),
558 - 'Referer' => admin_url(),
559 - 'X-WordPress-Site' => home_url()
560 - ),
561 - 'body' => json_encode($test_data)
562 - ));
563 -
564 - $end_time = microtime(true);
565 - $response_time = round(($end_time - $start_time) * 1000, 2);
566 -
567 - if (is_wp_error($response)) {
568 - wp_send_json_success(array(
569 - 'method' => 'POST',
570 - 'status' => 'error',
571 - 'response_time' => $response_time . 'ms',
572 - 'error' => $response->get_error_message(),
573 - 'blocked' => true,
574 - 'details' => 'Request failed - possible blocking detected',
575 - 'sent_data' => $test_data
576 - ));
577 - } else {
578 - $status_code = wp_remote_retrieve_response_code($response);
579 - $body = wp_remote_retrieve_body($response);
580 - $headers = wp_remote_retrieve_headers($response);
581 -
582 - $is_blocked = false;
583 - $status_text = 'success';
584 - $details = 'POST request completed successfully';
585 - $parsed_response = null;
586 -
587 - if ($status_code === 200) {
588 - $parsed_response = json_decode($body, true);
589 -
590 - if ($parsed_response && isset($parsed_response['results']['post'])) {
591 - $post_result = $parsed_response['results']['post'];
592 - $post_status_code = isset($post_result['statusCode']) ? $post_result['statusCode'] : null;
593 -
594 - if ($post_status_code !== 200) {
595 - $is_blocked = true;
596 - $status_text = 'error';
597 - $details = "POST request to target site returned status code {$post_status_code} - host blocking detected";
598 - }
599 - } else {
600 - $is_blocked = true;
601 - $status_text = 'error';
602 - $details = 'Unable to parse response structure - possible blocking or endpoint issue';
603 - }
604 - } else {
605 - $is_blocked = true;
606 - $status_text = 'error';
607 - $details = "External endpoint returned status code {$status_code} - possible blocking detected";
608 - }
609 -
610 - wp_send_json_success(array(
611 - 'method' => 'POST',
612 - 'status' => $status_text,
613 - 'response_time' => $response_time . 'ms',
614 - 'status_code' => $status_code,
615 - 'body' => $body,
616 - 'headers' => $headers->getAll(),
617 - 'blocked' => $is_blocked,
618 - 'details' => $details,
619 - 'sent_data' => $test_data,
620 - 'parsed_response' => $parsed_response
621 - ));
622 - }
644 + wp_send_json_success(Metasync_Host_Blocking_Check::get_instance()->run_check($method));
623 645 }
624 646
625 647 public function execute_transient_cleanup()
626 648 {
@@ -637,9 +659,9 @@
637 659 try {
638 660 delete_expired_transients(true);
639 661 $cleanup_stats['expired_transients'] = 'cleaned_by_wordpress';
640 662
641 - $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
642 664 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_%'",
643 665 ARRAY_A
644 666 );
645 667
@@ -648,9 +670,9 @@
648 670 delete_transient($transient_name);
649 671 $cleanup_stats['plugin_transients']++;
650 672 }
651 673
652 - $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
653 675 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_sa_connect_rate_limit_%'",
654 676 ARRAY_A
655 677 );
656 678
@@ -659,9 +681,9 @@
659 681 delete_transient($transient_name);
660 682 $cleanup_stats['rate_limit_transients']++;
661 683 }
662 684
663 - $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
664 686 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_telemetry_%'",
665 687 ARRAY_A
666 688 );
667 689
@@ -670,9 +692,9 @@
670 692 delete_transient($transient_name);
671 693 $cleanup_stats['telemetry_transients']++;
672 694 }
673 695
674 - $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
675 697 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE '_transient_metasync_sa_connect_success_%'",
676 698 ARRAY_A
677 699 );
678 700
@@ -683,17 +705,50 @@
683 705 }
684 706
685 707 $cleanup_stats['execution_time'] = round((microtime(true) - $cleanup_stats['start_time']) * 1000, 2);
686 708 $cleanup_stats['next_run'] = wp_next_scheduled('metasync_cleanup_transients') ?
687 - 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';
688 710
689 - error_log('MetaSync: Transient cleanup completed - ' . json_encode($cleanup_stats));
690 -
691 711 } catch (Exception $e) {
692 - 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
693 713 }
694 714 }
695 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 +
696 751 public function ajax_submit_issue_report()
697 752 {
698 753 try {
699 754 # Verify nonce
@@ -764,8 +819,11 @@
764 819 );
765 820 }
766 821 }
767 822
823 + # Read the locally stored white-label settings for the report payload.
824 + $whitelabel_context = self::build_issue_report_whitelabel_context();
825 +
768 826 # Get general options (same way as used throughout the plugin)
769 827 $general_options = Metasync::get_option('general');
770 828 if (!is_array($general_options)) {
771 829 $general_options = array();
@@ -820,14 +878,18 @@
820 878 # Check if Sentry feedback function exists
821 879 if (!function_exists('metasync_sentry_capture_feedback')) {
822 880 # Log warning if function doesn't exist
823 881 if (defined('WP_DEBUG') && WP_DEBUG) {
824 - 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
825 883 }
826 884 } else {
827 885 $feedback_data = array(
828 886 'message' => $issue_message,
829 - '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']
830 892 );
831 893
832 894 # Add user information if requested
833 895 if ($include_user_info) {
@@ -849,13 +911,11 @@
849 911 ));
850 912 } else {
851 913 # Fallback: Log locally if Sentry fails or is unavailable
852 914 if (defined('WP_DEBUG') && WP_DEBUG) {
853 - error_log(sprintf(
854 - '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',
855 917 $project_uuid,
856 - $issue_title,
857 - $issue_message,
858 918 $issue_severity
859 919 ));
860 920 }
861 921
@@ -869,9 +929,9 @@
869 929
870 930 } catch (Exception $e) {
871 931 # Log the error securely (only in debug mode)
872 932 if (defined('WP_DEBUG') && WP_DEBUG) {
873 - error_log(sprintf(
933 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
874 934 'MetaSync Report Submission Error: %s in %s on line %d',
875 935 $e->getMessage(),
876 936 $e->getFile(),
877 937 $e->getLine()
@@ -884,8 +944,9 @@
884 944 }
885 945
886 946 public function ajax_recover_password()
887 947 {
948 + $rate_owner = '';
888 949 try {
889 950 if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'metasync_recover_password_nonce')) {
890 951 wp_send_json_error(array('message' => 'Security verification failed.'));
891 952 return;
@@ -890,26 +951,87 @@
890 951 wp_send_json_error(array('message' => 'Security verification failed.'));
891 952 return;
892 953 }
893 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 +
894 971 $whitelabel_settings = Metasync::get_whitelabel_settings();
895 - $password = $whitelabel_settings['settings_password'] ?? '';
896 972 $recovery_email = $whitelabel_settings['recovery_email'] ?? '';
897 973
898 - 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 = '';
899 978 wp_send_json_error(array('message' => 'No password is configured for recovery.'));
900 979 return;
901 980 }
902 981
903 982 if (empty($recovery_email) || !is_email($recovery_email)) {
983 + $this->release_recovery_lock($rate_owner);
984 + $rate_owner = '';
904 985 wp_send_json_error(array('message' => 'No valid recovery email is configured. Please contact your administrator.'));
905 986 return;
906 987 }
907 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 +
908 1030 $site_name = get_bloginfo('name');
909 1031 $site_url = home_url();
910 1032 $plugin_name = Metasync::get_effective_plugin_name('');
911 - $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));
912 1034 $to = $recovery_email;
913 1035
914 1036 $subject = sprintf('[%s] Settings Password Recovery', $site_name);
915 1037
@@ -915,16 +1037,14 @@
915 1037
916 1038 $message = sprintf(
917 1039 "Hello,\n\n" .
918 1040 "A password recovery request was made for the %s settings on %s.\n\n" .
919 - "Your Settings Password is:\n%s\n\n" .
920 - "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" .
921 1042 "If you did not request this password recovery, please secure your WordPress admin account immediately.\n\n" .
922 1043 "---\n" .
923 1044 "This is an automated message from %s\n%s",
924 1045 $plugin_name,
925 1046 $site_name,
926 - $password,
927 1047 $settings_url,
928 1048 $site_name,
929 1049 $site_url
930 1050 );
@@ -937,13 +1057,8 @@
937 1057 'Content-Type: text/plain; charset=UTF-8',
938 1058 sprintf('From: %s <%s>', $from_name, get_option('admin_email'))
939 1059 );
940 1060
941 - $mail_error = '';
942 - add_action('wp_mail_failed', function($error) use (&$mail_error) {
943 - $mail_error = $error->get_error_message();
944 - });
945 -
946 1061 $sent = wp_mail($to, $subject, $message, $headers);
947 1062
948 1063 if ($sent) {
949 1064 wp_send_json_success(array(
@@ -949,21 +1064,19 @@
949 1064 wp_send_json_success(array(
950 1065 'message' => sprintf('Password recovery email sent to %s', esc_html($recovery_email))
951 1066 ));
952 1067 } else {
953 - $error_message = 'Failed to send recovery email. ';
954 - if (!empty($mail_error)) {
955 - $error_message .= 'Error: ' . $mail_error;
956 - } else {
957 - $error_message .= 'Your server may not be configured to send emails. Please check your email configuration or contact your administrator.';
958 - }
959 -
960 - 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.'));
961 1069 }
962 1070
963 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 + }
964 1077 if (defined('WP_DEBUG') && WP_DEBUG) {
965 - error_log(sprintf(
1078 + error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
966 1079 'MetaSync Password Recovery Error: %s in %s on line %d',
967 1080 $e->getMessage(),
968 1081 $e->getFile(),
969 1082 $e->getLine()
@@ -973,8 +1086,113 @@
973 1086 wp_send_json_error(array('message' => 'An error occurred while processing your request. Please try again later.'));
974 1087 }
975 1088 }
976 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 +
977 1195 public function ajax_save_theme()
978 1196 {
979 1197 try {
980 1198 # Verify nonce for security
@@ -979,15 +1197,13 @@
979 1197 try {
980 1198 # Verify nonce for security
981 1199 if (!isset($_POST['_ajax_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_ajax_nonce'])), 'metasync_theme_nonce')) {
982 1200 wp_send_json_error(array('message' => 'Security verification failed.'));
983 - return;
984 1201 }
985 1202
986 1203 # Check user capabilities
987 1204 if (!Metasync::current_user_has_plugin_access()) {
988 1205 wp_send_json_error(array('message' => 'Insufficient permissions.'));
989 - return;
990 1206 }
991 1207
992 1208 # Get and validate theme value
993 1209 $theme = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
@@ -994,9 +1210,8 @@
994 1210
995 1211 # Validate theme is either 'light' or 'dark'
996 1212 if (!in_array($theme, array('light', 'dark'), true)) {
997 1213 wp_send_json_error(array('message' => 'Invalid theme value.'));
998 - return;
999 1214 }
1000 1215
1001 1216 # Save theme preference to WordPress options
1002 1217 update_option('metasync_theme', $theme, true);
@@ -1009,9 +1224,9 @@
1009 1224
1010 1225 } catch (Exception $e) {
1011 1226 # Log error if debug is enabled
1012 1227 if (defined('WP_DEBUG') && WP_DEBUG) {
1013 - 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
1014 1229 }
1015 1230
1016 1231 wp_send_json_error(array('message' => 'Failed to save theme preference.'));
1017 1232 }
@@ -1022,9 +1237,8 @@
1022 1237 check_ajax_referer('metasync_nonce', 'nonce');
1023 1238 # Check user capabilities
1024 1239 if (!Metasync::current_user_has_plugin_access()) {
1025 1240 wp_send_json_error(['message' => 'Insufficient permissions']);
1026 - return;
1027 1241 }
1028 1242
1029 1243 # Get parameters
1030 1244 $auth_method = isset($_POST['auth_method']) ? sanitize_text_field(wp_unslash($_POST['auth_method'])) : 'searchatlas_connect';
@@ -1066,32 +1280,20 @@
1066 1280 wp_die('Security verification failed.');
1067 1281 }
1068 1282
1069 1283 try {
1070 - # Get all whitelabel settings
1071 - $whitelabel_settings = Metasync::get_whitelabel_settings();
1072 -
1073 - # Get general settings that relate to whitelabel
1074 - $general_settings = Metasync::get_option('general');
1075 - $whitelabel_related_general = array();
1076 -
1077 - # Include ALL whitelabel-related general settings
1078 - $whitelabel_keys = array(
1079 - 'white_label_plugin_name',
1080 - 'white_label_plugin_description',
1081 - 'white_label_plugin_author',
1082 - 'white_label_plugin_author_uri',
1083 - 'white_label_plugin_uri',
1084 - 'white_label_plugin_menu_slug',
1085 - 'white_label_plugin_menu_icon',
1086 - 'whitelabel_otto_name'
1087 - );
1088 -
1089 - foreach ($whitelabel_keys as $key) {
1090 - if (isset($general_settings[$key])) {
1091 - $whitelabel_related_general[$key] = $general_settings[$key];
1092 - }
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';
1093 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'];
1094 1296
1095 1297 # Bundle the menu icon file so it survives import on a different site.
1096 1298 # If the icon is a local URL (media library), replace it with a special
1097 1299 # marker and include the actual file in the ZIP under the plugin folder.
@@ -1145,9 +1347,9 @@
1145 1347 wp_mkdir_p($temp_dir);
1146 1348 }
1147 1349
1148 1350 # Generate unique filename
1149 - $timestamp = date('Y-m-d_H-i-s');
1351 + $timestamp = gmdate('Y-m-d_H-i-s');
1150 1352 $zip_filename = 'metasync-whitelabel-plugin-' . $timestamp . '.zip';
1151 1353 $json_filename = 'whitelabel-settings.json';
1152 1354 $zip_path = $temp_dir . '/' . $zip_filename;
1153 1355
@@ -1208,14 +1410,14 @@
1208 1410 header('Pragma: no-cache');
1209 1411 header('Expires: 0');
1210 1412
1211 1413 # Output file and clean up
1212 - readfile($zip_path);
1213 - 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);
1214 1416
1215 1417 # Clean up temp directory if empty
1216 1418 if (is_dir($temp_dir) && count(scandir($temp_dir)) == 2) {
1217 - 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
1218 1420 }
1219 1421
1220 1422 # Exit to prevent WordPress from adding anything to the response
1221 1423 exit;
@@ -1222,12 +1424,12 @@
1222 1424
1223 1425 } catch (Exception $e) {
1224 1426 # Log error if debug is enabled
1225 1427 if (defined('WP_DEBUG') && WP_DEBUG) {
1226 - 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
1227 1429 }
1228 1430
1229 - wp_die('Failed to export whitelabel settings: ' . $e->getMessage());
1431 + wp_die(esc_html__('Failed to export whitelabel settings.', 'metasync'));
1230 1432 }
1231 1433 }
1232 1434
1233 1435 private static function add_directory_to_zip($zip, $dir, $zip_path = '', $exclude_patterns = array())
@@ -1272,10 +1474,10 @@
1272 1474 public function render_html_pages_dashboard_widget()
1273 1475 {
1274 1476 global $wpdb;
1275 1477
1276 - $query = "
1277 - 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
1278 1480 FROM {$wpdb->posts} p
1279 1481 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1280 1482 WHERE p.post_status = 'publish'
1281 1483 AND p.post_type IN ('post', 'page')
@@ -1282,11 +1484,10 @@
1282 1484 AND (pm.meta_key = '_metasync_raw_html_enabled' OR pm.meta_key = '_metasync_custom_css')
1283 1485 GROUP BY p.ID
1284 1486 ORDER BY p.post_modified DESC
1285 1487 LIMIT 10
1286 - ";
1287 -
1288 - $html_pages = $wpdb->get_results($query);
1488 + "
1489 + );
1289 1490 $total_count = count($html_pages);
1290 1491
1291 1492 $label = $this->get_html_source_label();
1292 1493
@@ -1293,13 +1494,14 @@
1293 1494 if (empty($html_pages)) {
1294 1495 echo '<div class="metasync-dashboard-widget-empty">';
1295 1496 echo '<span class="dashicons dashicons-admin-page" style="font-size: 48px; opacity: 0.3; display: block; margin: 20px auto;"></span>';
1296 1497 echo '<p style="text-align: center; color: #666;">';
1297 - 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>');
1298 1500 echo '</p>';
1299 1501 echo '<p style="text-align: center;">';
1300 - echo '<a href="' . admin_url('admin.php?page=' . Metasync_Admin::$page_slug) . '" class="button button-primary">';
1301 - 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');
1302 1504 echo '</a>';
1303 1505 echo '</p>';
1304 1506 echo '</div>';
1305 1507 return;
@@ -1308,15 +1510,15 @@
1308 1510 echo '<div class="metasync-dashboard-widget">';
1309 1511
1310 1512 echo '<div class="metasync-widget-stats">';
1311 1513 echo '<div class="metasync-stat-box">';
1312 - echo '<span class="metasync-stat-number">' . $total_count . '</span>';
1313 - 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>';
1314 1516 echo '</div>';
1315 1517 echo '</div>';
1316 1518
1317 1519 echo '<div class="metasync-widget-list">';
1318 - echo '<h4>' . __('Recent Pages', 'metasync') . '</h4>';
1520 + echo '<h4>' . esc_html__('Recent Pages', 'metasync') . '</h4>';
1319 1521 echo '<ul>';
1320 1522
1321 1523 foreach ($html_pages as $page) {
1322 1524 $edit_link = get_edit_post_link($page->ID);
@@ -1329,11 +1531,12 @@
1329 1531 echo '<a href="' . esc_url($edit_link) . '" class="metasync-page-title">';
1330 1532 echo esc_html($page->post_title ?: __('(no title)', 'metasync'));
1331 1533 echo '</a>';
1332 1534 echo '<span class="metasync-page-meta">';
1333 - 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));
1334 1537 echo ' • ';
1335 - 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>';
1336 1539 echo '</span>';
1337 1540 echo '</div>';
1338 1541 echo '</li>';
1339 1542 }
@@ -1341,10 +1544,10 @@
1341 1544 echo '</ul>';
1342 1545 echo '</div>';
1343 1546
1344 1547 echo '<div class="metasync-widget-footer">';
1345 - echo '<a href="' . admin_url('edit.php?post_type=page') . '">';
1346 - 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') . ' →';
1347 1550 echo '</a>';
1348 1551 echo '</div>';
1349 1552
1350 1553 echo '</div>';