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